diff --git a/.agents/skills/page-layouts/SKILL.md b/.agents/skills/page-layouts/SKILL.md index f991ccbe6..8f72c03cd 100644 --- a/.agents/skills/page-layouts/SKILL.md +++ b/.agents/skills/page-layouts/SKILL.md @@ -45,19 +45,24 @@ Do not pass `pageHeader` when it repeats `title` — the fallback already covers ## 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 ``, 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` | -| `hideTopbar` | `layout-hide-topbar` | -| `navbarTransparent`, `navbarCondensed`, `navbarDark`, `navbarOverlap`, `navbarSticky`, `navbarHideBrand` | the matching `layout-navbar-*` classes | -| `navbarClass` | extra classes on the navbar | +| `navbarPosition` | `data-bs-navbar-position` — `horizontal` (default) or `vertical` | +| `layout` | `data-bs-layout` — `fluid` or `boxed` | +| `navbarSticky` | `data-bs-navbar="sticky"` | +| `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 | | `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 | +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 Anything to the right of the title goes in the `page-header-actions` slot, as a direct child of the layout: diff --git a/.build/tsconfig.json b/.build/tsconfig.json index 1a3d47034..f6d151246 100644 --- a/.build/tsconfig.json +++ b/.build/tsconfig.json @@ -13,5 +13,5 @@ "skipLibCheck": 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"] } diff --git a/.changeset/customize-entry-unified.md b/.changeset/customize-entry-unified.md new file mode 100644 index 000000000..69f0acb1b --- /dev/null +++ b/.changeset/customize-entry-unified.md @@ -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. diff --git a/.changeset/font-family-utilities.md b/.changeset/font-family-utilities.md new file mode 100644 index 000000000..4aad122ed --- /dev/null +++ b/.changeset/font-family-utilities.md @@ -0,0 +1,5 @@ +--- +"@tabler/core": minor +--- + +Added the `.font-sans-serif`, `.font-serif` and `.font-comic` utilities next to `.font-monospace`. diff --git a/.changeset/layout-customizer-panel.md b/.changeset/layout-customizer-panel.md new file mode 100644 index 000000000..0644e67f7 --- /dev/null +++ b/.changeset/layout-customizer-panel.md @@ -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. diff --git a/.changeset/layout-settings-docs.md b/.changeset/layout-settings-docs.md new file mode 100644 index 000000000..feddad821 --- /dev/null +++ b/.changeset/layout-settings-docs.md @@ -0,0 +1,5 @@ +--- +"@tabler/docs": patch +--- + +Documented the layout theme settings on the color modes and page layouts pages. diff --git a/.changeset/layout-theme-settings.md b/.changeset/layout-theme-settings.md new file mode 100644 index 000000000..5e7d3194f --- /dev/null +++ b/.changeset/layout-theme-settings.md @@ -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. diff --git a/.changeset/menu-group-aria-current.md b/.changeset/menu-group-aria-current.md new file mode 100644 index 000000000..0113bd13d --- /dev/null +++ b/.changeset/menu-group-aria-current.md @@ -0,0 +1,5 @@ +--- +"@tabler/preview": patch +--- + +Removed `aria-current="page"` from menu groups, so only the link to the current page carries it. diff --git a/.changeset/navbar-side-vertical.md b/.changeset/navbar-side-vertical.md new file mode 100644 index 000000000..c852838c0 --- /dev/null +++ b/.changeset/navbar-side-vertical.md @@ -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. diff --git a/.changeset/sidebar-active-group-emphasis.md b/.changeset/sidebar-active-group-emphasis.md new file mode 100644 index 000000000..4c9327bcc --- /dev/null +++ b/.changeset/sidebar-active-group-emphasis.md @@ -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. diff --git a/.changeset/sidebar-customize-button.md b/.changeset/sidebar-customize-button.md new file mode 100644 index 000000000..5be9c67a2 --- /dev/null +++ b/.changeset/sidebar-customize-button.md @@ -0,0 +1,5 @@ +--- +"@tabler/preview": patch +--- + +Added a Customize entry to the sidebar footer that opens the theme settings panel. diff --git a/.changeset/sidebar-mobile-brand-centered.md b/.changeset/sidebar-mobile-brand-centered.md new file mode 100644 index 000000000..3b9620c3b --- /dev/null +++ b/.changeset/sidebar-mobile-brand-centered.md @@ -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. diff --git a/.changeset/sidebar-user-always.md b/.changeset/sidebar-user-always.md new file mode 100644 index 000000000..bf42c3aec --- /dev/null +++ b/.changeset/sidebar-user-always.md @@ -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. diff --git a/.changeset/theme-settings-panel-design.md b/.changeset/theme-settings-panel-design.md new file mode 100644 index 000000000..156d15f6b --- /dev/null +++ b/.changeset/theme-settings-panel-design.md @@ -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. diff --git a/.github/labeler.yml b/.github/labeler.yml index 6d78697ef..7a2a31bed 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -25,7 +25,6 @@ javascript: - changed-files: - any-glob-to-any-file: - 'core/js/**' - - 'preview/js/**' icons: - changed-files: diff --git a/core/js/src/theme-config.ts b/core/js/src/theme-config.ts new file mode 100644 index 000000000..c04dd8ed4 --- /dev/null +++ b/core/js/src/theme-config.ts @@ -0,0 +1,21 @@ +/** + * Every setting the theme switcher understands, with its default value. Each + * key becomes a `data-bs-` attribute on ``, a `tabler-` + * localStorage entry and a `?=` 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 diff --git a/core/js/tabler-theme.ts b/core/js/tabler-theme.ts index 163243934..6fd23f637 100644 --- a/core/js/tabler-theme.ts +++ b/core/js/tabler-theme.ts @@ -3,23 +3,7 @@ * 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. */ -interface ThemeConfig { - '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', -} +import { themeDefaults, type ThemeKey } from './src/theme-config' const params = new Proxy(new URLSearchParams(window.location.search), { 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)') -for (const key in themeConfig) { +for (const key in themeDefaults) { const param = params[key] 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. const storedTheme = localStorage.getItem('tabler-' + 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') { 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) } else { document.documentElement.removeAttribute('data-bs-' + key) diff --git a/core/js/tests/unit/tabler-theme.spec.ts b/core/js/tests/unit/tabler-theme.spec.ts new file mode 100644 index 000000000..5f550d18c --- /dev/null +++ b/core/js/tests/unit/tabler-theme.spec.ts @@ -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 { + 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')) + }) +}) diff --git a/core/package.json b/core/package.json index 24a9d2d36..9057f3dbd 100644 --- a/core/package.json +++ b/core/package.json @@ -82,7 +82,7 @@ "files": [ { "path": "./dist/css/tabler.css", - "maxSize": "83 kB" + "maxSize": "84 kB" }, { "path": "./dist/css/tabler.min.css", @@ -90,7 +90,7 @@ }, { "path": "./dist/css/tabler.rtl.css", - "maxSize": "83 kB" + "maxSize": "84 kB" }, { "path": "./dist/css/tabler.rtl.min.css", @@ -226,11 +226,11 @@ }, { "path": "./dist/js/tabler-theme.js", - "maxSize": "1 kB" + "maxSize": "1.3 kB" }, { "path": "./dist/js/tabler-theme.esm.js", - "maxSize": "1 kB" + "maxSize": "1.3 kB" }, { "path": "./dist/js/tabler-theme.min.js", diff --git a/core/scss/_extends.scss b/core/scss/_extends.scss index 736876052..d5c8a8818 100644 --- a/core/scss/_extends.scss +++ b/core/scss/_extends.scss @@ -84,4 +84,16 @@ body[data-theme='dark'] [data-theme='light'] { @extend [data-bs-theme='dark']; } + + // Navbar theme chosen at runtime (data-bs-navbar-theme on ). 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']; + } } diff --git a/core/scss/_utilities.scss b/core/scss/_utilities.scss index d73e25014..681fd5cf7 100644 --- a/core/scss/_utilities.scss +++ b/core/scss/_utilities.scss @@ -601,11 +601,16 @@ $utilities: map.merge( ), ), // 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': ( property: font-family, class: font, values: ( + sans-serif: var(--font-sans-serif), + serif: var(--font-serif), monospace: var(--font-monospace), + comic: var(--font-comic), ), ), 'font-size': ( diff --git a/core/scss/layout/_core.scss b/core/scss/layout/_core.scss index 606466b89..342981d5a 100644 --- a/core/scss/layout/_core.scss +++ b/core/scss/layout/_core.scss @@ -38,7 +38,11 @@ body { // // 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 , set pre-render from localStorage +// by tabler-theme.js. +.layout-fluid, +html[data-bs-layout='fluid'] { .container, [class^='container-'], [class*=' container-'] { @@ -49,7 +53,8 @@ body { // // Boxed container // -.layout-boxed { +.layout-boxed, +html[data-bs-layout='boxed'] body { --theme-boxed-border-radius: 0; --theme-boxed-width: #{map.get($container-max-widths, xxl)}; @@ -60,6 +65,9 @@ body { } .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); margin: 0 auto; color: var(--body-color); @@ -70,7 +78,11 @@ body { 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-end-radius: var(--theme-boxed-border-radius); } diff --git a/core/scss/layout/_navbar.scss b/core/scss/layout/_navbar.scss index 905311dba..b1332ce3c 100644 --- a/core/scss/layout/_navbar.scss +++ b/core/scss/layout/_navbar.scss @@ -47,6 +47,16 @@ 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. .nav-section-title { 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 { flex-direction: column; @@ -165,6 +193,15 @@ --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. .nav-link-title { width: 0; @@ -328,6 +365,20 @@ Navbar @include media-breakpoint-down(breakpoint-next($breakpoint)) { @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: // the avatar alone, with its menu dropping down instead of up. .navbar-footer { @@ -522,6 +573,9 @@ Navbar nav /** 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 { display: flex; flex-direction: row; @@ -530,6 +584,30 @@ Navbar side margin: 0; } +/** +Navbar sticky + */ +// Runtime route only (data-bs-navbar="sticky" on ); 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 ): 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 */ @@ -557,6 +635,34 @@ Navbar vertical } } + // Navbar position (data-bs-navbar-position on ). 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-expand { @each $breakpoint in map.keys($grid-breakpoints) { diff --git a/core/scss/tests/layout-attributes.test.mjs b/core/scss/tests/layout-attributes.test.mjs new file mode 100644 index 000000000..ca686f283 --- /dev/null +++ b/core/scss/tests/layout-attributes.test.mjs @@ -0,0 +1,70 @@ +// Guards the runtime layout switches: the theme script writes data-bs-* on +// , 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/) + }) +}) diff --git a/docs/content/ui/getting-started/color-modes.mdx b/docs/content/ui/getting-started/color-modes.mdx index a9902a4cb..d08fad063 100644 --- a/docs/content/ui/getting-started/color-modes.mdx +++ b/docs/content/ui/getting-started/color-modes.mdx @@ -1,7 +1,7 @@ --- 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. -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. +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 theme attributes for base color, font, primary color, corner radius and layout. related: [/ui/getting-started/customize, /ui/plugins/theme-base-colors] --- @@ -10,7 +10,7 @@ import { Code } from 'astro:components'; ## 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 ``. 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 ``. Tabler's CSS reads these attributes to switch styles instantly, with no page reload. | 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` | | 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` | +| 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 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 `` 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 Load the theme script right after the opening `` 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 -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. 2. **`localStorage`** - each key is stored under `tabler-`, 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: ```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 @@ -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. -- [`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 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) ``` - 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 @@ -101,7 +118,7 @@ document.documentElement.removeAttribute('data-bs-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 diff --git a/docs/content/ui/layout/page-layouts.mdx b/docs/content/ui/layout/page-layouts.mdx index 111996f1d..780aac3a9 100644 --- a/docs/content/ui/layout/page-layouts.mdx +++ b/docs/content/ui/layout/page-layouts.mdx @@ -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. +### 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 + +``` + ### 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: @@ -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. +## Layout options + +Beyond the sidebar, four more layout choices are available as attributes on ``, 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 + + + + + + + +``` + +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 + +``` + +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 + +``` + +This one needs the markup to cooperate. Render **both** navigations as direct children of `.page`, the vertical `aside` first: + +```html +
+ + +
...
+
+``` + +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 + + +``` + +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 + + +``` + +A light navigation inside a dark page is not supported - a light-scoped subtree inside a dark page inherits the dark styles. + ## Accessibility A layout is what a screen reader user navigates by, so the landmarks matter more here than on any single component. diff --git a/package.json b/package.json index 32f82303e..96ac10b9c 100644 --- a/package.json +++ b/package.json @@ -33,9 +33,9 @@ "lint:scss:fix": "pnpm --filter @tabler/core lint:scss:fix", "lint:md": "markdownlint --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: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", "zip-package": "tsx .build/zip-package.ts", "start": "pnpm dev" diff --git a/preview/.build/vite.config.mts b/preview/.build/vite.config.mts deleted file mode 100644 index eb7985cee..000000000 --- a/preview/.build/vite.config.mts +++ /dev/null @@ -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, -}) diff --git a/preview/astro.config.mjs b/preview/astro.config.mjs index 227cd43f9..cf9422838 100644 --- a/preview/astro.config.mjs +++ b/preview/astro.config.mjs @@ -114,7 +114,7 @@ export default defineConfig({ 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). from: path('./tmp-assets'), to: path('./public/preview'), @@ -137,7 +137,7 @@ export default defineConfig({ { from: path('../core/dist'), to: path('./public/dist') }, { 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. reloadDirs: [path('./public/preview')], }), diff --git a/preview/js/demo.ts b/preview/js/demo.ts deleted file mode 100644 index 2d1d1c76c..000000000 --- a/preview/js/demo.ts +++ /dev/null @@ -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 = {} -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) -} diff --git a/preview/package.json b/preview/package.json index 11dc92ba4..a8a398f70 100644 --- a/preview/package.json +++ b/preview/package.json @@ -7,13 +7,11 @@ "node": ">=22.12.0" }, "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", - "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:js": "cross-env PREVIEW_JS_OUT_DIR=public/preview/js vite build --watch --config .build/vite.config.mts", - "watch": "concurrently \"pnpm run watch:css\" \"pnpm run watch:js\"", - "dev": "concurrently -n astro,css,js -c blue,magenta,cyan \"astro dev\" \"pnpm run watch:css\" \"pnpm run watch:js\"", + "watch": "pnpm run watch:css", + "dev": "concurrently -n astro,css -c blue,magenta \"astro dev\" \"pnpm run watch:css\"", "dev:prepare": "pnpm run assets", "build": "pnpm run assets && astro build", "preview": "astro preview", diff --git a/preview/pages/layout-boxed.astro b/preview/pages/layout-boxed.astro index 94ce01422..3a3c256e8 100644 --- a/preview/pages/layout-boxed.astro +++ b/preview/pages/layout-boxed.astro @@ -4,7 +4,7 @@ import HomepageContent from '@shared/components/HomepageContent.astro' import HeaderActionsButtons from '@shared/components/layout/HeaderActionsButtons.astro' --- - + diff --git a/preview/pages/layout-combo.astro b/preview/pages/layout-combo.astro deleted file mode 100644 index c5840d127..000000000 --- a/preview/pages/layout-combo.astro +++ /dev/null @@ -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' ---- - - - - - diff --git a/preview/pages/layout-fluid-vertical.astro b/preview/pages/layout-fluid-vertical.astro index f3f96332a..d9a19a8c6 100644 --- a/preview/pages/layout-fluid-vertical.astro +++ b/preview/pages/layout-fluid-vertical.astro @@ -4,7 +4,7 @@ import HomepageContent from '@shared/components/HomepageContent.astro' import HeaderActionsButtons from '@shared/components/layout/HeaderActionsButtons.astro' --- - + diff --git a/preview/pages/layout-fluid.astro b/preview/pages/layout-fluid.astro index e7ff2d737..b3d83c8f9 100644 --- a/preview/pages/layout-fluid.astro +++ b/preview/pages/layout-fluid.astro @@ -4,7 +4,7 @@ import HomepageContent from '@shared/components/HomepageContent.astro' import HeaderActionsButtons from '@shared/components/layout/HeaderActionsButtons.astro' --- - + diff --git a/preview/pages/layout-folded-hover.astro b/preview/pages/layout-folded-hover.astro index fa3a9e4b5..d488f7cc0 100644 --- a/preview/pages/layout-folded-hover.astro +++ b/preview/pages/layout-folded-hover.astro @@ -4,7 +4,7 @@ import HomepageContent from '@shared/components/HomepageContent.astro' import HeaderActionsButtons from '@shared/components/layout/HeaderActionsButtons.astro' --- - + diff --git a/preview/pages/layout-folded.astro b/preview/pages/layout-folded.astro index b357f8b03..decbbe76d 100644 --- a/preview/pages/layout-folded.astro +++ b/preview/pages/layout-folded.astro @@ -4,7 +4,7 @@ import HomepageContent from '@shared/components/HomepageContent.astro' import HeaderActionsButtons from '@shared/components/layout/HeaderActionsButtons.astro' --- - + diff --git a/preview/pages/layout-vertical-right.astro b/preview/pages/layout-vertical-right.astro index 4697e2bc7..a440e0fd3 100644 --- a/preview/pages/layout-vertical-right.astro +++ b/preview/pages/layout-vertical-right.astro @@ -4,7 +4,7 @@ import HomepageContent from '@shared/components/HomepageContent.astro' import HeaderActionsButtons from '@shared/components/layout/HeaderActionsButtons.astro' --- - + diff --git a/preview/pages/layout-vertical-transparent.astro b/preview/pages/layout-vertical-transparent.astro index aa39eea45..dda75de71 100644 --- a/preview/pages/layout-vertical-transparent.astro +++ b/preview/pages/layout-vertical-transparent.astro @@ -4,7 +4,7 @@ import HomepageContent from '@shared/components/HomepageContent.astro' import HeaderActionsButtons from '@shared/components/layout/HeaderActionsButtons.astro' --- - + diff --git a/preview/pages/layout-vertical.astro b/preview/pages/layout-vertical.astro index 44e54ddcf..c9ad35941 100644 --- a/preview/pages/layout-vertical.astro +++ b/preview/pages/layout-vertical.astro @@ -4,7 +4,7 @@ import HomepageContent from '@shared/components/HomepageContent.astro' import HeaderActionsButtons from '@shared/components/layout/HeaderActionsButtons.astro' --- - + diff --git a/preview/pages/map-fullsize.astro b/preview/pages/map-fullsize.astro index 25b5d3489..4fb67aeb0 100644 --- a/preview/pages/map-fullsize.astro +++ b/preview/pages/map-fullsize.astro @@ -3,7 +3,7 @@ import DefaultLayout from '@shared/layouts/DefaultLayout.astro' import CaptureScript from '@shared/components/CaptureScript.astro' --- - +

Full Size Map

diff --git a/preview/pages/playground-layout.astro b/preview/pages/playground-layout.astro index 8a98c2462..c6aab86b4 100644 --- a/preview/pages/playground-layout.astro +++ b/preview/pages/playground-layout.astro @@ -5,7 +5,7 @@ import CardBody from '@ui/CardBody.astro' import CardTitle from '@ui/CardTitle.astro' --- - +
diff --git a/shared/components/demo/ThemeSettings.astro b/shared/components/demo/ThemeSettings.astro index a44b70ef0..e8e313ab0 100644 --- a/shared/components/demo/ThemeSettings.astro +++ b/shared/components/demo/ThemeSettings.astro @@ -2,156 +2,205 @@ // Offcanvas behavior script lives in BaseLayout (after global scripts). import FormHint from '@ui/FormHint.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 { themeDefaults, themeSections, type ThemeKey, type ThemePreset } from '@shared/lib/theme-config' -const defaults = { - 'theme': 'light', - 'theme-base': 'gray', - 'theme-font': 'sans-serif', - 'theme-primary': 'blue', - 'theme-radius': '1', - 'sidebar': 'default', +const lastSection = themeSections.length - 1 + +// Thumbnails, drawn as inline SVG on the theme's own custom properties so they +// follow the color mode, the gray base and the accent color. The preset tiles +// pin light and dark on purpose (they depict a mode); the layout tiles follow +// the page. All share one 96×64 frame. +type Mode = 'light' | 'dark' +const shade: Record = { light: 'var(--tblr-gray-50)', dark: 'var(--tblr-gray-900)' } +const bar: Record = { light: 'var(--tblr-white)', dark: 'var(--tblr-gray-800)', primary: 'var(--tblr-primary)' } +const lineOn: Record = { 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 = '') => `` +const frame = (fill: string) => `` +const outline = `` +const topBar = (fill: string) => `` +const rail = (w: number, fill: string) => `` +const lines = (x: number, y: number, widths: number[], fill: string, step = 11) => widths.map((w, i) => ``).join('') +const dots = (x: number, y: number, count: number, fill: string) => Array.from({ length: count }, (_, i) => ``).join('') + +const pageHalf = (mode: Mode, right: boolean) => (right ? `` : ``) +const barHalf = (fill: string, right: boolean) => (right ? `` : ``) + +/** A preset tile: page halves and navigation bar halves in the preset's modes. */ +const presetSvg = (values: Partial>) => { + 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) => `` + 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) => `` + +/** 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) => ``).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(`` + 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) + `` + 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) + `` + 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) => ``).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) + ``) + } + default: + return svg(frame(page) + outline) + } } -const sidebarModes = [ - { value: 'default', label: 'Expanded' }, - { value: 'folded', label: 'Folded' }, - { value: 'folded-hover', label: 'Folded with hover' }, -] ---
- -
diff --git a/shared/components/navbar/Navbar.astro b/shared/components/navbar/Navbar.astro index 4d9e6f38c..d4ff6c0f5 100644 --- a/shared/components/navbar/Navbar.astro +++ b/shared/components/navbar/Navbar.astro @@ -14,7 +14,6 @@ interface Props { dark?: boolean | undefined; transparent?: boolean; overlap?: boolean | undefined; - sticky?: boolean | undefined; hideBrand?: boolean | undefined; /** use the sample menu (menu-sample.json) instead of the real one */ sample?: boolean; @@ -43,7 +42,6 @@ const { dark = false, transparent = false, overlap = false, - sticky = false, hideBrand = false, sample = false, personId, @@ -63,15 +61,12 @@ const breakpoint = 'md'; const headerClass = [ `navbar navbar-expand-${breakpoint}`, transparent ? 'navbar-transparent' : background && `bg-${background}`, - sticky && 'sticky-top', overlap && 'navbar-overlap', className, 'd-print-none', ] --- -{sticky && } -
- - - + + +
@@ -184,5 +179,3 @@ const headerClass = [ ) } - -{sticky && } diff --git a/shared/components/navbar/NavbarMenu.astro b/shared/components/navbar/NavbarMenu.astro index d9c12b181..e5b9bd0c0 100644 --- a/shared/components/navbar/NavbarMenu.astro +++ b/shared/components/navbar/NavbarMenu.astro @@ -68,7 +68,7 @@ const chunk = (arr: T[], size: number): T[][] => { role={hasChildren ? 'button' : undefined} aria-haspopup={hasChildren ? 'true' : undefined} aria-expanded={hasChildren ? (autoOpen && key === currentPage[0] ? 'true' : 'false') : undefined} - aria-current={active ? 'page' : undefined} + aria-current={active && !hasChildren ? 'page' : undefined} > {!hideIcons && {item.icon && }} diff --git a/shared/components/navbar/NavbarMenuItem.astro b/shared/components/navbar/NavbarMenuItem.astro index 47d672491..1e0a50c35 100644 --- a/shared/components/navbar/NavbarMenuItem.astro +++ b/shared/components/navbar/NavbarMenuItem.astro @@ -29,6 +29,9 @@ const hasChildren = !!item.children const relative = (url?: string) => (isExternal(url) ? url : `./${url}`) 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}`] --- @@ -36,7 +39,7 @@ const itemClasses = ['dropdown-item', hasChildren && 'dropdown-toggle', active & { hasChildren ? ( + {item.icon && } {item.title} diff --git a/shared/components/navbar/Sidebar.astro b/shared/components/navbar/Sidebar.astro index 36ea9c193..38b6190ca 100644 --- a/shared/components/navbar/Sidebar.astro +++ b/shared/components/navbar/Sidebar.astro @@ -19,13 +19,11 @@ interface Props { foldedHover?: boolean | undefined /** renders a bottom-pinned button toggling the folded state at runtime */ foldToggle?: boolean | undefined - /** renders a user block in the sidebar footer */ - user?: boolean | undefined /** active menu entry, e.g. "layout.vertical" */ 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 @@ -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 stays in the top bar row; on desktop CSS order moves it to the bottom. */ } - {user && } + diff --git a/shared/data/layouts.json b/shared/data/layouts.json deleted file mode 100644 index 2854c044a..000000000 --- a/shared/data/layouts.json +++ /dev/null @@ -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 - } - } -] \ No newline at end of file diff --git a/shared/data/menu.json b/shared/data/menu.json index 2165d3646..28d833661 100644 --- a/shared/data/menu.json +++ b/shared/data/menu.json @@ -401,10 +401,6 @@ "url": "layout-boxed.html", "title": "Boxed" }, - "combo": { - "url": "layout-combo.html", - "title": "Combined" - }, "condensed": { "url": "layout-condensed.html", "title": "Condensed" diff --git a/shared/layouts/BaseLayout.astro b/shared/layouts/BaseLayout.astro index c172cdadc..e0f01b1b9 100644 --- a/shared/layouts/BaseLayout.astro +++ b/shared/layouts/BaseLayout.astro @@ -4,6 +4,7 @@ import { site } from '@shared/lib/site' import PageModals from '@shared/components/PageModals.astro' import PageScripts from '@shared/components/PageScripts.astro' import libs from '@tabler/core/libs.json' +import { themeDefaults, themePresets, sidebarOnlyKeys, navbarOnlyKeys, themeDataAttributes, type ThemeKey } from '@shared/lib/theme-config' interface Props { title?: string | undefined @@ -17,9 +18,15 @@ interface Props { base?: string /** — falls back to the site description */ description?: string | undefined + /** layout settings the page renders as data-bs-* on ; the switcher's stored choice still wins */ + themeSettings?: Partial> | 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. const environment = 'development' @@ -38,7 +45,7 @@ const libJsFiles = (head: boolean) => pageLibEntries.filter(([, lib]) => Boolean --- - + @@ -118,14 +125,10 @@ const libJsFiles = (head: boolean) => pageLibEntries.filter(([, lib]) => Boolean - - - - -