mirror of
https://github.com/tabler/tabler.git
synced 2026-10-12 05:06:55 +04:00
Add layout settings to the theme switcher (#2948)
Co-authored-by: Bartek <xbartoszdobija@gmail.com>
This commit is contained in:
1 parent
080d3aae76
commit
70f996bf77
53 files changed
+1161
-542
No files matched your search
@@ -45,19 +45,24 @@ Do not pass `pageHeader` when it repeats `title` — the fallback already covers
|
|||||||
|
|
||||||
## 3. `DefaultLayout` — chrome flags
|
## 3. `DefaultLayout` — chrome flags
|
||||||
|
|
||||||
Each flag maps to one documented body/layout class, so reach for the prop instead of `bodyClass`:
|
Every page renders **both** navigations — the vertical sidebar and the horizontal navbar — and the layout is chosen by `data-bs-*` attributes on `<html>`, which the theme settings panel can change at runtime. Props that map to an attribute state the page's own layout; a visitor's stored choice still wins.
|
||||||
|
|
||||||
| Prop | Class |
|
| Prop | Result |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `sidebar`, `sidebarDark`, `sidebarEnd` | `layout-sidebar`, `layout-sidebar-dark`, `layout-sidebar-end` |
|
| `navbarPosition` | `data-bs-navbar-position` — `horizontal` (default) or `vertical` |
|
||||||
| `hideTopbar` | `layout-hide-topbar` |
|
| `layout` | `data-bs-layout` — `fluid` or `boxed` |
|
||||||
| `navbarTransparent`, `navbarCondensed`, `navbarDark`, `navbarOverlap`, `navbarSticky`, `navbarHideBrand` | the matching `layout-navbar-*` classes |
|
| `navbarSticky` | `data-bs-navbar="sticky"` |
|
||||||
| `navbarClass` | extra classes on the navbar |
|
| `navbarDark` | `data-bs-navbar-theme="dark"` — the dark palette on whichever navigation shows |
|
||||||
|
| `sidebarMode` | `data-bs-sidebar` — `folded` or `folded-hover` |
|
||||||
|
| `sidebarEnd`, `sidebarFoldToggle` | sidebar markup: end side, pin button |
|
||||||
|
| `navbarTransparent`, `navbarCondensed`, `navbarOverlap` | navbar markup variants |
|
||||||
| `wrapperFull` | `page-wrapper-full`, drops the `.container-xl` around the body |
|
| `wrapperFull` | `page-wrapper-full`, drops the `.container-xl` around the body |
|
||||||
| `containerCentered`, `containerClass` | `my-auto` / extra classes on the `.container-xl` |
|
| `containerCentered`, `containerClass` | `my-auto` / extra classes on the `.container-xl` |
|
||||||
| `bodyClass` | free-form body classes — `layout-boxed`, `layout-fluid` |
|
| `bodyClass` | free-form body classes; use `layout` for the container width |
|
||||||
| `rtl` | `dir="rtl"` plus the RTL stylesheets |
|
| `rtl` | `dir="rtl"` plus the RTL stylesheets |
|
||||||
|
|
||||||
|
There is no `sidebar`, `hideTopbar` or `sidebarUser` prop any more: which navigation shows is `navbarPosition`, not which one is rendered, and the sidebar always carries the user block, like the navbar always carries the user menu.
|
||||||
|
|
||||||
## 4. The page-header slot
|
## 4. The page-header slot
|
||||||
|
|
||||||
Anything to the right of the title goes in the `page-header-actions` slot, as a direct child of the layout:
|
Anything to the right of the title goes in the `page-header-actions` slot, as a direct child of the layout:
|
||||||
|
|||||||
@@ -13,5 +13,5 @@
|
|||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"allowJs": true
|
"allowJs": true
|
||||||
},
|
},
|
||||||
"include": ["./*.ts", "./*.mts", "./*.d.ts", "../core/.build/*.ts", "../core/.build/*.mts", "../preview/.build/*.mts", "../screenshots/.build/*.ts"]
|
"include": ["./*.ts", "./*.mts", "./*.d.ts", "../core/.build/*.ts", "../core/.build/*.mts", "../screenshots/.build/*.ts"]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@tabler/preview": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Unified the Customize entry: the navbar and the sidebar use the same `.navbar-side` button with the `brush` icon.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@tabler/core": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Added the `.font-sans-serif`, `.font-serif` and `.font-comic` utilities next to `.font-monospace`.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@tabler/preview": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Added layout options to the theme settings panel and removed the dead demo script and the `layout-combo` page.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@tabler/docs": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Documented the layout theme settings on the color modes and page layouts pages.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@tabler/core": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Added the `data-bs-layout`, `data-bs-navbar-position`, `data-bs-navbar` and `data-bs-navbar-theme` layout settings.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@tabler/preview": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Removed `aria-current="page"` from menu groups, so only the link to the current page carries it.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@tabler/core": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Added `.navbar-side` support in the vertical navbar: a wrapper for a trailing `.navbar-nav` group pinned to the bottom of the menu.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@tabler/core": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fixed the sidebar marking two items as selected: a group holding the current page is now emphasized, not filled.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@tabler/preview": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Added a Customize entry to the sidebar footer that opens the theme settings panel.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@tabler/core": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fixed the vertical navbar brand sitting at the far edge on small screens when nothing follows it in the top bar.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@tabler/preview": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
The sidebar now always renders the user block, so the vertical layout keeps the profile menu on every page.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@tabler/preview": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Redesigned the theme settings panel with an illustrated intro, color scheme presets and thumbnail tiles for most settings.
|
||||||
@@ -25,7 +25,6 @@ javascript:
|
|||||||
- changed-files:
|
- changed-files:
|
||||||
- any-glob-to-any-file:
|
- any-glob-to-any-file:
|
||||||
- 'core/js/**'
|
- 'core/js/**'
|
||||||
- 'preview/js/**'
|
|
||||||
|
|
||||||
icons:
|
icons:
|
||||||
- changed-files:
|
- changed-files:
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* Every setting the theme switcher understands, with its default value. Each
|
||||||
|
* key becomes a `data-bs-<key>` attribute on `<html>`, a `tabler-<key>`
|
||||||
|
* localStorage entry and a `?<key>=` URL parameter, written only when the value
|
||||||
|
* differs from the default. Adding a key here is all the JavaScript a new
|
||||||
|
* setting needs; the switcher loops over this object.
|
||||||
|
*/
|
||||||
|
export const themeDefaults = {
|
||||||
|
'theme': 'auto',
|
||||||
|
'theme-base': 'gray',
|
||||||
|
'theme-font': 'sans-serif',
|
||||||
|
'theme-primary': 'blue',
|
||||||
|
'theme-radius': '1',
|
||||||
|
'layout': 'default',
|
||||||
|
'navbar': 'default',
|
||||||
|
'navbar-position': 'horizontal',
|
||||||
|
'navbar-theme': 'default',
|
||||||
|
'sidebar': 'default',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type ThemeKey = keyof typeof themeDefaults
|
||||||
+4
-20
@@ -3,23 +3,7 @@
|
|||||||
* to ensure we switch to the chosen dark/light theme as fast as possible.
|
* to ensure we switch to the chosen dark/light theme as fast as possible.
|
||||||
* This will prevent any flashes of the light theme (default) before switching.
|
* This will prevent any flashes of the light theme (default) before switching.
|
||||||
*/
|
*/
|
||||||
interface ThemeConfig {
|
import { themeDefaults, type ThemeKey } from './src/theme-config'
|
||||||
'theme': string
|
|
||||||
'theme-base': string
|
|
||||||
'theme-font': string
|
|
||||||
'theme-primary': string
|
|
||||||
'theme-radius': string
|
|
||||||
'sidebar': string
|
|
||||||
}
|
|
||||||
|
|
||||||
const themeConfig: ThemeConfig = {
|
|
||||||
'theme': 'auto',
|
|
||||||
'theme-base': 'gray',
|
|
||||||
'theme-font': 'sans-serif',
|
|
||||||
'theme-primary': 'blue',
|
|
||||||
'theme-radius': '1',
|
|
||||||
'sidebar': 'default',
|
|
||||||
}
|
|
||||||
|
|
||||||
const params = new Proxy(new URLSearchParams(window.location.search), {
|
const params = new Proxy(new URLSearchParams(window.location.search), {
|
||||||
get: (searchParams: URLSearchParams, prop: string): string | null => searchParams.get(prop),
|
get: (searchParams: URLSearchParams, prop: string): string | null => searchParams.get(prop),
|
||||||
@@ -27,7 +11,7 @@ const params = new Proxy(new URLSearchParams(window.location.search), {
|
|||||||
|
|
||||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)')
|
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)')
|
||||||
|
|
||||||
for (const key in themeConfig) {
|
for (const key in themeDefaults) {
|
||||||
const param = params[key]
|
const param = params[key]
|
||||||
let selectedValue: string
|
let selectedValue: string
|
||||||
|
|
||||||
@@ -38,14 +22,14 @@ for (const key in themeConfig) {
|
|||||||
// A stored choice wins; otherwise a server-rendered attribute is the starting value.
|
// A stored choice wins; otherwise a server-rendered attribute is the starting value.
|
||||||
const storedTheme = localStorage.getItem('tabler-' + key)
|
const storedTheme = localStorage.getItem('tabler-' + key)
|
||||||
const serverValue = document.documentElement.getAttribute('data-bs-' + key)
|
const serverValue = document.documentElement.getAttribute('data-bs-' + key)
|
||||||
selectedValue = storedTheme ?? serverValue ?? themeConfig[key as keyof ThemeConfig]
|
selectedValue = storedTheme ?? serverValue ?? themeDefaults[key as ThemeKey]
|
||||||
}
|
}
|
||||||
|
|
||||||
if (key === 'theme' && selectedValue === 'auto') {
|
if (key === 'theme' && selectedValue === 'auto') {
|
||||||
selectedValue = prefersDark.matches ? 'dark' : 'light'
|
selectedValue = prefersDark.matches ? 'dark' : 'light'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedValue !== themeConfig[key as keyof ThemeConfig]) {
|
if (selectedValue !== themeDefaults[key as ThemeKey]) {
|
||||||
document.documentElement.setAttribute('data-bs-' + key, selectedValue)
|
document.documentElement.setAttribute('data-bs-' + key, selectedValue)
|
||||||
} else {
|
} else {
|
||||||
document.documentElement.removeAttribute('data-bs-' + key)
|
document.documentElement.removeAttribute('data-bs-' + key)
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { themeDefaults, type ThemeKey } from '../../src/theme-config'
|
||||||
|
|
||||||
|
// tabler-theme does its work at import time, so each case arranges the URL,
|
||||||
|
// storage and attributes first and then imports a fresh instance of it. The
|
||||||
|
// browser caches a module per URL, so the query string is what forces a new
|
||||||
|
// evaluation — vi.resetModules() has no effect on the browser's registry.
|
||||||
|
let run = 0
|
||||||
|
async function runThemeScript(): Promise<void> {
|
||||||
|
run += 1
|
||||||
|
await import(/* @vite-ignore */ `../../tabler-theme.ts?run=${run}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const attribute = (key: ThemeKey): string | null => document.documentElement.getAttribute(`data-bs-${key}`)
|
||||||
|
|
||||||
|
describe('tabler-theme', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
for (const key of Object.keys(themeDefaults)) {
|
||||||
|
document.documentElement.removeAttribute(`data-bs-${key}`)
|
||||||
|
}
|
||||||
|
history.replaceState(null, '', window.location.pathname)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
for (const key of Object.keys(themeDefaults)) {
|
||||||
|
document.documentElement.removeAttribute(`data-bs-${key}`)
|
||||||
|
}
|
||||||
|
history.replaceState(null, '', window.location.pathname)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('writes no attribute for a default value', async () => {
|
||||||
|
await runThemeScript()
|
||||||
|
|
||||||
|
expect(attribute('navbar-position')).toBeNull()
|
||||||
|
expect(attribute('layout')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies a stored value', async () => {
|
||||||
|
localStorage.setItem('tabler-layout', 'boxed')
|
||||||
|
|
||||||
|
await runThemeScript()
|
||||||
|
|
||||||
|
expect(attribute('layout')).toBe('boxed')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies a URL parameter and stores it', async () => {
|
||||||
|
history.replaceState(null, '', '?navbar=sticky')
|
||||||
|
|
||||||
|
await runThemeScript()
|
||||||
|
|
||||||
|
expect(attribute('navbar')).toBe('sticky')
|
||||||
|
expect(localStorage.getItem('tabler-navbar')).toBe('sticky')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('prefers a URL parameter over a stored value', async () => {
|
||||||
|
localStorage.setItem('tabler-layout', 'boxed')
|
||||||
|
history.replaceState(null, '', '?layout=fluid')
|
||||||
|
|
||||||
|
await runThemeScript()
|
||||||
|
|
||||||
|
expect(attribute('layout')).toBe('fluid')
|
||||||
|
expect(localStorage.getItem('tabler-layout')).toBe('fluid')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('prefers a stored value over the attribute the page rendered', async () => {
|
||||||
|
document.documentElement.setAttribute('data-bs-navbar-position', 'vertical')
|
||||||
|
localStorage.setItem('tabler-navbar-position', 'horizontal')
|
||||||
|
|
||||||
|
await runThemeScript()
|
||||||
|
|
||||||
|
expect(attribute('navbar-position')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the attribute the page rendered when nothing is stored', async () => {
|
||||||
|
document.documentElement.setAttribute('data-bs-navbar-position', 'vertical')
|
||||||
|
|
||||||
|
await runThemeScript()
|
||||||
|
|
||||||
|
expect(attribute('navbar-position')).toBe('vertical')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removes an attribute whose stored value is the default', async () => {
|
||||||
|
document.documentElement.setAttribute('data-bs-sidebar', 'folded')
|
||||||
|
localStorage.setItem('tabler-sidebar', 'default')
|
||||||
|
|
||||||
|
await runThemeScript()
|
||||||
|
|
||||||
|
expect(attribute('sidebar')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resolves the auto color mode to a concrete value', async () => {
|
||||||
|
localStorage.setItem('tabler-theme', 'auto')
|
||||||
|
|
||||||
|
await runThemeScript()
|
||||||
|
|
||||||
|
expect(['light', 'dark']).toContain(attribute('theme'))
|
||||||
|
})
|
||||||
|
})
|
||||||
+4
-4
@@ -82,7 +82,7 @@
|
|||||||
"files": [
|
"files": [
|
||||||
{
|
{
|
||||||
"path": "./dist/css/tabler.css",
|
"path": "./dist/css/tabler.css",
|
||||||
"maxSize": "83 kB"
|
"maxSize": "84 kB"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "./dist/css/tabler.min.css",
|
"path": "./dist/css/tabler.min.css",
|
||||||
@@ -90,7 +90,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "./dist/css/tabler.rtl.css",
|
"path": "./dist/css/tabler.rtl.css",
|
||||||
"maxSize": "83 kB"
|
"maxSize": "84 kB"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "./dist/css/tabler.rtl.min.css",
|
"path": "./dist/css/tabler.rtl.min.css",
|
||||||
@@ -226,11 +226,11 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "./dist/js/tabler-theme.js",
|
"path": "./dist/js/tabler-theme.js",
|
||||||
"maxSize": "1 kB"
|
"maxSize": "1.3 kB"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "./dist/js/tabler-theme.esm.js",
|
"path": "./dist/js/tabler-theme.esm.js",
|
||||||
"maxSize": "1 kB"
|
"maxSize": "1.3 kB"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "./dist/js/tabler-theme.min.js",
|
"path": "./dist/js/tabler-theme.min.js",
|
||||||
|
|||||||
@@ -84,4 +84,16 @@
|
|||||||
body[data-theme='dark'] [data-theme='light'] {
|
body[data-theme='dark'] [data-theme='light'] {
|
||||||
@extend [data-bs-theme='dark'];
|
@extend [data-bs-theme='dark'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Navbar theme chosen at runtime (data-bs-navbar-theme on <html>). It covers
|
||||||
|
// whichever navigation the page shows — the vertical sidebar is a .navbar
|
||||||
|
// too. Extending the dark color mode keeps this route identical to the
|
||||||
|
// static one, which sets data-bs-theme="dark" on the element itself; the
|
||||||
|
// primary variant is the dark palette on a primary background (see
|
||||||
|
// layout/_navbar.scss). The default value writes no attribute and means
|
||||||
|
// "follow the page", so there is no rule for it.
|
||||||
|
html[data-bs-navbar-theme='dark'] .page > .navbar,
|
||||||
|
html[data-bs-navbar-theme='primary'] .page > .navbar {
|
||||||
|
@extend [data-bs-theme='dark'];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -601,11 +601,16 @@ $utilities: map.merge(
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Text
|
// Text
|
||||||
|
// The four families the theme switcher can pick (data-bs-theme-font), as
|
||||||
|
// utilities, so a font can be shown or previewed outside the body setting.
|
||||||
'font-family': (
|
'font-family': (
|
||||||
property: font-family,
|
property: font-family,
|
||||||
class: font,
|
class: font,
|
||||||
values: (
|
values: (
|
||||||
|
sans-serif: var(--font-sans-serif),
|
||||||
|
serif: var(--font-serif),
|
||||||
monospace: var(--font-monospace),
|
monospace: var(--font-monospace),
|
||||||
|
comic: var(--font-comic),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
'font-size': (
|
'font-size': (
|
||||||
|
|||||||
@@ -38,7 +38,11 @@ body {
|
|||||||
//
|
//
|
||||||
// Fluid container
|
// Fluid container
|
||||||
//
|
//
|
||||||
.layout-fluid {
|
// Two routes to the same state, like the folded sidebar: a class on the body
|
||||||
|
// (static markup) or data-bs-layout on <html>, set pre-render from localStorage
|
||||||
|
// by tabler-theme.js.
|
||||||
|
.layout-fluid,
|
||||||
|
html[data-bs-layout='fluid'] {
|
||||||
.container,
|
.container,
|
||||||
[class^='container-'],
|
[class^='container-'],
|
||||||
[class*=' container-'] {
|
[class*=' container-'] {
|
||||||
@@ -49,7 +53,8 @@ body {
|
|||||||
//
|
//
|
||||||
// Boxed container
|
// Boxed container
|
||||||
//
|
//
|
||||||
.layout-boxed {
|
.layout-boxed,
|
||||||
|
html[data-bs-layout='boxed'] body {
|
||||||
--theme-boxed-border-radius: 0;
|
--theme-boxed-border-radius: 0;
|
||||||
--theme-boxed-width: #{map.get($container-max-widths, xxl)};
|
--theme-boxed-width: #{map.get($container-max-widths, xxl)};
|
||||||
|
|
||||||
@@ -60,6 +65,9 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.page {
|
.page {
|
||||||
|
// Makes the frame the containing block of the fixed vertical navbar, so a
|
||||||
|
// sidebar stays inside the boxed page instead of pinning to the viewport.
|
||||||
|
contain: layout;
|
||||||
max-width: var(--theme-boxed-width);
|
max-width: var(--theme-boxed-width);
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
color: var(--body-color);
|
color: var(--body-color);
|
||||||
@@ -70,7 +78,11 @@ body {
|
|||||||
border: var(--border-width) var(--border-style) var(--border-color);
|
border: var(--border-width) var(--border-style) var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
> .navbar:first-child {
|
// Whichever navbar opens the frame: the first child on a page that has one
|
||||||
|
// navbar, or the horizontal navbar on a page that renders the vertical
|
||||||
|
// sidebar before it.
|
||||||
|
> .navbar:first-child,
|
||||||
|
> .navbar:not(.navbar-vertical):first-of-type {
|
||||||
border-start-start-radius: var(--theme-boxed-border-radius);
|
border-start-start-radius: var(--theme-boxed-border-radius);
|
||||||
border-start-end-radius: var(--theme-boxed-border-radius);
|
border-start-end-radius: var(--theme-boxed-border-radius);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,16 @@
|
|||||||
background: var(--navbar-active-bg);
|
background: var(--navbar-active-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A group that only holds the current page is emphasized, not filled: the
|
||||||
|
// pill marks the page itself, and painting the group with the same pill
|
||||||
|
// reads as two selections. The folded rail puts it back — there the titles
|
||||||
|
// are collapsed and the submenu is a flyout, so the pill is the only thing
|
||||||
|
// left that can point at the group.
|
||||||
|
// stylelint-disable-next-line selector-max-class
|
||||||
|
.nav-item.active > .nav-link.dropdown-toggle {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
// Group label — aligned with the item text, muted like the icons.
|
// Group label — aligned with the item text, muted like the icons.
|
||||||
.nav-section-title {
|
.nav-section-title {
|
||||||
display: block;
|
display: block;
|
||||||
@@ -64,6 +74,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// .navbar-side wraps the menu's trailing group (a Customize link, a help
|
||||||
|
// link) in its own .navbar-nav. The base rule lays it out as a row for the
|
||||||
|
// horizontal navbar; here it stacks like the menu, never grows, and the
|
||||||
|
// auto margin pins it to the bottom of the scroll area when the menu is
|
||||||
|
// shorter than the sidebar.
|
||||||
|
.navbar-side {
|
||||||
|
flex-grow: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
justify-content: start;
|
||||||
|
padding-bottom: 0.25rem;
|
||||||
|
margin-top: auto;
|
||||||
|
|
||||||
|
.navbar-nav {
|
||||||
|
flex-grow: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.navbar-collapse {
|
.navbar-collapse {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
@@ -165,6 +193,15 @@
|
|||||||
--nav-link-icon-margin-end: 0;
|
--nav-link-icon-margin-end: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only icons are readable in the rail, so the group holding the current page
|
||||||
|
// takes the pill back — the emphasis the expanded sidebar carries on the
|
||||||
|
// title alone cannot be seen here. Scoped through .navbar-nav to match the
|
||||||
|
// specificity of the rule it overrides, which wins on source order.
|
||||||
|
// stylelint-disable-next-line selector-max-class
|
||||||
|
.navbar-nav .nav-item.active > .nav-link.dropdown-toggle {
|
||||||
|
background: var(--navbar-active-bg);
|
||||||
|
}
|
||||||
|
|
||||||
// Collapsed, not display: none — titles stay in the accessibility tree.
|
// Collapsed, not display: none — titles stay in the accessibility tree.
|
||||||
.nav-link-title {
|
.nav-link-title {
|
||||||
width: 0;
|
width: 0;
|
||||||
@@ -328,6 +365,20 @@ Navbar
|
|||||||
@include media-breakpoint-down(breakpoint-next($breakpoint)) {
|
@include media-breakpoint-down(breakpoint-next($breakpoint)) {
|
||||||
@include navbar-vertical-nav();
|
@include navbar-vertical-nav();
|
||||||
|
|
||||||
|
// Below the breakpoint the vertical navbar is a top bar row: toggler,
|
||||||
|
// brand, and whatever trails them (a user block, or nothing). Auto
|
||||||
|
// margins keep the brand centered either way — space-between alone
|
||||||
|
// would push it to the far edge when nothing follows it.
|
||||||
|
&.navbar-vertical .navbar-brand {
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
// In the stacked mobile menu nothing pins the trailing group to the
|
||||||
|
// bottom, so a small gap marks it as a separate part of the menu.
|
||||||
|
&.navbar-vertical .navbar-side {
|
||||||
|
margin-top: $sidebar-inset;
|
||||||
|
}
|
||||||
|
|
||||||
// Below the breakpoint the navbar-footer sits in the top bar row:
|
// Below the breakpoint the navbar-footer sits in the top bar row:
|
||||||
// the avatar alone, with its menu dropping down instead of up.
|
// the avatar alone, with its menu dropping down instead of up.
|
||||||
.navbar-footer {
|
.navbar-footer {
|
||||||
@@ -522,6 +573,9 @@ Navbar nav
|
|||||||
/**
|
/**
|
||||||
Navbar side
|
Navbar side
|
||||||
*/
|
*/
|
||||||
|
// The group at the end of a navbar: a row on the end side of a horizontal
|
||||||
|
// navbar. In a vertical navbar the same class pins the group to the bottom
|
||||||
|
// of the menu — see navbar-vertical-nav().
|
||||||
.navbar-side {
|
.navbar-side {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
@@ -530,6 +584,30 @@ Navbar side
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Navbar sticky
|
||||||
|
*/
|
||||||
|
// Runtime route only (data-bs-navbar="sticky" on <html>); static markup wraps
|
||||||
|
// the navbar in its own .sticky-top. Just the top bar sticks — on the two-row
|
||||||
|
// navbar the menu row scrolls away, so the sticky strip stays one row high.
|
||||||
|
html[data-bs-navbar='sticky'] .page > .navbar:not(.navbar-vertical) {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: $zindex-sticky;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Navbar primary
|
||||||
|
*/
|
||||||
|
// Runtime route only (data-bs-navbar-theme="primary" on <html>): the primary
|
||||||
|
// color as the navbar surface. The text palette comes from the dark color
|
||||||
|
// mode, which _extends.scss applies to the same selector; static markup gets
|
||||||
|
// the same look from .bg-primary with data-bs-theme="dark" on the navbar.
|
||||||
|
html[data-bs-navbar-theme='primary'] .page > .navbar {
|
||||||
|
--navbar-bg: var(--primary);
|
||||||
|
--navbar-border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
Navbar vertical
|
Navbar vertical
|
||||||
*/
|
*/
|
||||||
@@ -557,6 +635,34 @@ Navbar vertical
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Navbar position (data-bs-navbar-position on <html>). A page can render both
|
||||||
|
// navbars — the vertical aside first, the horizontal navbar(s) after — and
|
||||||
|
// these rules show one of them. Horizontal is the default and writes no
|
||||||
|
// attribute, so its rule matches on the attribute's absence.
|
||||||
|
//
|
||||||
|
// Both rules are guarded with :has() so they only fire on a page that really
|
||||||
|
// renders the other navbar: a page with a single navbar keeps it, whatever a
|
||||||
|
// stale stored choice says, and can never end up with no navigation at all.
|
||||||
|
html:not([data-bs-navbar-position='vertical']) .page:has(> [class*='navbar-expand']:not(.navbar-vertical)) {
|
||||||
|
> .navbar-vertical {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The hidden aside must leave no gap: its siblings offset themselves by
|
||||||
|
// --sidebar-width, and this declaration sits on the same elements as the
|
||||||
|
// folded copy above, so it wins by coming later.
|
||||||
|
> .navbar,
|
||||||
|
> .page-wrapper {
|
||||||
|
--sidebar-width: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-bs-navbar-position='vertical'] .page:has(> .navbar-vertical) {
|
||||||
|
> [class*='navbar-expand']:not(.navbar-vertical) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.navbar-vertical {
|
.navbar-vertical {
|
||||||
&.navbar-expand {
|
&.navbar-expand {
|
||||||
@each $breakpoint in map.keys($grid-breakpoints) {
|
@each $breakpoint in map.keys($grid-breakpoints) {
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// Guards the runtime layout switches: the theme script writes data-bs-* on
|
||||||
|
// <html>, and these selectors are the only thing that turns those attributes
|
||||||
|
// into a layout. They are easy to break silently — a renamed class in the
|
||||||
|
// markup, a lost :has() guard, or a rule that stops beating the folded
|
||||||
|
// --sidebar-width declaration — and nothing else in the suite would notice.
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { compile as compileSass } from 'sass'
|
||||||
|
|
||||||
|
const scssDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||||
|
const { css } = compileSass(path.join(scssDir, 'tabler.scss'), { loadPaths: ['node_modules'], style: 'expanded' })
|
||||||
|
|
||||||
|
/** Collapses whitespace so a selector can be matched regardless of line breaks. */
|
||||||
|
// Custom properties are bare here: the `--tblr-` prefix is a build-time postcss
|
||||||
|
// pass (see css-var-prefix.test.mjs), not part of the Sass output.
|
||||||
|
const flat = css.replace(/\s+/g, ' ')
|
||||||
|
|
||||||
|
describe('container width', () => {
|
||||||
|
it('keeps the class and the attribute route on the same rule', () => {
|
||||||
|
expect(flat).toContain('.layout-fluid .container, .layout-fluid [class^=container-]')
|
||||||
|
expect(flat).toContain('html[data-bs-layout=fluid] .container')
|
||||||
|
expect(flat).toContain('.layout-boxed, html[data-bs-layout=boxed] body {')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('contains the boxed frame so a fixed sidebar stays inside it', () => {
|
||||||
|
expect(flat).toContain('html[data-bs-layout=boxed] body .page { contain: layout;')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('sticky navbar', () => {
|
||||||
|
it('sticks only the horizontal navbar', () => {
|
||||||
|
expect(flat).toContain('html[data-bs-navbar=sticky] .page > .navbar:not(.navbar-vertical) { position: sticky; top: 0; z-index: 1020; }')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('navbar position', () => {
|
||||||
|
it('hides the sidebar when the attribute is absent, which is the horizontal default', () => {
|
||||||
|
expect(flat).toContain('html:not([data-bs-navbar-position=vertical]) .page:has(> [class*=navbar-expand]:not(.navbar-vertical)) > .navbar-vertical { display: none; }')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('drops the sidebar offset from the page with no sidebar on it', () => {
|
||||||
|
expect(flat).toMatch(/html:not\(\[data-bs-navbar-position=vertical\]\) \.page:has\(> \[class\*=navbar-expand\]:not\(\.navbar-vertical\)\) > \.navbar,[^{]*> \.page-wrapper \{ --sidebar-width: 0; \}/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides the horizontal navbar in the vertical position', () => {
|
||||||
|
expect(flat).toContain('html[data-bs-navbar-position=vertical] .page:has(> .navbar-vertical) > [class*=navbar-expand]:not(.navbar-vertical) { display: none; }')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('comes after the folded sidebar rule, so the two agree on --sidebar-width', () => {
|
||||||
|
expect(flat.indexOf('html[data-bs-sidebar^=folded]')).toBeLessThan(flat.indexOf('html:not([data-bs-navbar-position=vertical])'))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('navbar theme', () => {
|
||||||
|
it('gives whichever navigation shows the dark color mode', () => {
|
||||||
|
expect(flat).toMatch(/html\[data-bs-navbar-theme=dark\] \.page > \.navbar[^{]*\{[^}]*color-scheme: dark/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('gives the navigation the dark navbar variables', () => {
|
||||||
|
// Sass merges the extend into `.navbar[data-bs-theme=dark]`; the sidebar
|
||||||
|
// is `.navbar.navbar-vertical`, so one selector serves both navigations.
|
||||||
|
expect(flat).toMatch(/html\[data-bs-navbar-theme=dark\] \.page > \.navbar[^{]*\{[^}]*--navbar-color/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('paints the primary variant on the primary color with the dark palette', () => {
|
||||||
|
expect(flat).toContain('html[data-bs-navbar-theme=primary] .page > .navbar { --navbar-bg: var(--primary); --navbar-border-color: transparent; }')
|
||||||
|
expect(flat).toMatch(/html\[data-bs-navbar-theme=primary\] \.page > \.navbar[^{]*\{[^}]*color-scheme: dark/)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
title: Color modes
|
title: Color modes
|
||||||
summary: The theme script switches between light, dark and auto color modes, plus the base gray, font, primary color and corner radius, without a page reload.
|
summary: The theme script switches between light, dark and auto color modes, plus the base gray, font, primary color, corner radius and the page layout, without a page reload.
|
||||||
description: Set up Tabler's light, dark, and auto color modes, and learn the five theme attributes for base color, font, primary color, and corner radius.
|
description: Set up Tabler's light, dark, and auto color modes, and learn the theme attributes for base color, font, primary color, corner radius and layout.
|
||||||
related: [/ui/getting-started/customize, /ui/plugins/theme-base-colors]
|
related: [/ui/getting-started/customize, /ui/plugins/theme-base-colors]
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -10,7 +10,7 @@ import { Code } from 'astro:components';
|
|||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
Tabler includes a small script, [`tabler-theme.js`](https://github.com/tabler/tabler/blob/dev/core/js/tabler-theme.ts), that controls five theme settings. Each setting is read from a URL query parameter or `localStorage`, then applied as a `data-bs-*` attribute on `<html>`. Tabler's CSS reads these attributes to switch styles instantly, with no page reload.
|
Tabler includes a small script, [`tabler-theme.js`](https://github.com/tabler/tabler/blob/dev/core/js/tabler-theme.ts), that controls the settings below. Each setting is read from a URL query parameter or `localStorage`, then applied as a `data-bs-*` attribute on `<html>`. Tabler's CSS reads these attributes to switch styles instantly, with no page reload.
|
||||||
|
|
||||||
| Setting | Attribute | Default | Allowed values |
|
| Setting | Attribute | Default | Allowed values |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
@@ -19,11 +19,28 @@ Tabler includes a small script, [`tabler-theme.js`](https://github.com/tabler/ta
|
|||||||
| Font family | `data-bs-theme-font` | `sans-serif` | `sans-serif`, `serif`, `monospace`, `comic` |
|
| Font family | `data-bs-theme-font` | `sans-serif` | `sans-serif`, `serif`, `monospace`, `comic` |
|
||||||
| Primary color | `data-bs-theme-primary` | `blue` | `blue`, `azure`, `indigo`, `purple`, `pink`, `red`, `orange`, `yellow`, `lime`, `green`, `teal`, `cyan`, `inverted` |
|
| Primary color | `data-bs-theme-primary` | `blue` | `blue`, `azure`, `indigo`, `purple`, `pink`, `red`, `orange`, `yellow`, `lime`, `green`, `teal`, `cyan`, `inverted` |
|
||||||
| Corner radius | `data-bs-theme-radius` | `1` | `0`, `0.5`, `1`, `1.5`, `2` |
|
| Corner radius | `data-bs-theme-radius` | `1` | `0`, `0.5`, `1`, `1.5`, `2` |
|
||||||
|
| Navigation position | `data-bs-navbar-position` | `horizontal` | `horizontal`, `vertical` |
|
||||||
|
| Container width | `data-bs-layout` | `default` | `default`, `fluid`, `boxed` |
|
||||||
|
| Navbar behavior | `data-bs-navbar` | `default` | `default`, `sticky` |
|
||||||
|
| Navigation theme | `data-bs-navbar-theme` | `default` | `default`, `dark`, `primary` |
|
||||||
|
| Sidebar | `data-bs-sidebar` | `default` | `default`, `folded`, `folded-hover` |
|
||||||
|
|
||||||
The `data-bs-theme-base` colors are the gray palettes that back every other color mode. See [Theme base colors](/ui/plugins/theme-base-colors) for every shade of every palette; this page only covers the script that switches between them.
|
The `data-bs-theme-base` colors are the gray palettes that back every other color mode. See [Theme base colors](/ui/plugins/theme-base-colors) for every shade of every palette; this page only covers the script that switches between them.
|
||||||
|
|
||||||
|
The four `data-bs-theme-font` values are also available as utilities - `.font-sans-serif`, `.font-serif`, `.font-monospace` and `.font-comic` - to set a font family on a single element regardless of the page-wide setting.
|
||||||
|
|
||||||
Each setting only appears on `<html>` when it differs from its default. For example, `data-bs-theme-primary="blue"` is never written because `blue` is already the default - the browser falls back to Tabler's built-in styles instead.
|
Each setting only appears on `<html>` when it differs from its default. For example, `data-bs-theme-primary="blue"` is never written because `blue` is already the default - the browser falls back to Tabler's built-in styles instead.
|
||||||
|
|
||||||
|
## Layout settings
|
||||||
|
|
||||||
|
The last five settings change the page layout rather than its colors, and they need the markup described in [Page layouts](/ui/layout/page-layouts):
|
||||||
|
|
||||||
|
- `data-bs-navbar-position` only does something on a page that renders **both** navigations - a vertical `aside.navbar-vertical` and a horizontal navbar, both direct children of `.page`. The CSS shows one and hides the other. A page with a single navbar keeps it, whatever the stored value says, so a stale choice can never leave a page with no navigation.
|
||||||
|
- `data-bs-layout` is the attribute form of the `.layout-fluid` and `.layout-boxed` body classes. Use one route or the other, not both.
|
||||||
|
- `data-bs-navbar="sticky"` makes the top navbar stick to the top of the viewport while the page scrolls. On the two-row navbar only the top row sticks.
|
||||||
|
- `data-bs-navbar-theme` colors whichever navigation the page shows, since the vertical sidebar is a navbar too. `default` means "follow the page color mode", not "always light". `dark` gives it the dark palette on a light page - the same result as putting `data-bs-theme="dark"` on the element in your markup. `primary` paints it in the primary color with the dark text palette.
|
||||||
|
- `data-bs-sidebar="folded"` folds every vertical navbar on the page into a narrow, icon-only rail. `folded-hover` starts folded too, but unfolds when the user hovers over it or moves keyboard focus into it. See [Folded sidebar](/ui/layout/page-layouts#folded-sidebar) for the markup and the `tabler:sidebar-folded` event.
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
Load the theme script right after the opening `<body>` tag, and do not add `defer` or `async` to it:
|
Load the theme script right after the opening `<body>` tag, and do not add `defer` or `async` to it:
|
||||||
@@ -56,7 +73,7 @@ With `auto`, the script checks the `prefers-color-scheme` media query once on lo
|
|||||||
|
|
||||||
## Setting values
|
## Setting values
|
||||||
|
|
||||||
You can set any of the five keys in three ways, and they all agree with each other:
|
You can set any of these keys in three ways, and they all agree with each other:
|
||||||
|
|
||||||
1. **URL query parameter** - add `?theme=dark` (or any other key) to the page URL. The script reads it, applies it, and saves it to `localStorage` so it persists on the next visit.
|
1. **URL query parameter** - add `?theme=dark` (or any other key) to the page URL. The script reads it, applies it, and saves it to `localStorage` so it persists on the next visit.
|
||||||
2. **`localStorage`** - each key is stored under `tabler-<key>`, for example `tabler-theme` or `tabler-theme-primary`. This is what makes the choice persist across page loads once it has been set once, by either method here.
|
2. **`localStorage`** - each key is stored under `tabler-<key>`, for example `tabler-theme` or `tabler-theme-primary`. This is what makes the choice persist across page loads once it has been set once, by either method here.
|
||||||
@@ -65,7 +82,7 @@ You can set any of the five keys in three ways, and they all agree with each oth
|
|||||||
A query parameter always wins over what's already stored, and updates the stored value for next time:
|
A query parameter always wins over what's already stored, and updates the stored value for next time:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
https://example.com/?theme=dark&theme-primary=azure&theme-radius=0
|
https://example.com/?theme=dark&theme-primary=azure&navbar-position=vertical&layout=boxed
|
||||||
```
|
```
|
||||||
|
|
||||||
## Building your own theme switcher
|
## Building your own theme switcher
|
||||||
@@ -81,7 +98,7 @@ Tabler's own demo pages ship two working examples you can copy:
|
|||||||
|
|
||||||
Since the theme script reads `theme` straight from the query string, a plain link is enough to switch modes - no JavaScript of your own required. `.hide-theme-dark` and `.hide-theme-light` are used to show only the relevant link for the current mode.
|
Since the theme script reads `theme` straight from the query string, a plain link is enough to switch modes - no JavaScript of your own required. `.hide-theme-dark` and `.hide-theme-light` are used to show only the relevant link for the current mode.
|
||||||
|
|
||||||
- [`shared/components/demo/ThemeSettings.astro`](https://github.com/tabler/tabler/blob/dev/shared/components/demo/ThemeSettings.astro) is a full settings panel, opened from a floating button, with radios and color swatches for all five keys. It only builds the form markup - the behavior lives in a page-level script in [`shared/layouts/BaseLayout.astro`](https://github.com/tabler/tabler/blob/dev/shared/layouts/BaseLayout.astro) that, on every `change` event:
|
- [`shared/components/demo/ThemeSettings.astro`](https://github.com/tabler/tabler/blob/dev/shared/components/demo/ThemeSettings.astro) is a full settings panel, opened from a floating button, with radios and color swatches for every key. It only builds the form markup - the behavior lives in a page-level script in [`shared/layouts/BaseLayout.astro`](https://github.com/tabler/tabler/blob/dev/shared/layouts/BaseLayout.astro) that, on every `change` event:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
document.documentElement.setAttribute('data-bs-' + key, value)
|
document.documentElement.setAttribute('data-bs-' + key, value)
|
||||||
@@ -90,7 +107,7 @@ Tabler's own demo pages ship two working examples you can copy:
|
|||||||
window.history.pushState({}, '', url)
|
window.history.pushState({}, '', url)
|
||||||
```
|
```
|
||||||
|
|
||||||
It sets the attribute immediately, saves it to `localStorage`, and pushes it into the URL without a reload, so a shared link reproduces the same look. Use this as a starting point for your own settings UI - it's meant to be copied and adjusted, not used as-is in production.
|
It sets the attribute immediately, saves it to `localStorage`, and pushes it into the URL without a reload, so a shared link reproduces the same look. Its "Color scheme" tiles are presets: one tile sets `theme` and `navbar-theme` together, and reads as selected only while both keys hold the tile's values. The script knows nothing about presets - they are a convenience of the panel, built on the keys above. Use this as a starting point for your own settings UI - it's meant to be copied and adjusted, not used as-is in production.
|
||||||
|
|
||||||
## Resetting the choice
|
## Resetting the choice
|
||||||
|
|
||||||
@@ -101,7 +118,7 @@ document.documentElement.removeAttribute('data-bs-theme')
|
|||||||
window.localStorage.removeItem('tabler-theme')
|
window.localStorage.removeItem('tabler-theme')
|
||||||
```
|
```
|
||||||
|
|
||||||
`ThemeSettings.astro`'s "Reset changes" button does this for all five keys at once, and also strips them from the URL's query string.
|
`ThemeSettings.astro`'s "Reset changes" button does this for every key at once, and also strips them from the URL's query string.
|
||||||
|
|
||||||
## Accessibility
|
## Accessibility
|
||||||
|
|
||||||
|
|||||||
@@ -244,6 +244,26 @@ Group related menu items with a label. Add a `nav-section-title` list item befor
|
|||||||
|
|
||||||
The label is aligned with the item text and muted, so it does not compete with the links. In the folded sidebar, the label text is replaced with a short separator line centered in the gap between the groups, and the label keeps its height, so the menu does not shift when the sidebar folds. Section labels render only in vertical navbars - a horizontal navbar hides them.
|
The label is aligned with the item text and muted, so it does not compete with the links. In the folded sidebar, the label text is replaced with a short separator line centered in the gap between the groups, and the label keeps its height, so the menu does not shift when the sidebar folds. Section labels render only in vertical navbars - a horizontal navbar hides them.
|
||||||
|
|
||||||
|
### Trailing entries
|
||||||
|
|
||||||
|
A `.navbar-side` wrapper with its own `.navbar-nav` after the menu is the sidebar's trailing group: a Customize link, a help link, anything that belongs at the end rather than in the menu. It stacks like the menu and never grows, and when the menu is shorter than the sidebar it is pinned to the bottom of the scroll area. In a horizontal navbar the same class lays the group out as a row on the end side.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<nav class="collapse navbar-collapse" id="sidebar-menu">
|
||||||
|
<ul class="navbar-nav">...</ul>
|
||||||
|
<div class="navbar-side">
|
||||||
|
<ul class="navbar-nav">
|
||||||
|
<li class="nav-item">
|
||||||
|
<button type="button" class="nav-link" data-bs-toggle="offcanvas" data-bs-target="#offcanvas-settings">
|
||||||
|
<span class="nav-link-icon"><svg><!-- brush icon --></svg></span>
|
||||||
|
<span class="nav-link-title">Customize</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
```
|
||||||
|
|
||||||
### Sidebar footer and user block
|
### Sidebar footer and user block
|
||||||
|
|
||||||
The vertical sidebar has three zones: the brand at the top, the nav as its own scroll area, and an optional `navbar-footer` pinned at the bottom. A common use is a user block with a drop-up menu:
|
The vertical sidebar has three zones: the brand at the top, the nav as its own scroll area, and an optional `navbar-footer` pinned at the bottom. A common use is a user block with a drop-up menu:
|
||||||
@@ -394,6 +414,74 @@ document.addEventListener('tabler:sidebar-folded', (event) => {
|
|||||||
|
|
||||||
You can also add tooltips with the link title to icon-only links, so users see the name of each item on hover.
|
You can also add tooltips with the link title to icon-only links, so users see the name of each item on hover.
|
||||||
|
|
||||||
|
## Layout options
|
||||||
|
|
||||||
|
Beyond the sidebar, four more layout choices are available as attributes on `<html>`, applied before the page renders by [`tabler-theme.js`](/ui/getting-started/color-modes). Every one of them has a static route too, so you can hard-code a layout without any JavaScript.
|
||||||
|
|
||||||
|
### Container width
|
||||||
|
|
||||||
|
The page content runs at the container's normal width by default. Set `fluid` to let every container fill the viewport, or `boxed` to center the whole page in a frame:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- attribute route, switchable at runtime -->
|
||||||
|
<html data-bs-layout="fluid">
|
||||||
|
<html data-bs-layout="boxed">
|
||||||
|
|
||||||
|
<!-- class route, fixed in the markup -->
|
||||||
|
<body class="layout-fluid">
|
||||||
|
<body class="layout-boxed">
|
||||||
|
```
|
||||||
|
|
||||||
|
Use one route or the other, not both. The boxed frame is the containing block of a vertical navbar, so a sidebar stays inside the frame instead of pinning to the viewport - it scrolls with the frame rather than staying fixed.
|
||||||
|
|
||||||
|
### Sticky navbar
|
||||||
|
|
||||||
|
`data-bs-navbar="sticky"` keeps the top navbar at the top of the viewport while the page scrolls:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<html data-bs-navbar="sticky">
|
||||||
|
```
|
||||||
|
|
||||||
|
Only the navbar that is a direct child of `.page` sticks. On the two-row navbar the menu row scrolls away underneath, so the sticky strip stays one row high. Without the attribute you can still wrap a navbar in your own `.sticky-top` element.
|
||||||
|
|
||||||
|
### Navigation position
|
||||||
|
|
||||||
|
`data-bs-navbar-position` picks which navigation the page shows:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<html data-bs-navbar-position="vertical">
|
||||||
|
```
|
||||||
|
|
||||||
|
This one needs the markup to cooperate. Render **both** navigations as direct children of `.page`, the vertical `aside` first:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="page">
|
||||||
|
<aside class="navbar navbar-vertical navbar-expand-lg">...</aside>
|
||||||
|
<header class="navbar navbar-expand-md">...</header>
|
||||||
|
<div class="page-wrapper">...</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
The CSS then shows one and hides the other, and removes the sidebar's width from the page offset when the sidebar is hidden. Both rules only fire when the other navigation really is on the page, so a page with a single navbar keeps it whatever the stored value says. That is what makes the layout safe to switch at runtime: no stored choice can leave a visitor with no navigation.
|
||||||
|
|
||||||
|
The demo pages use this: every page renders both navigations, and the layout demos state their own variant with the attribute.
|
||||||
|
|
||||||
|
### Navigation theme
|
||||||
|
|
||||||
|
```html
|
||||||
|
<html data-bs-navbar-theme="dark">
|
||||||
|
<html data-bs-navbar-theme="primary">
|
||||||
|
```
|
||||||
|
|
||||||
|
The attribute colors whichever navigation the page shows - the vertical sidebar is a navbar too. `default` means "follow the page color mode", so the navigation is light on a light page and dark on a dark one. `dark` gives it the dark palette on a light page, exactly like putting `data-bs-theme="dark"` on the navbar in your markup. `primary` paints it in the primary color and keeps the dark text palette, the same look as `.bg-primary` with `data-bs-theme="dark"`:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<header class="navbar navbar-expand-md" data-bs-theme="dark">...</header>
|
||||||
|
<header class="navbar navbar-expand-md bg-primary" data-bs-theme="dark">...</header>
|
||||||
|
```
|
||||||
|
|
||||||
|
A light navigation inside a dark page is not supported - a light-scoped subtree inside a dark page inherits the dark styles.
|
||||||
|
|
||||||
## Accessibility
|
## Accessibility
|
||||||
|
|
||||||
A layout is what a screen reader user navigates by, so the landmarks matter more here than on any single component.
|
A layout is what a screen reader user navigates by, so the landmarks matter more here than on any single component.
|
||||||
|
|||||||
+2
-2
@@ -33,9 +33,9 @@
|
|||||||
"lint:scss:fix": "pnpm --filter @tabler/core lint:scss:fix",
|
"lint:scss:fix": "pnpm --filter @tabler/core lint:scss:fix",
|
||||||
"lint:md": "markdownlint --config .markdownlint-docs.json \"docs/content/**/*.mdx\"",
|
"lint:md": "markdownlint --config .markdownlint-docs.json \"docs/content/**/*.mdx\"",
|
||||||
"lint:md:fix": "markdownlint --fix --config .markdownlint-docs.json \"docs/content/**/*.mdx\"",
|
"lint:md:fix": "markdownlint --fix --config .markdownlint-docs.json \"docs/content/**/*.mdx\"",
|
||||||
"lint:prettier": "prettier --check \"core/scss/**/*.scss\" \"core/js/**/*.{js,ts}\" \"core/.build/**/*.{ts,mts,mjs}\" \".build/**/*.{ts,mts}\" \"screenshots/.build/**/*.ts\" \"preview/scss/**/*.scss\" \"docs/scss/**/*.scss\" \"preview/js/**/*.{js,ts}\" \"{preview,docs,screenshots,shared}/**/*.astro\" \"{preview,docs,screenshots}/**/*.{mjs,mts,ts}\" \"shared/**/*.{js,mjs,mts,ts}\" --cache",
|
"lint:prettier": "prettier --check \"core/scss/**/*.scss\" \"core/js/**/*.{js,ts}\" \"core/.build/**/*.{ts,mts,mjs}\" \".build/**/*.{ts,mts}\" \"screenshots/.build/**/*.ts\" \"preview/scss/**/*.scss\" \"docs/scss/**/*.scss\" \"{preview,docs,screenshots,shared}/**/*.astro\" \"{preview,docs,screenshots}/**/*.{mjs,mts,ts}\" \"shared/**/*.{js,mjs,mts,ts}\" --cache",
|
||||||
"format": "pnpm run format:prettier && pnpm run format:md",
|
"format": "pnpm run format:prettier && pnpm run format:md",
|
||||||
"format:prettier": "prettier --write \"core/scss/**/*.scss\" \"core/js/**/*.{js,ts}\" \"core/.build/**/*.{ts,mts,mjs}\" \".build/**/*.{ts,mts}\" \"screenshots/.build/**/*.ts\" \"preview/scss/**/*.scss\" \"docs/scss/**/*.scss\" \"preview/js/**/*.{js,ts}\" \"{preview,docs,screenshots,shared}/**/*.astro\" \"{preview,docs,screenshots}/**/*.{mjs,mts,ts}\" \"shared/**/*.{js,mjs,mts,ts}\" --cache",
|
"format:prettier": "prettier --write \"core/scss/**/*.scss\" \"core/js/**/*.{js,ts}\" \"core/.build/**/*.{ts,mts,mjs}\" \".build/**/*.{ts,mts}\" \"screenshots/.build/**/*.ts\" \"preview/scss/**/*.scss\" \"docs/scss/**/*.scss\" \"{preview,docs,screenshots,shared}/**/*.astro\" \"{preview,docs,screenshots}/**/*.{mjs,mts,ts}\" \"shared/**/*.{js,mjs,mts,ts}\" --cache",
|
||||||
"check": "pnpm run lint && pnpm run type-check && pnpm run type-check:build",
|
"check": "pnpm run lint && pnpm run type-check && pnpm run type-check:build",
|
||||||
"zip-package": "tsx .build/zip-package.ts",
|
"zip-package": "tsx .build/zip-package.ts",
|
||||||
"start": "pnpm dev"
|
"start": "pnpm dev"
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
import path from 'node:path'
|
|
||||||
import { fileURLToPath } from 'node:url'
|
|
||||||
import { createViteConfig } from '../../.build/vite.config.helper'
|
|
||||||
import getBanner from '../../shared/banner/index.mjs'
|
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
||||||
const entry = path.resolve(__dirname, '../js/demo.ts')
|
|
||||||
|
|
||||||
// Build mode (default): tmp-assets/js, copied into public/preview by copy-assets.mjs.
|
|
||||||
// Outside dist/ (Astro's build output) on purpose: dist/ is copied from public/ on
|
|
||||||
// every `astro build`, so an outDir inside dist/ would get re-seeded with whatever
|
|
||||||
// public/ already contained and re-copied back on the next `pnpm run assets`,
|
|
||||||
// growing without bound across repeated builds (see copy-assets.mjs). Not
|
|
||||||
// dot-prefixed either — terser's CLI --source-map option parser chokes on a
|
|
||||||
// leading-dot path segment (e.g. ".build/out/...") with a spurious "not a supported
|
|
||||||
// option" error; a plain relative dir avoids that entirely.
|
|
||||||
//
|
|
||||||
// Watch mode (pnpm run watch:js, via PREVIEW_JS_OUT_DIR): writes straight to
|
|
||||||
// public/preview/js. `astro dev` never touches public/, so there's no dist/
|
|
||||||
// collision to avoid there, and this lets edits show up on refresh without an
|
|
||||||
// extra copy-assets.mjs pass.
|
|
||||||
const outDir = process.env.PREVIEW_JS_OUT_DIR ? path.resolve(__dirname, '..', process.env.PREVIEW_JS_OUT_DIR) : path.resolve(__dirname, '../tmp-assets/js')
|
|
||||||
|
|
||||||
export default createViteConfig({
|
|
||||||
entry,
|
|
||||||
name: 'demo',
|
|
||||||
fileName: () => 'demo.js',
|
|
||||||
formats: ['es'],
|
|
||||||
outDir,
|
|
||||||
banner: getBanner('Demo'),
|
|
||||||
minify: false,
|
|
||||||
})
|
|
||||||
@@ -114,7 +114,7 @@ export default defineConfig({
|
|||||||
allowDestinationFallback: true,
|
allowDestinationFallback: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// demo css/js built by this package's sass/vite pipeline, from tmp-assets/
|
// demo css built by this package's sass pipeline, from tmp-assets/
|
||||||
// (not dist/, which is Astro's own build output — see copy-assets.ts).
|
// (not dist/, which is Astro's own build output — see copy-assets.ts).
|
||||||
from: path('./tmp-assets'),
|
from: path('./tmp-assets'),
|
||||||
to: path('./public/preview'),
|
to: path('./public/preview'),
|
||||||
@@ -137,7 +137,7 @@ export default defineConfig({
|
|||||||
{ from: path('../core/dist'), to: path('./public/dist') },
|
{ from: path('../core/dist'), to: path('./public/dist') },
|
||||||
{ from: path('../shared/static'), to: path('./public/static') },
|
{ from: path('../shared/static'), to: path('./public/static') },
|
||||||
],
|
],
|
||||||
// watch:css/watch:js write straight into public/preview — the file is already
|
// watch:css writes straight into public/preview — the file is already
|
||||||
// in place, but Astro does not reload the browser on public/ changes.
|
// in place, but Astro does not reload the browser on public/ changes.
|
||||||
reloadDirs: [path('./public/preview')],
|
reloadDirs: [path('./public/preview')],
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
interface SettingItem {
|
|
||||||
localStorage: string
|
|
||||||
default: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SettingsItems {
|
|
||||||
[key: string]: SettingItem
|
|
||||||
}
|
|
||||||
|
|
||||||
const items: SettingsItems = {
|
|
||||||
'menu-position': { localStorage: 'tablerMenuPosition', default: 'top' },
|
|
||||||
'menu-behavior': { localStorage: 'tablerMenuBehavior', default: 'sticky' },
|
|
||||||
'container-layout': {
|
|
||||||
localStorage: 'tablerContainerLayout',
|
|
||||||
default: 'boxed',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const config: Record<string, string> = {}
|
|
||||||
for (const [key, params] of Object.entries(items)) {
|
|
||||||
const lsParams = localStorage.getItem(params.localStorage)
|
|
||||||
config[key] = lsParams ? lsParams : params.default
|
|
||||||
}
|
|
||||||
|
|
||||||
const parseUrl = (): void => {
|
|
||||||
const search = window.location.search.substring(1)
|
|
||||||
const params = search.split('&')
|
|
||||||
|
|
||||||
for (const param of params) {
|
|
||||||
const [key, value] = param.split('=')
|
|
||||||
const item = key !== undefined ? items[key] : undefined
|
|
||||||
|
|
||||||
if (item && key !== undefined && value !== undefined) {
|
|
||||||
localStorage.setItem(item.localStorage, value)
|
|
||||||
config[key] = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleFormControls = (form: HTMLFormElement): void => {
|
|
||||||
for (const [key] of Object.entries(items)) {
|
|
||||||
const elem = form.querySelector(`[name="settings-${key}"][value="${config[key]}"]`) as HTMLInputElement | null
|
|
||||||
|
|
||||||
if (elem) {
|
|
||||||
elem.checked = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const submitForm = (form: HTMLFormElement): void => {
|
|
||||||
for (const [key, params] of Object.entries(items)) {
|
|
||||||
const checkedInput = form.querySelector(`[name="settings-${key}"]:checked`) as HTMLInputElement
|
|
||||||
if (checkedInput) {
|
|
||||||
const value = checkedInput.value
|
|
||||||
localStorage.setItem(params.localStorage, value)
|
|
||||||
config[key] = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.dispatchEvent(new Event('resize'))
|
|
||||||
|
|
||||||
const bootstrap = (window as Window & { bootstrap?: { Offcanvas: new (form: HTMLFormElement) => { hide(): void } } }).bootstrap
|
|
||||||
if (bootstrap) {
|
|
||||||
new bootstrap.Offcanvas(form).hide()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
parseUrl()
|
|
||||||
|
|
||||||
const form = document.querySelector('#offcanvas-settings') as HTMLFormElement | null
|
|
||||||
|
|
||||||
if (form) {
|
|
||||||
form.addEventListener('submit', function (event) {
|
|
||||||
event.preventDefault()
|
|
||||||
submitForm(form)
|
|
||||||
})
|
|
||||||
|
|
||||||
toggleFormControls(form)
|
|
||||||
}
|
|
||||||
@@ -7,13 +7,11 @@
|
|||||||
"node": ">=22.12.0"
|
"node": ">=22.12.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"assets": "shx rm -rf tmp-assets && pnpm run css && pnpm run js",
|
"assets": "shx rm -rf tmp-assets && pnpm run css",
|
||||||
"css": "tsx ../.build/build-css.ts scss tmp-assets/css --minify",
|
"css": "tsx ../.build/build-css.ts scss tmp-assets/css --minify",
|
||||||
"js": "vite build --config .build/vite.config.mts && terser tmp-assets/js/demo.js --module --compress --mangle --source-map \"content=tmp-assets/js/demo.js.map,filename=tmp-assets/js/demo.min.js.map,url=demo.min.js.map\" -o tmp-assets/js/demo.min.js",
|
|
||||||
"watch:css": "nodemon --watch scss/ --ext scss --exec \"tsx ../.build/build-css.ts scss public/preview/css\"",
|
"watch:css": "nodemon --watch scss/ --ext scss --exec \"tsx ../.build/build-css.ts scss public/preview/css\"",
|
||||||
"watch:js": "cross-env PREVIEW_JS_OUT_DIR=public/preview/js vite build --watch --config .build/vite.config.mts",
|
"watch": "pnpm run watch:css",
|
||||||
"watch": "concurrently \"pnpm run watch:css\" \"pnpm run watch:js\"",
|
"dev": "concurrently -n astro,css -c blue,magenta \"astro dev\" \"pnpm run watch:css\"",
|
||||||
"dev": "concurrently -n astro,css,js -c blue,magenta,cyan \"astro dev\" \"pnpm run watch:css\" \"pnpm run watch:js\"",
|
|
||||||
"dev:prepare": "pnpm run assets",
|
"dev:prepare": "pnpm run assets",
|
||||||
"build": "pnpm run assets && astro build",
|
"build": "pnpm run assets && astro build",
|
||||||
"preview": "astro preview",
|
"preview": "astro preview",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import HomepageContent from '@shared/components/HomepageContent.astro'
|
|||||||
import HeaderActionsButtons from '@shared/components/layout/HeaderActionsButtons.astro'
|
import HeaderActionsButtons from '@shared/components/layout/HeaderActionsButtons.astro'
|
||||||
---
|
---
|
||||||
|
|
||||||
<DefaultLayout title="Dashboard" pageHeader="Boxed layout" pretitle="Overview" pageMenu="layout.boxed" pageLibs={['apexcharts', 'jsvectormap']} bodyClass="layout-boxed">
|
<DefaultLayout title="Dashboard" pageHeader="Boxed layout" pretitle="Overview" pageMenu="layout.boxed" pageLibs={['apexcharts', 'jsvectormap']} layout="boxed">
|
||||||
<HeaderActionsButtons slot="page-header-actions" />
|
<HeaderActionsButtons slot="page-header-actions" />
|
||||||
<HomepageContent />
|
<HomepageContent />
|
||||||
</DefaultLayout>
|
</DefaultLayout>
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
---
|
|
||||||
import DefaultLayout from '@shared/layouts/DefaultLayout.astro'
|
|
||||||
import HomepageContent from '@shared/components/HomepageContent.astro'
|
|
||||||
import HeaderActionsButtons from '@shared/components/layout/HeaderActionsButtons.astro'
|
|
||||||
---
|
|
||||||
|
|
||||||
<DefaultLayout title="Dashboard" pageHeader="Combo layout" pretitle="Overview" pageMenu="layout.combo" pageLibs={['apexcharts', 'jsvectormap']} sidebar navbarHideBrand navbarClass="d-none d-lg-flex" navbarCondensed>
|
|
||||||
<HeaderActionsButtons slot="page-header-actions" />
|
|
||||||
<HomepageContent />
|
|
||||||
</DefaultLayout>
|
|
||||||
@@ -4,7 +4,7 @@ import HomepageContent from '@shared/components/HomepageContent.astro'
|
|||||||
import HeaderActionsButtons from '@shared/components/layout/HeaderActionsButtons.astro'
|
import HeaderActionsButtons from '@shared/components/layout/HeaderActionsButtons.astro'
|
||||||
---
|
---
|
||||||
|
|
||||||
<DefaultLayout title="Dashboard" pageHeader="Fluid vertical layout" pretitle="Overview" pageMenu="layout.fluid-vertical" pageLibs={['apexcharts', 'jsvectormap']} bodyClass="layout-fluid" sidebar hideTopbar>
|
<DefaultLayout title="Dashboard" pageHeader="Fluid vertical layout" pretitle="Overview" pageMenu="layout.fluid-vertical" pageLibs={['apexcharts', 'jsvectormap']} layout="fluid" navbarPosition="vertical">
|
||||||
<HeaderActionsButtons slot="page-header-actions" />
|
<HeaderActionsButtons slot="page-header-actions" />
|
||||||
<HomepageContent />
|
<HomepageContent />
|
||||||
</DefaultLayout>
|
</DefaultLayout>
|
||||||
@@ -4,7 +4,7 @@ import HomepageContent from '@shared/components/HomepageContent.astro'
|
|||||||
import HeaderActionsButtons from '@shared/components/layout/HeaderActionsButtons.astro'
|
import HeaderActionsButtons from '@shared/components/layout/HeaderActionsButtons.astro'
|
||||||
---
|
---
|
||||||
|
|
||||||
<DefaultLayout title="Dashboard" pageHeader="Fluid layout" pretitle="Overview" pageMenu="layout.fluid" pageLibs={['apexcharts', 'jsvectormap']} bodyClass="layout-fluid">
|
<DefaultLayout title="Dashboard" pageHeader="Fluid layout" pretitle="Overview" pageMenu="layout.fluid" pageLibs={['apexcharts', 'jsvectormap']} layout="fluid">
|
||||||
<HeaderActionsButtons slot="page-header-actions" />
|
<HeaderActionsButtons slot="page-header-actions" />
|
||||||
<HomepageContent />
|
<HomepageContent />
|
||||||
</DefaultLayout>
|
</DefaultLayout>
|
||||||
@@ -4,7 +4,7 @@ import HomepageContent from '@shared/components/HomepageContent.astro'
|
|||||||
import HeaderActionsButtons from '@shared/components/layout/HeaderActionsButtons.astro'
|
import HeaderActionsButtons from '@shared/components/layout/HeaderActionsButtons.astro'
|
||||||
---
|
---
|
||||||
|
|
||||||
<DefaultLayout title="Dashboard" pageHeader="Folded hover layout" pretitle="Overview" pageMenu="layout.folded-hover" pageLibs={['apexcharts', 'jsvectormap']} sidebar sidebarFoldedHover sidebarUser hideTopbar>
|
<DefaultLayout title="Dashboard" pageHeader="Folded hover layout" pretitle="Overview" pageMenu="layout.folded-hover" pageLibs={['apexcharts', 'jsvectormap']} navbarPosition="vertical" sidebarMode="folded-hover">
|
||||||
<HeaderActionsButtons slot="page-header-actions" />
|
<HeaderActionsButtons slot="page-header-actions" />
|
||||||
<HomepageContent />
|
<HomepageContent />
|
||||||
</DefaultLayout>
|
</DefaultLayout>
|
||||||
@@ -4,7 +4,7 @@ import HomepageContent from '@shared/components/HomepageContent.astro'
|
|||||||
import HeaderActionsButtons from '@shared/components/layout/HeaderActionsButtons.astro'
|
import HeaderActionsButtons from '@shared/components/layout/HeaderActionsButtons.astro'
|
||||||
---
|
---
|
||||||
|
|
||||||
<DefaultLayout title="Dashboard" pageHeader="Folded sidebar layout" pretitle="Overview" pageMenu="layout.folded" pageLibs={['apexcharts', 'jsvectormap']} sidebar sidebarFolded sidebarUser hideTopbar>
|
<DefaultLayout title="Dashboard" pageHeader="Folded sidebar layout" pretitle="Overview" pageMenu="layout.folded" pageLibs={['apexcharts', 'jsvectormap']} navbarPosition="vertical" sidebarMode="folded">
|
||||||
<HeaderActionsButtons slot="page-header-actions" />
|
<HeaderActionsButtons slot="page-header-actions" />
|
||||||
<HomepageContent />
|
<HomepageContent />
|
||||||
</DefaultLayout>
|
</DefaultLayout>
|
||||||
@@ -4,7 +4,7 @@ import HomepageContent from '@shared/components/HomepageContent.astro'
|
|||||||
import HeaderActionsButtons from '@shared/components/layout/HeaderActionsButtons.astro'
|
import HeaderActionsButtons from '@shared/components/layout/HeaderActionsButtons.astro'
|
||||||
---
|
---
|
||||||
|
|
||||||
<DefaultLayout title="Dashboard" pageHeader="Right vertical layout" pretitle="Overview" pageMenu="layout.vertical-right" pageLibs={['apexcharts', 'jsvectormap']} sidebar sidebarEnd hideTopbar>
|
<DefaultLayout title="Dashboard" pageHeader="Right vertical layout" pretitle="Overview" pageMenu="layout.vertical-right" pageLibs={['apexcharts', 'jsvectormap']} navbarPosition="vertical" sidebarEnd>
|
||||||
<HeaderActionsButtons slot="page-header-actions" />
|
<HeaderActionsButtons slot="page-header-actions" />
|
||||||
<HomepageContent />
|
<HomepageContent />
|
||||||
</DefaultLayout>
|
</DefaultLayout>
|
||||||
@@ -4,7 +4,7 @@ import HomepageContent from '@shared/components/HomepageContent.astro'
|
|||||||
import HeaderActionsButtons from '@shared/components/layout/HeaderActionsButtons.astro'
|
import HeaderActionsButtons from '@shared/components/layout/HeaderActionsButtons.astro'
|
||||||
---
|
---
|
||||||
|
|
||||||
<DefaultLayout title="Dashboard" pageHeader="Vertical transparent layout" pretitle="Overview" pageMenu="layout.vertical-transparent" pageLibs={['apexcharts', 'jsvectormap']} sidebar navbarTransparent hideTopbar>
|
<DefaultLayout title="Dashboard" pageHeader="Vertical transparent layout" pretitle="Overview" pageMenu="layout.vertical-transparent" pageLibs={['apexcharts', 'jsvectormap']} navbarPosition="vertical" navbarTransparent>
|
||||||
<HeaderActionsButtons slot="page-header-actions" />
|
<HeaderActionsButtons slot="page-header-actions" />
|
||||||
<HomepageContent />
|
<HomepageContent />
|
||||||
</DefaultLayout>
|
</DefaultLayout>
|
||||||
@@ -4,7 +4,7 @@ import HomepageContent from '@shared/components/HomepageContent.astro'
|
|||||||
import HeaderActionsButtons from '@shared/components/layout/HeaderActionsButtons.astro'
|
import HeaderActionsButtons from '@shared/components/layout/HeaderActionsButtons.astro'
|
||||||
---
|
---
|
||||||
|
|
||||||
<DefaultLayout title="Dashboard" pageHeader="Vertical layout" pretitle="Overview" pageLibs={['apexcharts', 'jsvectormap']} pageMenu="layout.vertical" sidebar sidebarFoldToggle sidebarUser hideTopbar>
|
<DefaultLayout title="Dashboard" pageHeader="Vertical layout" pretitle="Overview" pageLibs={['apexcharts', 'jsvectormap']} pageMenu="layout.vertical" navbarPosition="vertical" sidebarFoldToggle>
|
||||||
<HeaderActionsButtons slot="page-header-actions" />
|
<HeaderActionsButtons slot="page-header-actions" />
|
||||||
<HomepageContent />
|
<HomepageContent />
|
||||||
</DefaultLayout>
|
</DefaultLayout>
|
||||||
@@ -3,7 +3,7 @@ import DefaultLayout from '@shared/layouts/DefaultLayout.astro'
|
|||||||
import CaptureScript from '@shared/components/CaptureScript.astro'
|
import CaptureScript from '@shared/components/CaptureScript.astro'
|
||||||
---
|
---
|
||||||
|
|
||||||
<DefaultLayout title="Full Size Map" pageHeader={false} pageMenu="plugins.map-fullsize" pageLibs={['google-maps']} sidebar hideTopbar wrapperFull>
|
<DefaultLayout title="Full Size Map" pageHeader={false} pageMenu="plugins.map-fullsize" pageLibs={['google-maps']} navbarPosition="vertical" wrapperFull>
|
||||||
<h1 class="visually-hidden">Full Size Map</h1>
|
<h1 class="visually-hidden">Full Size Map</h1>
|
||||||
<div class="map flex-fill" id="map-google"></div>
|
<div class="map flex-fill" id="map-google"></div>
|
||||||
<CaptureScript>
|
<CaptureScript>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import CardBody from '@ui/CardBody.astro'
|
|||||||
import CardTitle from '@ui/CardTitle.astro'
|
import CardTitle from '@ui/CardTitle.astro'
|
||||||
---
|
---
|
||||||
|
|
||||||
<DefaultLayout title="Layout playground" pretitle="Playground" sidebar>
|
<DefaultLayout title="Layout playground" pretitle="Playground" navbarPosition="vertical">
|
||||||
<div class="row row-cards">
|
<div class="row row-cards">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<Card>
|
<Card>
|
||||||
|
|||||||
@@ -2,156 +2,205 @@
|
|||||||
// Offcanvas behavior script lives in BaseLayout (after global scripts).
|
// Offcanvas behavior script lives in BaseLayout (after global scripts).
|
||||||
import FormHint from '@ui/FormHint.astro'
|
import FormHint from '@ui/FormHint.astro'
|
||||||
import Icon from '@ui/Icon.astro'
|
import Icon from '@ui/Icon.astro'
|
||||||
|
import Illustration from '@ui/Illustration.astro'
|
||||||
|
import Subheader from '@ui/Subheader.astro'
|
||||||
import { site } from '@shared/lib/site'
|
import { site } from '@shared/lib/site'
|
||||||
|
import { themeDefaults, themeSections, type ThemeKey, type ThemePreset } from '@shared/lib/theme-config'
|
||||||
|
|
||||||
const defaults = {
|
const lastSection = themeSections.length - 1
|
||||||
'theme': 'light',
|
|
||||||
'theme-base': 'gray',
|
// Thumbnails, drawn as inline SVG on the theme's own custom properties so they
|
||||||
'theme-font': 'sans-serif',
|
// follow the color mode, the gray base and the accent color. The preset tiles
|
||||||
'theme-primary': 'blue',
|
// pin light and dark on purpose (they depict a mode); the layout tiles follow
|
||||||
'theme-radius': '1',
|
// the page. All share one 96×64 frame.
|
||||||
'sidebar': 'default',
|
type Mode = 'light' | 'dark'
|
||||||
|
const shade: Record<Mode, string> = { light: 'var(--tblr-gray-50)', dark: 'var(--tblr-gray-900)' }
|
||||||
|
const bar: Record<Mode | 'primary', string> = { light: 'var(--tblr-white)', dark: 'var(--tblr-gray-800)', primary: 'var(--tblr-primary)' }
|
||||||
|
const lineOn: Record<Mode, string> = { light: 'var(--tblr-gray-300)', dark: 'var(--tblr-gray-700)' }
|
||||||
|
// mode-aware palette for the layout tiles
|
||||||
|
const page = 'var(--tblr-bg-surface-secondary)'
|
||||||
|
const surface = 'var(--tblr-bg-surface)'
|
||||||
|
const border = 'var(--tblr-border-color)'
|
||||||
|
const primary = 'var(--tblr-primary)'
|
||||||
|
|
||||||
|
const svg = (inner: string, attrs = '') => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 64" class="form-imagecheck-image w-100" aria-hidden="true" focusable="false"${attrs}>${inner}</svg>`
|
||||||
|
const frame = (fill: string) => `<rect x="0.5" y="0.5" width="95" height="63" rx="6" fill="${fill}" stroke="${border}" />`
|
||||||
|
const outline = `<rect x="0.5" y="0.5" width="95" height="63" rx="6" fill="none" stroke="${border}" />`
|
||||||
|
const topBar = (fill: string) => `<path d="M6.5 0.5h83a6 6 0 0 1 6 6v13.5h-95v-13.5a6 6 0 0 1 6 -6z" fill="${fill}" /><rect x="0.5" y="19.5" width="95" height="0.5" fill="${border}" />`
|
||||||
|
const rail = (w: number, fill: string) => `<path d="M6.5 0.5h${w - 6}v63h-${w - 6}a6 6 0 0 1 -6 -6v-51a6 6 0 0 1 6 -6z" fill="${fill}" /><rect x="${w}" y="0.5" width="0.5" height="63" fill="${border}" />`
|
||||||
|
const lines = (x: number, y: number, widths: number[], fill: string, step = 11) => widths.map((w, i) => `<rect x="${x}" y="${y + i * step}" width="${w}" height="5" rx="2.5" fill="${fill}" />`).join('')
|
||||||
|
const dots = (x: number, y: number, count: number, fill: string) => Array.from({ length: count }, (_, i) => `<circle cx="${x}" cy="${y + i * 11}" r="2.5" fill="${fill}" />`).join('')
|
||||||
|
|
||||||
|
const pageHalf = (mode: Mode, right: boolean) => (right ? `<path d="M48 0.5h41.5a6 6 0 0 1 6 6v51a6 6 0 0 1 -6 6h-41.5z" fill="${shade[mode]}" />` : `<path d="M6.5 0.5h41.5v63h-41.5a6 6 0 0 1 -6 -6v-51a6 6 0 0 1 6 -6z" fill="${shade[mode]}" />`)
|
||||||
|
const barHalf = (fill: string, right: boolean) => (right ? `<path d="M48 0.5h41.5a6 6 0 0 1 6 6v13.5h-47.5z" fill="${fill}" />` : `<path d="M6.5 0.5h41.5v19.5h-47.5v-13.5a6 6 0 0 1 6 -6z" fill="${fill}" />`)
|
||||||
|
|
||||||
|
/** A preset tile: page halves and navigation bar halves in the preset's modes. */
|
||||||
|
const presetSvg = (values: Partial<Record<ThemeKey, string>>) => {
|
||||||
|
const theme = values.theme ?? 'light'
|
||||||
|
const nav = values['navbar-theme'] ?? 'default'
|
||||||
|
const [first, second]: [Mode, Mode] = theme === 'auto' ? ['light', 'dark'] : [theme as Mode, theme as Mode]
|
||||||
|
const barFill = (mode: Mode) => (nav === 'primary' ? bar.primary : nav === 'dark' ? bar.dark : bar[mode])
|
||||||
|
return svg(pageHalf(first, false) + pageHalf(second, true) + barHalf(barFill(first), false) + barHalf(barFill(second), true) + lines(12, 30, [46, 72, 30], lineOn[first]) + outline)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Marks drawn inside an accent-colored navigation shape. */
|
||||||
|
const onAccent = 'rgba(255, 255, 255, 0.7)'
|
||||||
|
/** A content card: the page's own surface with a border, lines inside. */
|
||||||
|
const card = (x: number, y: number, w: number, h: number) => `<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="3" fill="${surface}" stroke="${border}" />` + lines(x + 6, y + 7, [w * 0.6, w * 0.8, w * 0.4].map(Math.round), border, 9)
|
||||||
|
/** A scrollbar thumb on the right edge, at the top or scrolled down. */
|
||||||
|
const thumb = (y: number) => `<rect x="90" y="${y}" width="3" height="14" rx="1.5" fill="${border}" />`
|
||||||
|
|
||||||
|
/** A tile for one value of one setting. */
|
||||||
|
const tileSvg = (key: string, value: string): string => {
|
||||||
|
switch (key) {
|
||||||
|
case 'navbar-position':
|
||||||
|
// The navigation is the accent-colored shape: a bar across the top or a rail down the start side.
|
||||||
|
return value === 'vertical'
|
||||||
|
? svg(frame(page) + rail(30, primary) + lines(7, 10, [16, 16, 16, 16], onAccent, 10) + lines(40, 12, [40, 48, 28], border) + outline)
|
||||||
|
: svg(frame(page) + topBar(primary) + [8, 26, 44].map((x) => `<rect x="${x}" y="8" width="14" height="4" rx="2" fill="${onAccent}" />`).join('') + lines(12, 30, [46, 72, 30], border) + outline)
|
||||||
|
case 'layout':
|
||||||
|
// Only the content width changes, so the navigation stays out of these tiles.
|
||||||
|
// The surround is the inverted surface, so it stays dark on a light page and light on a dark one.
|
||||||
|
if (value === 'boxed') return svg(`<rect x="0.5" y="0.5" width="95" height="63" rx="6" fill="var(--tblr-bg-surface-inverted)" /><rect x="12" y="8" width="72" height="48" rx="4" fill="${page}" />` + card(18, 16, 60, 32) + outline)
|
||||||
|
return value === 'fluid' ? svg(frame(page) + card(4, 8, 88, 48) + outline) : svg(frame(page) + card(22, 8, 52, 48) + outline)
|
||||||
|
case 'navbar':
|
||||||
|
// Sticky: the bar keeps its place while the content has scrolled up behind it.
|
||||||
|
return value === 'sticky'
|
||||||
|
? svg(frame(page) + lines(12, 4, [46, 72, 30, 60], border) + thumb(44) + `<rect x="0.5" y="19.5" width="95" height="5" fill="var(--tblr-gray-900)" opacity="0.18" />` + topBar(primary) + outline)
|
||||||
|
: svg(frame(page) + topBar(primary) + lines(12, 30, [46, 72, 30], border) + thumb(24) + outline)
|
||||||
|
case 'sidebar':
|
||||||
|
if (value === 'folded') return svg(frame(page) + rail(16, primary) + dots(8, 12, 4, onAccent) + lines(26, 12, [50, 60, 34], border) + outline)
|
||||||
|
if (value === 'folded-hover') return svg(frame(page) + rail(16, primary) + dots(8, 12, 4, onAccent) + lines(26, 12, [50, 60, 34], border) + `<rect x="16" y="0.5" width="4" height="63" fill="var(--tblr-gray-900)" opacity="0.12" /><rect x="16.5" y="0.5" width="28" height="63" fill="${surface}" stroke="${border}" />` + lines(21, 9.5, [18, 18, 18, 18], border, 11) + outline)
|
||||||
|
return svg(frame(page) + rail(30, primary) + lines(7, 10, [16, 16, 16, 16], onAccent, 10) + lines(40, 12, [40, 48, 28], border) + outline)
|
||||||
|
case 'theme-base':
|
||||||
|
// The base's own gray scale: the attribute swaps the --tblr-gray-* values for this tile only.
|
||||||
|
return svg(frame(surface) + [100, 300, 500, 700, 900].map((shadeValue, i) => `<rect x="${10 + i * 16}" y="24" width="12" height="16" rx="3" fill="var(--tblr-gray-${shadeValue})" />`).join(''), ` data-bs-theme-base="${value}"`)
|
||||||
|
case 'theme-radius': {
|
||||||
|
// One shape carries the whole message: a square at 0, a circle at 2.
|
||||||
|
const r = Number(value) * 10
|
||||||
|
return svg(frame(page) + `<rect x="28" y="12" width="40" height="40" rx="${r}" fill="${primary}" />`)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return svg(frame(page) + outline)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const sidebarModes = [
|
|
||||||
{ value: 'default', label: 'Expanded' },
|
|
||||||
{ value: 'folded', label: 'Folded' },
|
|
||||||
{ value: 'folded-hover', label: 'Folded with hover' },
|
|
||||||
]
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<!-- BEGIN THEME SETTINGS -->
|
<!-- BEGIN THEME SETTINGS -->
|
||||||
<div class="settings">
|
<div class="settings">
|
||||||
<button type="button" class="btn btn-floating btn-icon btn-primary" data-bs-toggle="offcanvas" data-bs-target="#offcanvas-settings" aria-controls="offcanvas-settings" aria-label="Theme Settings">
|
<button type="button" class="btn btn-floating btn-icon btn-primary" data-bs-toggle="offcanvas" data-bs-target="#offcanvas-settings" aria-controls="offcanvas-settings" aria-label="Customize">
|
||||||
<Icon name="brush" />
|
<Icon name="brush" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<form class="offcanvas offcanvas-start offcanvas-narrow" tabindex="-1" id="offcanvas-settings" role="dialog" aria-modal="true" aria-labelledby="offcanvas-settings-title">
|
<form class="offcanvas offcanvas-start" tabindex="-1" id="offcanvas-settings" role="dialog" aria-modal="true" aria-labelledby="offcanvas-settings-title">
|
||||||
<div class="offcanvas-header">
|
<button type="button" class="btn-close position-absolute top-0 end-0 m-3 z-1" data-bs-dismiss="offcanvas" aria-label="Close"></button>
|
||||||
<h2 class="offcanvas-title" id="offcanvas-settings-title">Theme Settings</h2>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>
|
|
||||||
</div>
|
|
||||||
<div class="offcanvas-body d-flex flex-column">
|
|
||||||
<div>
|
|
||||||
<fieldset class="border-0 p-0 mb-4">
|
|
||||||
<legend class="form-label float-none mb-0" style="font-size: inherit;">Color mode</legend>
|
|
||||||
<FormHint as="p">Choose the color mode for your app.</FormHint>
|
|
||||||
{
|
|
||||||
['light', 'dark', 'auto'].map((mode) => (
|
|
||||||
<label class="form-check">
|
|
||||||
<div class="form-selectgroup-item">
|
|
||||||
<input type="radio" name="theme" value={mode} class="form-check-input" checked={mode === defaults.theme} />
|
|
||||||
<div class="form-check-label">{mode.charAt(0).toUpperCase() + mode.slice(1)}</div>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<fieldset class="border-0 p-0 mb-4">
|
{/* The body is the only scrolling area, so the actions below stay reachable. */}
|
||||||
<legend class="form-label float-none mb-0" style="font-size: inherit;">Color scheme</legend>
|
<div class="offcanvas-body p-0">
|
||||||
<FormHint as="p">The perfect color mode for your app.</FormHint>
|
<section class="p-4 text-center border-bottom">
|
||||||
|
<Illustration image="computer-fix" height={120} class="mb-3" />
|
||||||
|
<h2 class="offcanvas-title mb-1" id="offcanvas-settings-title">Make {site.title} your own</h2>
|
||||||
|
<p class="text-secondary m-0">Pick the look and layout of this preview. Your choices are saved in this browser.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div class="row g-2">
|
{
|
||||||
{
|
themeSections.map((section, index) => (
|
||||||
site.themeColors.map((color) => (
|
<section class:list={['p-4', index < lastSection && 'border-bottom']}>
|
||||||
<div class="col-auto">
|
<Subheader as="h3" class="mb-3">
|
||||||
<label class="form-colorinput">
|
{section.title}
|
||||||
<input name="theme-primary" type="radio" value={color} class="form-colorinput-input" aria-label={color} />
|
</Subheader>
|
||||||
<span class={`form-colorinput-color bg-${color}`} />
|
|
||||||
</label>
|
<div class="space-y-4">
|
||||||
|
{section.settings.map((setting) => (
|
||||||
|
<div role="group" aria-labelledby={`theme-setting-${setting.key}`} data-theme-setting={setting.key}>
|
||||||
|
<div class="form-label mb-1" id={`theme-setting-${setting.key}`}>
|
||||||
|
{setting.legend}
|
||||||
|
</div>
|
||||||
|
<FormHint as="p" class="mb-3">
|
||||||
|
{setting.hint}
|
||||||
|
</FormHint>
|
||||||
|
|
||||||
|
{setting.control === 'color' ? (
|
||||||
|
<div class="row g-2">
|
||||||
|
{setting.options.map((option) => (
|
||||||
|
<div class="col-auto">
|
||||||
|
<label class="form-colorinput">
|
||||||
|
<input type="radio" name={setting.key} value={option.value} class="form-colorinput-input" checked={option.value === themeDefaults[setting.key as ThemeKey]} aria-label={option.label} />
|
||||||
|
<span class={`form-colorinput-color bg-${option.value}`} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : setting.control === 'font' ? (
|
||||||
|
<div class="row g-3">
|
||||||
|
{setting.options.map((option) => (
|
||||||
|
<div class="col-4">
|
||||||
|
{/* Block, unlike the image tiles: text does not size the label the way an image does. */}
|
||||||
|
<label class="form-imagecheck d-block">
|
||||||
|
<input type="radio" name={setting.key} value={option.value} class="form-imagecheck-input" checked={option.value === themeDefaults[setting.key as ThemeKey]} />
|
||||||
|
<span class="form-imagecheck-figure p-1">
|
||||||
|
<span class={`form-imagecheck-image text-center py-2 display-5 fw-medium lh-1 font-${option.value}`} aria-hidden="true">
|
||||||
|
Aa
|
||||||
|
</span>
|
||||||
|
<span class="form-imagecheck-caption">{option.label}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : setting.control === 'tile' ? (
|
||||||
|
<div class="row g-3">
|
||||||
|
{setting.options.map((option) => (
|
||||||
|
<div class="col-4">
|
||||||
|
<label class="form-imagecheck">
|
||||||
|
<input type="radio" name={setting.key} value={option.value} class="form-imagecheck-input" checked={option.value === themeDefaults[setting.key as ThemeKey]} />
|
||||||
|
<span class="form-imagecheck-figure p-1">
|
||||||
|
<Fragment set:html={tileSvg(setting.key, option.value)} />
|
||||||
|
<span class="form-imagecheck-caption">{option.label}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : setting.control === 'preset' ? (
|
||||||
|
<div class="row g-3">
|
||||||
|
{(setting.options as ThemePreset[]).map((option) => (
|
||||||
|
<div class="col-4">
|
||||||
|
<label class="form-imagecheck">
|
||||||
|
<input type="radio" name={setting.key} value={option.value} class="form-imagecheck-input" checked={Object.entries(option.values).every(([key, value]) => themeDefaults[key as ThemeKey] === value)} />
|
||||||
|
<span class="form-imagecheck-figure p-1">
|
||||||
|
<Fragment set:html={presetSvg(option.values)} />
|
||||||
|
<span class="form-imagecheck-caption">{option.label}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div class="form-selectgroup">
|
||||||
|
{setting.options.map((option) => (
|
||||||
|
<label class="form-selectgroup-item">
|
||||||
|
<input type="radio" name={setting.key} value={option.value} class="form-selectgroup-input" checked={option.value === themeDefaults[setting.key as ThemeKey]} />
|
||||||
|
<span class="form-selectgroup-label">{option.label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))
|
))}
|
||||||
}
|
|
||||||
<div class="col-auto">
|
|
||||||
<label class="form-colorinput">
|
|
||||||
<input name="theme-primary" type="radio" value="inverted" class="form-colorinput-input" aria-label="Inverted" />
|
|
||||||
<span class="form-colorinput-color bg-inverted"></span>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
</fieldset>
|
))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
<fieldset class="border-0 p-0 mb-4">
|
<div class="offcanvas-footer border-top space-y-2">
|
||||||
<legend class="form-label float-none mb-0" style="font-size: inherit;">Font family</legend>
|
<button type="button" class="btn w-100" id="reset-changes">
|
||||||
<FormHint as="p">Choose the font family that fits your app.</FormHint>
|
<Icon name="rotate" />
|
||||||
|
Reset changes
|
||||||
<div>
|
</button>
|
||||||
{
|
<button type="button" class="btn btn-primary w-100" data-bs-dismiss="offcanvas">Save</button>
|
||||||
site.themeFonts.map((font) => (
|
|
||||||
<label class="form-check">
|
|
||||||
<div class="form-selectgroup-item">
|
|
||||||
<input type="radio" name="theme-font" value={font} class="form-check-input" checked={font === defaults['theme-font']} />
|
|
||||||
<div class="form-check-label">{font.charAt(0).toUpperCase() + font.slice(1)}</div>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<fieldset class="border-0 p-0 mb-4">
|
|
||||||
<legend class="form-label float-none mb-0" style="font-size: inherit;">Theme base</legend>
|
|
||||||
<FormHint as="p">Choose the gray shade for your app.</FormHint>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
{
|
|
||||||
site.themeBases.map((base) => (
|
|
||||||
<label class="form-check">
|
|
||||||
<div class="form-selectgroup-item">
|
|
||||||
<input type="radio" name="theme-base" value={base} class="form-check-input" checked={base === defaults['theme-base']} />
|
|
||||||
<div class="form-check-label">{base.charAt(0).toUpperCase() + base.slice(1)}</div>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<fieldset class="border-0 p-0 mb-4">
|
|
||||||
<legend class="form-label float-none mb-0" style="font-size: inherit;">Corner Radius</legend>
|
|
||||||
<FormHint as="p">Choose the border radius factor for your app.</FormHint>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
{
|
|
||||||
site.themeRadiuses.map((radius) => (
|
|
||||||
<label class="form-check">
|
|
||||||
<div class="form-selectgroup-item">
|
|
||||||
<input type="radio" name="theme-radius" value={radius} class="form-check-input" checked={radius === defaults['theme-radius']} />
|
|
||||||
<div class="form-check-label">{radius}</div>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<fieldset class="border-0 p-0 mb-4">
|
|
||||||
<legend class="form-label float-none mb-0" style="font-size: inherit;">Sidebar</legend>
|
|
||||||
<FormHint as="p">Choose how the vertical sidebar behaves.</FormHint>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
{
|
|
||||||
sidebarModes.map((mode) => (
|
|
||||||
<label class="form-check">
|
|
||||||
<div class="form-selectgroup-item">
|
|
||||||
<input type="radio" name="sidebar" value={mode.value} class="form-check-input" checked={mode.value === defaults.sidebar} />
|
|
||||||
<div class="form-check-label">{mode.label}</div>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-auto space-y">
|
|
||||||
<button type="button" class="btn w-100" id="reset-changes">
|
|
||||||
<Icon name="rotate" />
|
|
||||||
Reset changes
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-primary w-100" data-bs-dismiss="offcanvas">Save</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ interface Props {
|
|||||||
dark?: boolean | undefined;
|
dark?: boolean | undefined;
|
||||||
transparent?: boolean;
|
transparent?: boolean;
|
||||||
overlap?: boolean | undefined;
|
overlap?: boolean | undefined;
|
||||||
sticky?: boolean | undefined;
|
|
||||||
hideBrand?: boolean | undefined;
|
hideBrand?: boolean | undefined;
|
||||||
/** use the sample menu (menu-sample.json) instead of the real one */
|
/** use the sample menu (menu-sample.json) instead of the real one */
|
||||||
sample?: boolean;
|
sample?: boolean;
|
||||||
@@ -43,7 +42,6 @@ const {
|
|||||||
dark = false,
|
dark = false,
|
||||||
transparent = false,
|
transparent = false,
|
||||||
overlap = false,
|
overlap = false,
|
||||||
sticky = false,
|
|
||||||
hideBrand = false,
|
hideBrand = false,
|
||||||
sample = false,
|
sample = false,
|
||||||
personId,
|
personId,
|
||||||
@@ -63,15 +61,12 @@ const breakpoint = 'md';
|
|||||||
const headerClass = [
|
const headerClass = [
|
||||||
`navbar navbar-expand-${breakpoint}`,
|
`navbar navbar-expand-${breakpoint}`,
|
||||||
transparent ? 'navbar-transparent' : background && `bg-${background}`,
|
transparent ? 'navbar-transparent' : background && `bg-${background}`,
|
||||||
sticky && 'sticky-top',
|
|
||||||
overlap && 'navbar-overlap',
|
overlap && 'navbar-overlap',
|
||||||
className,
|
className,
|
||||||
'd-print-none',
|
'd-print-none',
|
||||||
]
|
]
|
||||||
---
|
---
|
||||||
|
|
||||||
{sticky && <Fragment set:html="<div class='sticky-top'>" />}
|
|
||||||
|
|
||||||
<!-- BEGIN NAVBAR -->
|
<!-- BEGIN NAVBAR -->
|
||||||
<header
|
<header
|
||||||
class:list={headerClass}
|
class:list={headerClass}
|
||||||
@@ -155,26 +150,26 @@ const headerClass = [
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div class="col col-md-auto">
|
<div class="col col-md-auto">
|
||||||
<!-- BEGIN NAVBAR NAV -->
|
<!-- BEGIN NAVBAR SIDE -->
|
||||||
<ul class="navbar-nav">
|
<div class="navbar-side">
|
||||||
<li class="nav-item">
|
<ul class="navbar-nav">
|
||||||
<a
|
<li class="nav-item">
|
||||||
class="nav-link"
|
<button
|
||||||
href="#"
|
type="button"
|
||||||
data-bs-toggle="offcanvas"
|
class="nav-link"
|
||||||
data-bs-target="#offcanvas-settings"
|
data-bs-toggle="offcanvas"
|
||||||
>
|
data-bs-target="#offcanvas-settings"
|
||||||
<span class="badge badge-sm bg-red text-red-fg">
|
aria-controls="offcanvas-settings"
|
||||||
New
|
>
|
||||||
</span>
|
<span class="nav-link-icon">
|
||||||
<span class="nav-link-icon">
|
<Icon name="brush" />
|
||||||
<Icon name="settings" />
|
</span>
|
||||||
</span>
|
<span class="nav-link-title">Customize</span>
|
||||||
<span class="nav-link-title"> Theme Settings </span>
|
</button>
|
||||||
</a>
|
</li>
|
||||||
</li>
|
</ul>
|
||||||
</ul>
|
</div>
|
||||||
<!-- END NAVBAR NAV -->
|
<!-- END NAVBAR SIDE -->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -184,5 +179,3 @@ const headerClass = [
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
<!-- END NAVBAR -->
|
<!-- END NAVBAR -->
|
||||||
|
|
||||||
{sticky && <Fragment set:html="</div>" />}
|
|
||||||
@@ -68,7 +68,7 @@ const chunk = <T,>(arr: T[], size: number): T[][] => {
|
|||||||
role={hasChildren ? 'button' : undefined}
|
role={hasChildren ? 'button' : undefined}
|
||||||
aria-haspopup={hasChildren ? 'true' : undefined}
|
aria-haspopup={hasChildren ? 'true' : undefined}
|
||||||
aria-expanded={hasChildren ? (autoOpen && key === currentPage[0] ? 'true' : 'false') : undefined}
|
aria-expanded={hasChildren ? (autoOpen && key === currentPage[0] ? 'true' : 'false') : undefined}
|
||||||
aria-current={active ? 'page' : undefined}
|
aria-current={active && !hasChildren ? 'page' : undefined}
|
||||||
>
|
>
|
||||||
{!hideIcons && <span class="nav-link-icon">{item.icon && <Icon name={item.icon} />}</span>}
|
{!hideIcons && <span class="nav-link-icon">{item.icon && <Icon name={item.icon} />}</span>}
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ const hasChildren = !!item.children
|
|||||||
const relative = (url?: string) => (isExternal(url) ? url : `./${url}`)
|
const relative = (url?: string) => (isExternal(url) ? url : `./${url}`)
|
||||||
|
|
||||||
const active = level1Key === currentPage[0] && itemKey === currentPage[1]
|
const active = level1Key === currentPage[0] && itemKey === currentPage[1]
|
||||||
|
// A group is an ancestor of the current page, not the page itself; only the
|
||||||
|
// link that really points at it may claim aria-current.
|
||||||
|
const isCurrentPage = active && !hasChildren
|
||||||
|
|
||||||
const itemClasses = ['dropdown-item', hasChildren && 'dropdown-toggle', active && 'active', item.color && `text-${item.color}`]
|
const itemClasses = ['dropdown-item', hasChildren && 'dropdown-toggle', active && 'active', item.color && `text-${item.color}`]
|
||||||
---
|
---
|
||||||
@@ -36,7 +39,7 @@ const itemClasses = ['dropdown-item', hasChildren && 'dropdown-toggle', active &
|
|||||||
<!-- BEGIN NAVBAR ITEM -->{
|
<!-- BEGIN NAVBAR ITEM -->{
|
||||||
hasChildren ? (
|
hasChildren ? (
|
||||||
<div class="dropend">
|
<div class="dropend">
|
||||||
<a class:list={itemClasses} href="#" data-bs-toggle="dropdown" data-bs-auto-close={keepOpen ? 'false' : 'outside'} role="button" aria-haspopup="true" aria-expanded="false" aria-current={active ? 'page' : undefined}>
|
<a class:list={itemClasses} href="#" data-bs-toggle="dropdown" data-bs-auto-close={keepOpen ? 'false' : 'outside'} role="button" aria-haspopup="true" aria-expanded="false">
|
||||||
{item.icon && <Icon name={item.icon} class="icon-inline me-1" />}
|
{item.icon && <Icon name={item.icon} class="icon-inline me-1" />}
|
||||||
|
|
||||||
{item.title}
|
{item.title}
|
||||||
@@ -58,7 +61,7 @@ const itemClasses = ['dropdown-item', hasChildren && 'dropdown-toggle', active &
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<a class:list={itemClasses} href={relative(item.url)} target={isExternal(item.url) ? '_blank' : undefined} rel={isExternal(item.url) ? 'noopener' : undefined} aria-current={active ? 'page' : undefined}>
|
<a class:list={itemClasses} href={relative(item.url)} target={isExternal(item.url) ? '_blank' : undefined} rel={isExternal(item.url) ? 'noopener' : undefined} aria-current={isCurrentPage ? 'page' : undefined}>
|
||||||
{item.icon && <Icon name={item.icon} class="icon-inline me-1" />}
|
{item.icon && <Icon name={item.icon} class="icon-inline me-1" />}
|
||||||
|
|
||||||
{item.title}
|
{item.title}
|
||||||
|
|||||||
@@ -19,13 +19,11 @@ interface Props {
|
|||||||
foldedHover?: boolean | undefined
|
foldedHover?: boolean | undefined
|
||||||
/** renders a bottom-pinned button toggling the folded state at runtime */
|
/** renders a bottom-pinned button toggling the folded state at runtime */
|
||||||
foldToggle?: boolean | undefined
|
foldToggle?: boolean | undefined
|
||||||
/** renders a user block in the sidebar footer */
|
|
||||||
user?: boolean | undefined
|
|
||||||
/** active menu entry, e.g. "layout.vertical" */
|
/** active menu entry, e.g. "layout.vertical" */
|
||||||
pageMenu?: string | undefined
|
pageMenu?: string | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
const { dark = false, breakpoint = 'lg', end = false, transparent = false, folded = false, foldedHover = false, foldToggle = false, user = false, pageMenu } = Astro.props
|
const { dark = false, breakpoint = 'lg', end = false, transparent = false, folded = false, foldedHover = false, foldToggle = false, pageMenu } = Astro.props
|
||||||
|
|
||||||
const isFolded = folded || foldedHover
|
const isFolded = folded || foldedHover
|
||||||
|
|
||||||
@@ -52,11 +50,23 @@ const asideClass = ['navbar navbar-vertical', end && 'navbar-end', `navbar-expan
|
|||||||
/* Before the collapse, like the regular navbar's side items — on mobile it
|
/* Before the collapse, like the regular navbar's side items — on mobile it
|
||||||
stays in the top bar row; on desktop CSS order moves it to the bottom. */
|
stays in the top bar row; on desktop CSS order moves it to the bottom. */
|
||||||
}
|
}
|
||||||
{user && <SidebarUser />}
|
<SidebarUser />
|
||||||
|
|
||||||
<nav class="collapse navbar-collapse" id="sidebar-menu" aria-label="Sidebar">
|
<nav class="collapse navbar-collapse" id="sidebar-menu" aria-label="Sidebar">
|
||||||
{/* A folded sidebar renders flyout submenus, so the active branch is not kept open inline. */}
|
{/* A folded sidebar renders flyout submenus, so the active branch is not kept open inline. */}
|
||||||
<NavbarMenu autoOpen={!isFolded} class="pt-lg-3" keepOpen={!isFolded} sections pageMenu={pageMenu} />
|
<NavbarMenu autoOpen={!isFolded} class="pt-lg-3" keepOpen={!isFolded} sections pageMenu={pageMenu} />
|
||||||
|
|
||||||
|
{/* .navbar-side: the menu's trailing group, pinned to the bottom of the sidebar. */}
|
||||||
|
<div class="navbar-side">
|
||||||
|
<ul class="navbar-nav">
|
||||||
|
<li class="nav-item">
|
||||||
|
<button type="button" class="nav-link" data-bs-toggle="offcanvas" data-bs-target="#offcanvas-settings" aria-controls="offcanvas-settings">
|
||||||
|
<span class="nav-link-icon"><Icon name="brush" /></span>
|
||||||
|
<span class="nav-link-title">Customize</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"title": "Default tabler layout",
|
|
||||||
"page": "layouts.html",
|
|
||||||
"config": {
|
|
||||||
"topmenu": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Dark navigation",
|
|
||||||
"page": "layout-dark-navigation.html",
|
|
||||||
"config": {
|
|
||||||
"topmenu": true,
|
|
||||||
"topmenu-dark": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Dark topnav",
|
|
||||||
"page": "layout-dark-topnav.html",
|
|
||||||
"config": {
|
|
||||||
"topmenu": true,
|
|
||||||
"topnav-dark": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Sidebar",
|
|
||||||
"page": "layout-sidebar.html",
|
|
||||||
"new": true,
|
|
||||||
"config": {
|
|
||||||
"sidebar": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Dark sidebar",
|
|
||||||
"page": "layout-sidebar-dark.html",
|
|
||||||
"new": true,
|
|
||||||
"config": {
|
|
||||||
"sidebar": true,
|
|
||||||
"sidebar-dark": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Narrow sidebar",
|
|
||||||
"page": "layout-sidebar-narrow.html",
|
|
||||||
"new": true,
|
|
||||||
"config": {
|
|
||||||
"sidebar": true,
|
|
||||||
"sidebar-narrow": true,
|
|
||||||
"sidebar-dark": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Right sidebar",
|
|
||||||
"page": "layout-sidebar-right.html",
|
|
||||||
"new": true,
|
|
||||||
"config": {
|
|
||||||
"sidebar-right": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Sidebar only",
|
|
||||||
"page": "layout-sidebar-only.html",
|
|
||||||
"new": true,
|
|
||||||
"config": {
|
|
||||||
"sidebar": true,
|
|
||||||
"hide-topnav": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Dark mode",
|
|
||||||
"page": "layout-dark.html",
|
|
||||||
"soon": true,
|
|
||||||
"config": {
|
|
||||||
"sidebar": true,
|
|
||||||
"sidebar-dark": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -401,10 +401,6 @@
|
|||||||
"url": "layout-boxed.html",
|
"url": "layout-boxed.html",
|
||||||
"title": "Boxed"
|
"title": "Boxed"
|
||||||
},
|
},
|
||||||
"combo": {
|
|
||||||
"url": "layout-combo.html",
|
|
||||||
"title": "Combined"
|
|
||||||
},
|
|
||||||
"condensed": {
|
"condensed": {
|
||||||
"url": "layout-condensed.html",
|
"url": "layout-condensed.html",
|
||||||
"title": "Condensed"
|
"title": "Condensed"
|
||||||
|
|||||||
+100
-43
@@ -4,6 +4,7 @@ import { site } from '@shared/lib/site'
|
|||||||
import PageModals from '@shared/components/PageModals.astro'
|
import PageModals from '@shared/components/PageModals.astro'
|
||||||
import PageScripts from '@shared/components/PageScripts.astro'
|
import PageScripts from '@shared/components/PageScripts.astro'
|
||||||
import libs from '@tabler/core/libs.json'
|
import libs from '@tabler/core/libs.json'
|
||||||
|
import { themeDefaults, themePresets, sidebarOnlyKeys, navbarOnlyKeys, themeDataAttributes, type ThemeKey } from '@shared/lib/theme-config'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title?: string | undefined
|
title?: string | undefined
|
||||||
@@ -17,9 +18,15 @@ interface Props {
|
|||||||
base?: string
|
base?: string
|
||||||
/** <meta name="description"> — falls back to the site description */
|
/** <meta name="description"> — falls back to the site description */
|
||||||
description?: string | undefined
|
description?: string | undefined
|
||||||
|
/** layout settings the page renders as data-bs-* on <html>; the switcher's stored choice still wins */
|
||||||
|
themeSettings?: Partial<Record<ThemeKey, string | undefined>> | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
const { title, pageLibs = [], bodyClass, rtl = false, base = '.', description } = Astro.props
|
const { title, pageLibs = [], bodyClass, rtl = false, base = '.', description, themeSettings = {} } = Astro.props
|
||||||
|
|
||||||
|
// The page's own layout, in the switcher's vocabulary: rendered before the theme
|
||||||
|
// script runs, so a page keeps its variant until the visitor chooses otherwise.
|
||||||
|
const themeAttributes = themeDataAttributes(themeSettings)
|
||||||
|
|
||||||
// Development build: unminified assets, dev favicon.
|
// Development build: unminified assets, dev favicon.
|
||||||
const environment = 'development'
|
const environment = 'development'
|
||||||
@@ -38,7 +45,7 @@ const libJsFiles = (head: boolean) => pageLibEntries.filter(([, lib]) => Boolean
|
|||||||
---
|
---
|
||||||
|
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en" dir={rtl ? 'rtl' : undefined}>
|
<html lang="en" dir={rtl ? 'rtl' : undefined} {...themeAttributes}>
|
||||||
<head>
|
<head>
|
||||||
<!-- BEGIN META -->
|
<!-- BEGIN META -->
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
@@ -118,14 +125,10 @@ const libJsFiles = (head: boolean) => pageLibEntries.filter(([, lib]) => Boolean
|
|||||||
<script is:inline src={`${base}/dist/js/tabler${min}.js`} defer></script>
|
<script is:inline src={`${base}/dist/js/tabler${min}.js`} defer></script>
|
||||||
<!-- END GLOBAL MANDATORY SCRIPTS -->
|
<!-- END GLOBAL MANDATORY SCRIPTS -->
|
||||||
|
|
||||||
<!-- BEGIN DEMO SCRIPTS -->
|
|
||||||
<script is:inline src={`${base}/preview/js/demo${min}.js`} defer></script>
|
|
||||||
<!-- END DEMO SCRIPTS -->
|
|
||||||
|
|
||||||
<PageScripts />
|
<PageScripts />
|
||||||
|
|
||||||
<!-- BEGIN PAGE SCRIPTS -->
|
<!-- BEGIN PAGE SCRIPTS -->
|
||||||
<script is:inline>
|
<script is:inline define:vars={{ themeDefaults, themePresets, sidebarOnlyKeys, navbarOnlyKeys }}>
|
||||||
/*
|
/*
|
||||||
This script handles the theme settings offcanvas functionality
|
This script handles the theme settings offcanvas functionality
|
||||||
It saves the selected settings to localStorage and applies them to the document
|
It saves the selected settings to localStorage and applies them to the document
|
||||||
@@ -135,33 +138,89 @@ const libJsFiles = (head: boolean) => pageLibEntries.filter(([, lib]) => Boolean
|
|||||||
This is just a demo script to show how to implement theme settings. You can modify it as needed.
|
This is just a demo script to show how to implement theme settings. You can modify it as needed.
|
||||||
*/
|
*/
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
var themeConfig = {
|
|
||||||
'theme': 'light',
|
|
||||||
'theme-base': 'gray',
|
|
||||||
'theme-font': 'sans-serif',
|
|
||||||
'theme-primary': 'blue',
|
|
||||||
'theme-radius': '1',
|
|
||||||
'sidebar': 'default',
|
|
||||||
}
|
|
||||||
|
|
||||||
var url = new URL(window.location)
|
var url = new URL(window.location)
|
||||||
var form = document.getElementById('offcanvas-settings')
|
var form = document.getElementById('offcanvas-settings')
|
||||||
var resetButton = document.getElementById('reset-changes')
|
var resetButton = document.getElementById('reset-changes')
|
||||||
|
|
||||||
var checkItems = function () {
|
// Same rule as the theme script: the default state is the absence of the
|
||||||
for (var key in themeConfig) {
|
// attribute, so only a non-default value is written.
|
||||||
var value = window.localStorage['tabler-' + key] || themeConfig[key]
|
var applyValue = function (key, value) {
|
||||||
|
var applied = value
|
||||||
|
|
||||||
if (!!value) {
|
if (key === 'theme' && value === 'auto') {
|
||||||
var radios = form.querySelectorAll(`[name="${key}"]`)
|
applied = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||||
|
|
||||||
if (!!radios) {
|
|
||||||
radios.forEach((radio) => {
|
|
||||||
radio.checked = radio.value === value
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (applied !== themeDefaults[key]) {
|
||||||
|
document.documentElement.setAttribute('data-bs-' + key, applied)
|
||||||
|
} else {
|
||||||
|
document.documentElement.removeAttribute('data-bs-' + key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same precedence as the theme script: a stored choice wins, otherwise
|
||||||
|
// the attribute the page rendered for its own layout, otherwise the
|
||||||
|
// default. Reading the attribute matters on the layout demo pages,
|
||||||
|
// where the page states its variant and nothing is stored yet.
|
||||||
|
//
|
||||||
|
// Color mode is the exception: data-bs-theme holds the resolved value,
|
||||||
|
// so "auto" on a light system looks exactly like an explicit "light"
|
||||||
|
// and reading it back would silently turn the default into a choice.
|
||||||
|
var currentValue = function (key) {
|
||||||
|
var stored = window.localStorage['tabler-' + key]
|
||||||
|
var rendered = key === 'theme' ? null : document.documentElement.getAttribute('data-bs-' + key)
|
||||||
|
|
||||||
|
return stored || rendered || themeDefaults[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Some settings only do something with one of the navigations on
|
||||||
|
// screen; the others are hidden rather than left to do nothing.
|
||||||
|
var toggleSidebarSettings = function () {
|
||||||
|
var vertical = currentValue('navbar-position') === 'vertical'
|
||||||
|
var setHidden = function (keys, hidden) {
|
||||||
|
keys.forEach(function (key) {
|
||||||
|
var fieldset = form.querySelector('[data-theme-setting="' + key + '"]')
|
||||||
|
|
||||||
|
if (fieldset) {
|
||||||
|
fieldset.hidden = hidden
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
setHidden(sidebarOnlyKeys, !vertical)
|
||||||
|
setHidden(navbarOnlyKeys, vertical)
|
||||||
|
}
|
||||||
|
|
||||||
|
var setValue = function (key, value) {
|
||||||
|
applyValue(key, value)
|
||||||
|
window.localStorage.setItem('tabler-' + key, value)
|
||||||
|
url.searchParams.set(key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A preset tile stands for several keys; it reads as checked only
|
||||||
|
// while every one of them holds the tile's value.
|
||||||
|
var presetMatches = function (values) {
|
||||||
|
return Object.keys(values).every(function (key) {
|
||||||
|
return currentValue(key) === values[key]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var checkItems = function () {
|
||||||
|
for (var key in themeDefaults) {
|
||||||
|
var value = currentValue(key)
|
||||||
|
|
||||||
|
form.querySelectorAll('[name="' + key + '"]').forEach(function (radio) {
|
||||||
|
radio.checked = radio.value === value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var name in themePresets) {
|
||||||
|
form.querySelectorAll('[name="' + name + '"]').forEach(function (radio) {
|
||||||
|
radio.checked = presetMatches(themePresets[name][radio.value])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleSidebarSettings()
|
||||||
}
|
}
|
||||||
|
|
||||||
form.addEventListener('change', function (event) {
|
form.addEventListener('change', function (event) {
|
||||||
@@ -169,34 +228,32 @@ const libJsFiles = (head: boolean) => pageLibEntries.filter(([, lib]) => Boolean
|
|||||||
name = target.name,
|
name = target.name,
|
||||||
value = target.value
|
value = target.value
|
||||||
|
|
||||||
for (var key in themeConfig) {
|
if (name in themePresets) {
|
||||||
if (name === key) {
|
var values = themePresets[name][value]
|
||||||
var applied = value
|
|
||||||
if (key === 'theme' && value === 'auto') {
|
for (var presetKey in values) {
|
||||||
applied = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
setValue(presetKey, values[presetKey])
|
||||||
}
|
|
||||||
if (applied !== themeConfig[key]) {
|
|
||||||
document.documentElement.setAttribute('data-bs-' + key, applied)
|
|
||||||
} else {
|
|
||||||
document.documentElement.removeAttribute('data-bs-' + key)
|
|
||||||
}
|
|
||||||
window.localStorage.setItem('tabler-' + key, value)
|
|
||||||
url.searchParams.set(key, value)
|
|
||||||
}
|
}
|
||||||
|
} else if (name in themeDefaults) {
|
||||||
|
setValue(name, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toggleSidebarSettings()
|
||||||
|
// Plugins that measured the old width (charts, maps) need a nudge.
|
||||||
|
window.dispatchEvent(new Event('resize'))
|
||||||
window.history.pushState({}, '', url)
|
window.history.pushState({}, '', url)
|
||||||
})
|
})
|
||||||
|
|
||||||
resetButton.addEventListener('click', function () {
|
resetButton.addEventListener('click', function () {
|
||||||
for (var key in themeConfig) {
|
for (var key in themeDefaults) {
|
||||||
document.documentElement.removeAttribute('data-bs-' + key)
|
applyValue(key, themeDefaults[key])
|
||||||
window.localStorage.removeItem('tabler-' + key)
|
window.localStorage.removeItem('tabler-' + key)
|
||||||
url.searchParams.delete(key)
|
url.searchParams.delete(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
checkItems()
|
checkItems()
|
||||||
|
|
||||||
|
window.dispatchEvent(new Event('resize'))
|
||||||
window.history.pushState({}, '', url)
|
window.history.pushState({}, '', url)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import Navbar from '@shared/components/navbar/Navbar.astro'
|
|||||||
import Sidebar from '@shared/components/navbar/Sidebar.astro'
|
import Sidebar from '@shared/components/navbar/Sidebar.astro'
|
||||||
import PageHeader from '@shared/components/layout/PageHeader.astro'
|
import PageHeader from '@shared/components/layout/PageHeader.astro'
|
||||||
import Footer from '@shared/components/layout/Footer.astro'
|
import Footer from '@shared/components/layout/Footer.astro'
|
||||||
|
import type { ThemeKey } from '@shared/lib/theme-config'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title?: string | undefined
|
title?: string | undefined
|
||||||
@@ -19,32 +20,26 @@ interface Props {
|
|||||||
description?: string | undefined
|
description?: string | undefined
|
||||||
/** active menu entry, e.g. "layout.vertical" */
|
/** active menu entry, e.g. "layout.vertical" */
|
||||||
pageMenu?: string | undefined
|
pageMenu?: string | undefined
|
||||||
/** renders the vertical sidebar */
|
/** where the main menu lives: the top navbar or the vertical sidebar */
|
||||||
sidebar?: boolean
|
navbarPosition?: 'horizontal' | 'vertical'
|
||||||
/** dark sidebar */
|
/** container width: default, fluid or boxed */
|
||||||
sidebarDark?: boolean
|
layout?: 'default' | 'fluid' | 'boxed'
|
||||||
/** hides the top navbar */
|
|
||||||
hideTopbar?: boolean
|
|
||||||
/** adds my-auto to the .container-xl */
|
/** adds my-auto to the .container-xl */
|
||||||
containerCentered?: boolean
|
containerCentered?: boolean
|
||||||
/** page-wrapper-full and no .container-xl around the body */
|
/** page-wrapper-full and no .container-xl around the body */
|
||||||
wrapperFull?: boolean
|
wrapperFull?: boolean
|
||||||
/** extra classes on the .container-xl */
|
/** extra classes on the .container-xl */
|
||||||
containerClass?: string
|
containerClass?: string
|
||||||
/** extra body classes, e.g. "layout-boxed" or "layout-fluid" */
|
/** extra body classes */
|
||||||
bodyClass?: string
|
bodyClass?: string
|
||||||
/** dir="rtl" and the RTL stylesheets */
|
/** dir="rtl" and the RTL stylesheets */
|
||||||
rtl?: boolean
|
rtl?: boolean
|
||||||
/** sidebar on the end side */
|
/** sidebar on the end side */
|
||||||
sidebarEnd?: boolean
|
sidebarEnd?: boolean
|
||||||
/** icon-only sidebar rail with flyout submenus */
|
/** icon-only sidebar rail: folded, or folded until hovered */
|
||||||
sidebarFolded?: boolean
|
sidebarMode?: 'folded' | 'folded-hover'
|
||||||
/** folded rail that unfolds on hover/focus */
|
|
||||||
sidebarFoldedHover?: boolean
|
|
||||||
/** renders the runtime fold toggle button in the sidebar */
|
/** renders the runtime fold toggle button in the sidebar */
|
||||||
sidebarFoldToggle?: boolean
|
sidebarFoldToggle?: boolean
|
||||||
/** user block in the sidebar footer */
|
|
||||||
sidebarUser?: boolean
|
|
||||||
/** transparent sidebar */
|
/** transparent sidebar */
|
||||||
navbarTransparent?: boolean
|
navbarTransparent?: boolean
|
||||||
/** condensed single-row navbar */
|
/** condensed single-row navbar */
|
||||||
@@ -55,68 +50,36 @@ interface Props {
|
|||||||
navbarOverlap?: boolean
|
navbarOverlap?: boolean
|
||||||
/** sticky navbar */
|
/** sticky navbar */
|
||||||
navbarSticky?: boolean
|
navbarSticky?: boolean
|
||||||
/** hides the brand in the navbar */
|
|
||||||
navbarHideBrand?: boolean
|
|
||||||
/** extra classes on the navbar */
|
|
||||||
navbarClass?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const { title, pageLibs, pageHeader, pageHeaderFile, pretitle, description, pageMenu, navbarPosition, layout, containerCentered, wrapperFull, containerClass, bodyClass, rtl, sidebarEnd, sidebarMode, sidebarFoldToggle, navbarTransparent, navbarCondensed, navbarDark, navbarOverlap, navbarSticky } = Astro.props
|
||||||
title,
|
|
||||||
pageLibs,
|
|
||||||
pageHeader,
|
|
||||||
pageHeaderFile,
|
|
||||||
pretitle,
|
|
||||||
description,
|
|
||||||
pageMenu,
|
|
||||||
sidebar,
|
|
||||||
sidebarDark,
|
|
||||||
hideTopbar,
|
|
||||||
containerCentered,
|
|
||||||
wrapperFull,
|
|
||||||
containerClass,
|
|
||||||
bodyClass,
|
|
||||||
rtl,
|
|
||||||
sidebarEnd,
|
|
||||||
sidebarFolded,
|
|
||||||
sidebarFoldedHover,
|
|
||||||
sidebarFoldToggle,
|
|
||||||
sidebarUser,
|
|
||||||
navbarTransparent,
|
|
||||||
navbarCondensed,
|
|
||||||
navbarDark,
|
|
||||||
navbarOverlap,
|
|
||||||
navbarSticky,
|
|
||||||
navbarHideBrand,
|
|
||||||
navbarClass,
|
|
||||||
} = Astro.props
|
|
||||||
|
|
||||||
// Most pages repeat the page title verbatim, so `pageHeader` falls back to `title`.
|
// Most pages repeat the page title verbatim, so `pageHeader` falls back to `title`.
|
||||||
// Pages that deliberately have no header (blank, playgrounds) pass pageHeader={false}.
|
// Pages that deliberately have no header (blank, playgrounds) pass pageHeader={false}.
|
||||||
const headerTitle = pageHeader === false ? undefined : (pageHeader ?? title)
|
const headerTitle = pageHeader === false ? undefined : (pageHeader ?? title)
|
||||||
|
|
||||||
|
// The page always renders both navigations and states its own layout as
|
||||||
|
// data-bs-* attributes on <html>; CSS shows one of them. That is what lets the
|
||||||
|
// theme settings panel switch the layout at runtime on any page — see
|
||||||
|
// `shared/lib/theme-config.ts`.
|
||||||
|
const themeSettings: Partial<Record<ThemeKey, string | undefined>> = {
|
||||||
|
'navbar-position': navbarPosition,
|
||||||
|
'layout': layout,
|
||||||
|
'navbar': navbarSticky ? 'sticky' : undefined,
|
||||||
|
'navbar-theme': navbarDark ? 'dark' : undefined,
|
||||||
|
'sidebar': sidebarMode,
|
||||||
|
}
|
||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout title={title} pageLibs={pageLibs} bodyClass={bodyClass} rtl={rtl} description={description}>
|
<BaseLayout title={title} pageLibs={pageLibs} bodyClass={bodyClass} rtl={rtl} description={description} themeSettings={themeSettings}>
|
||||||
<div class="page">
|
<div class="page">
|
||||||
{
|
<!-- BEGIN SIDEBAR -->
|
||||||
sidebar && (
|
<Sidebar end={sidebarEnd} transparent={navbarTransparent} foldToggle={sidebarFoldToggle} breakpoint="lg" pageMenu={pageMenu} />
|
||||||
<Fragment>
|
<!-- END SIDEBAR -->
|
||||||
<Fragment set:html={'<!-- BEGIN SIDEBAR -->'} />
|
|
||||||
<Sidebar dark={sidebarDark} end={sidebarEnd} transparent={navbarTransparent} folded={sidebarFolded} foldedHover={sidebarFoldedHover} foldToggle={sidebarFoldToggle} user={sidebarUser} breakpoint="lg" pageMenu={pageMenu} />
|
|
||||||
<Fragment set:html={'<!-- END SIDEBAR -->'} />
|
|
||||||
</Fragment>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
<!-- BEGIN NAVBAR -->
|
||||||
!hideTopbar && (
|
<Navbar hideSearch pageMenu={pageMenu} condensed={navbarCondensed} overlap={navbarOverlap} />
|
||||||
<Fragment>
|
<!-- END NAVBAR -->
|
||||||
<Fragment set:html={'<!-- BEGIN NAVBAR -->'} />
|
|
||||||
<Navbar hideSearch pageMenu={pageMenu} condensed={navbarCondensed} dark={navbarDark} overlap={navbarOverlap} sticky={navbarSticky} hideBrand={navbarHideBrand} class={navbarClass} />
|
|
||||||
<Fragment set:html={'<!-- END NAVBAR -->'} />
|
|
||||||
</Fragment>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class:list={['page-wrapper', wrapperFull && 'page-wrapper-full']}>
|
<div class:list={['page-wrapper', wrapperFull && 'page-wrapper-full']}>
|
||||||
<!-- BEGIN PAGE HEADER -->
|
<!-- BEGIN PAGE HEADER -->
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { themeDefaults, themeSections, themeSettings, themePresets, sidebarOnlyKeys, navbarOnlyKeys, themeDataAttributes, type ThemeKey, type ThemePreset } from './theme-config'
|
||||||
|
|
||||||
|
describe('themeSettings', () => {
|
||||||
|
it('covers every key the switcher knows, exactly once', () => {
|
||||||
|
const keys = themeSettings.flatMap((setting) => (setting.control === 'preset' ? Object.keys((setting.options[0] as ThemePreset).values) : [setting.key]))
|
||||||
|
|
||||||
|
expect([...keys].sort()).toEqual(Object.keys(themeDefaults).sort())
|
||||||
|
expect(new Set(keys).size).toBe(keys.length)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('offers the default value as one of the options', () => {
|
||||||
|
for (const setting of themeSettings.filter((s) => s.control !== 'preset')) {
|
||||||
|
const values = setting.options.map((option) => option.value)
|
||||||
|
|
||||||
|
expect(values, setting.key).toContain(themeDefaults[setting.key as ThemeKey])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has one preset that is exactly the defaults', () => {
|
||||||
|
for (const [name, presets] of Object.entries(themePresets)) {
|
||||||
|
const matching = Object.values(presets).filter((values) => Object.entries(values).every(([key, value]) => themeDefaults[key as ThemeKey] === value))
|
||||||
|
|
||||||
|
expect(matching, name).toHaveLength(1)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets the same keys in every preset of a group', () => {
|
||||||
|
for (const presets of Object.values(themePresets)) {
|
||||||
|
const shapes = new Set(Object.values(presets).map((values) => Object.keys(values).sort().join(',')))
|
||||||
|
|
||||||
|
expect(shapes.size).toBe(1)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('labels every option', () => {
|
||||||
|
for (const setting of themeSettings) {
|
||||||
|
for (const option of setting.options) {
|
||||||
|
expect(option.label.length, `${setting.key}=${option.value}`).toBeGreaterThan(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('groups the settings into Appearance and Layout', () => {
|
||||||
|
expect(themeSections.map((section) => section.title)).toEqual(['Appearance', 'Layout'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks the settings that depend on the navigation position', () => {
|
||||||
|
expect(sidebarOnlyKeys).toEqual(['sidebar'])
|
||||||
|
expect(navbarOnlyKeys).toEqual(['navbar'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('offers the color scheme as presets over theme and navbar-theme', () => {
|
||||||
|
expect(Object.keys(themePresets)).toEqual(['scheme'])
|
||||||
|
expect(Object.keys(themePresets.scheme!)).toEqual(['auto', 'light', 'dark', 'dark-nav', 'colored'])
|
||||||
|
expect(themePresets.scheme!.colored).toEqual({ 'theme': 'light', 'navbar-theme': 'primary' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('themeDataAttributes', () => {
|
||||||
|
it('prefixes the keys', () => {
|
||||||
|
expect(themeDataAttributes({ 'navbar-position': 'vertical' })).toEqual({ 'data-bs-navbar-position': 'vertical' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('omits values equal to the default', () => {
|
||||||
|
expect(themeDataAttributes({ 'navbar-position': 'horizontal', 'layout': 'boxed' })).toEqual({ 'data-bs-layout': 'boxed' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('omits undefined values', () => {
|
||||||
|
expect(themeDataAttributes({ layout: undefined })).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns nothing for an empty set', () => {
|
||||||
|
expect(themeDataAttributes({})).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps several settings at once', () => {
|
||||||
|
const values: Partial<Record<ThemeKey, string>> = { 'layout': 'fluid', 'navbar': 'sticky', 'navbar-theme': 'primary' }
|
||||||
|
|
||||||
|
expect(themeDataAttributes(values)).toEqual({
|
||||||
|
'data-bs-layout': 'fluid',
|
||||||
|
'data-bs-navbar': 'sticky',
|
||||||
|
'data-bs-navbar-theme': 'primary',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
// The theme switcher's settings, described once for both the panel markup and
|
||||||
|
// the demo script that applies them.
|
||||||
|
//
|
||||||
|
// The defaults come from the shipped switcher (@tabler/core/js/src/theme-config)
|
||||||
|
// so the panel can never disagree with the script about what "default" means.
|
||||||
|
// Each key maps to a `data-bs-<key>` attribute on <html>, written only when the
|
||||||
|
// value differs from the default.
|
||||||
|
import { themeDefaults, type ThemeKey } from '@tabler/core/js/src/theme-config'
|
||||||
|
import { site } from './site'
|
||||||
|
import { ucFirst } from './string-format'
|
||||||
|
|
||||||
|
export { themeDefaults }
|
||||||
|
export type { ThemeKey }
|
||||||
|
|
||||||
|
export interface ThemeOption {
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A preset is one tile that sets several settings at once, the way AppStack's
|
||||||
|
* "color scheme" picks the page theme and the sidebar theme together. The
|
||||||
|
* switcher script knows nothing about presets: the panel expands one into
|
||||||
|
* the keys it stands for, and reads the tile back from those keys.
|
||||||
|
*/
|
||||||
|
export interface ThemePreset extends ThemeOption {
|
||||||
|
values: Partial<Record<ThemeKey, string>>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThemeSetting {
|
||||||
|
/** input name; a real switcher key, or the preset group's own name */
|
||||||
|
key: ThemeKey | string
|
||||||
|
/** fieldset legend */
|
||||||
|
legend: string
|
||||||
|
/** help text under the legend */
|
||||||
|
hint: string
|
||||||
|
/**
|
||||||
|
* 'radio' renders chips, 'color' renders color swatches, 'font' renders "Aa"
|
||||||
|
* tiles in each font, 'tile' renders a thumbnail per value, 'preset' renders
|
||||||
|
* thumbnails that set several keys at once.
|
||||||
|
*/
|
||||||
|
control: 'radio' | 'color' | 'font' | 'tile' | 'preset'
|
||||||
|
options: ThemeOption[] | ThemePreset[]
|
||||||
|
/**
|
||||||
|
* Only meaningful while the sidebar is on screen; the panel hides these
|
||||||
|
* settings when the navigation is horizontal, the way Dashkit hides its
|
||||||
|
* sidebar sizing group.
|
||||||
|
*/
|
||||||
|
sidebarOnly?: boolean
|
||||||
|
/** Only meaningful with the top navbar; hidden while the navigation is vertical. */
|
||||||
|
navbarOnly?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThemeSection {
|
||||||
|
title: string
|
||||||
|
settings: ThemeSetting[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Turns a list of raw values into options labelled by their capitalized value. */
|
||||||
|
function autoOptions(values: readonly string[]): ThemeOption[] {
|
||||||
|
return values.map((value) => ({ value, label: ucFirst(value) }))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const themeSections: ThemeSection[] = [
|
||||||
|
{
|
||||||
|
title: 'Appearance',
|
||||||
|
settings: [
|
||||||
|
{
|
||||||
|
key: 'scheme',
|
||||||
|
legend: 'Color scheme',
|
||||||
|
hint: 'The page and its navigation, in one pick.',
|
||||||
|
control: 'preset',
|
||||||
|
options: [
|
||||||
|
{ value: 'auto', label: 'Auto', values: { 'theme': 'auto', 'navbar-theme': 'default' } },
|
||||||
|
{ value: 'light', label: 'Light', values: { 'theme': 'light', 'navbar-theme': 'default' } },
|
||||||
|
{ value: 'dark', label: 'Dark', values: { 'theme': 'dark', 'navbar-theme': 'default' } },
|
||||||
|
{ value: 'dark-nav', label: 'Dark nav', values: { 'theme': 'light', 'navbar-theme': 'dark' } },
|
||||||
|
{ value: 'colored', label: 'Colored', values: { 'theme': 'light', 'navbar-theme': 'primary' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'theme-primary',
|
||||||
|
legend: 'Accent color',
|
||||||
|
hint: 'The accent color used across the app.',
|
||||||
|
control: 'color',
|
||||||
|
options: autoOptions([...site.themeColors, 'inverted']),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'theme-font',
|
||||||
|
legend: 'Font family',
|
||||||
|
hint: 'The typeface used across the app.',
|
||||||
|
control: 'font',
|
||||||
|
options: autoOptions(site.themeFonts),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'theme-base',
|
||||||
|
legend: 'Theme base',
|
||||||
|
hint: 'The gray palette behind every surface.',
|
||||||
|
control: 'tile',
|
||||||
|
options: autoOptions(site.themeBases),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'theme-radius',
|
||||||
|
legend: 'Corner radius',
|
||||||
|
hint: 'How round corners are drawn.',
|
||||||
|
control: 'tile',
|
||||||
|
options: site.themeRadiuses.map((value) => ({ value, label: value })),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Layout',
|
||||||
|
settings: [
|
||||||
|
{
|
||||||
|
key: 'navbar-position',
|
||||||
|
legend: 'Navigation',
|
||||||
|
hint: 'Where the main menu lives.',
|
||||||
|
control: 'tile',
|
||||||
|
options: [
|
||||||
|
{ value: 'horizontal', label: 'Navbar' },
|
||||||
|
{ value: 'vertical', label: 'Sidebar' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'layout',
|
||||||
|
legend: 'Container width',
|
||||||
|
hint: 'How wide the page content runs.',
|
||||||
|
control: 'tile',
|
||||||
|
options: autoOptions(['default', 'fluid', 'boxed']),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'navbar',
|
||||||
|
legend: 'Navbar behavior',
|
||||||
|
hint: 'Keep the navbar visible while scrolling.',
|
||||||
|
control: 'tile',
|
||||||
|
navbarOnly: true,
|
||||||
|
options: [
|
||||||
|
{ value: 'default', label: 'Default' },
|
||||||
|
{ value: 'sticky', label: 'Sticky' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'sidebar',
|
||||||
|
legend: 'Sidebar',
|
||||||
|
hint: 'How the sidebar behaves.',
|
||||||
|
control: 'tile',
|
||||||
|
sidebarOnly: true,
|
||||||
|
options: [
|
||||||
|
{ value: 'default', label: 'Expanded' },
|
||||||
|
{ value: 'folded', label: 'Folded' },
|
||||||
|
{ value: 'folded-hover', label: 'On hover' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Every setting, in panel order. */
|
||||||
|
export const themeSettings: ThemeSetting[] = themeSections.flatMap((section) => section.settings)
|
||||||
|
|
||||||
|
/** The preset groups, keyed by input name: tile value -> the keys and values it sets. */
|
||||||
|
export const themePresets: Record<string, Record<string, Partial<Record<ThemeKey, string>>>> = Object.fromEntries(themeSettings.filter((setting) => setting.control === 'preset').map((setting) => [setting.key, Object.fromEntries((setting.options as ThemePreset[]).map((option) => [option.value, option.values]))]))
|
||||||
|
|
||||||
|
/** Input names of the settings the panel hides while the navigation is horizontal. */
|
||||||
|
export const sidebarOnlyKeys: string[] = themeSettings.filter((setting) => setting.sidebarOnly).map((setting) => setting.key)
|
||||||
|
|
||||||
|
/** Input names of the settings the panel hides while the navigation is vertical. */
|
||||||
|
export const navbarOnlyKeys: string[] = themeSettings.filter((setting) => setting.navbarOnly).map((setting) => setting.key)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the `data-bs-*` attributes a page renders on <html> to express its own
|
||||||
|
* layout. Defaults are omitted, matching what the switcher writes at runtime.
|
||||||
|
*/
|
||||||
|
export function themeDataAttributes(values: Partial<Record<ThemeKey, string | undefined>>): Record<string, string> {
|
||||||
|
const attributes: Record<string, string> = {}
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(values)) {
|
||||||
|
if (value !== undefined && value !== themeDefaults[key as ThemeKey]) {
|
||||||
|
attributes[`data-bs-${key}`] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return attributes
|
||||||
|
}
|
||||||
Reference in new issue
Block a user