diff --git a/.agents/rules/main.mdc b/.agents/rules/main.mdc index 9a3da1c0e..972df2148 100644 --- a/.agents/rules/main.mdc +++ b/.agents/rules/main.mdc @@ -64,6 +64,7 @@ Task-specific instructions live in `.agents/skills//SKILL.md` — read the - Follow Tabler's CSS custom properties pattern: `--component-property`. - Cards: `card` for containers, `card-body` for content, `card-header` / `card-title` for headers. - Buttons: `btn` for all buttons; `btn-primary` for primary actions; plain `btn` for secondary actions (do not use `btn-outline-secondary`); `btn-sm` for small buttons; `w-100` for full width. +- `btn-action` is always combined with `btn` (typically `btn btn-action btn-sm` for icon actions in headers and toolbars) — never use `btn-action` on its own. - Forms: `form-control` for inputs, `form-label` for labels, `form-check` for checkboxes/radios, `form-select` for dropdowns. - Layout: Bootstrap grid (`row`, `col-*`), `container-xl` for main containers, `page-wrapper` / `page-body` for page structure. - Badges: plain `badge` class; do not use `badge-outline` or `badge-primary`; do not change badge text color. diff --git a/.agents/skills/demo-pages/SKILL.md b/.agents/skills/demo-pages/SKILL.md index 98215266a..3f055f8e4 100644 --- a/.agents/skills/demo-pages/SKILL.md +++ b/.agents/skills/demo-pages/SKILL.md @@ -84,6 +84,8 @@ Use the list wrappers instead of margin utilities on each child: The pluralised class names (`.badges-list`, `.tags-list`) are deprecated aliases of `.badge-list` / `.tag-list`. Do not add `me-2`/`mb-2` to individual items. +Icon action buttons: `btn-action` is always combined with `btn` (`btn btn-action`, plus `btn-sm` when small) — never use `btn-action` on its own. + ## 6. Data and repetition - Loop over `shared/data/*.json` (`site.json` colors, `people.json`, `flags.json`) instead of pasting 20 near-identical blocks. diff --git a/.changeset/btn-floating-end.md b/.changeset/btn-floating-end.md new file mode 100644 index 000000000..2fb8f1c00 --- /dev/null +++ b/.changeset/btn-floating-end.md @@ -0,0 +1,5 @@ +--- +"@tabler/core": patch +--- + +Moved `btn-floating` to the end side of the screen, so it does not cover the sidebar. diff --git a/.changeset/folded-sidebar.md b/.changeset/folded-sidebar.md new file mode 100644 index 000000000..0df5560ff --- /dev/null +++ b/.changeset/folded-sidebar.md @@ -0,0 +1,7 @@ +--- +"@tabler/core": minor +"@tabler/preview": minor +"@tabler/docs": minor +--- + +Added a folded sidebar (`navbar-folded`, `navbar-folded-hover`) with flyout submenus, a pin toggle and a light `16rem` sidebar by default. diff --git a/.changeset/navbar-toggler-squeeze.md b/.changeset/navbar-toggler-squeeze.md new file mode 100644 index 000000000..4bf483e18 --- /dev/null +++ b/.changeset/navbar-toggler-squeeze.md @@ -0,0 +1,5 @@ +--- +"@tabler/core": patch +--- + +Fixed the `navbar-toggler` icon: the middle bar was flex-squeezed, breaking the open-state X and shortening the hamburger. diff --git a/.changeset/sidebar-section-titles.md b/.changeset/sidebar-section-titles.md new file mode 100644 index 000000000..85255982a --- /dev/null +++ b/.changeset/sidebar-section-titles.md @@ -0,0 +1,7 @@ +--- +"@tabler/core": minor +"@tabler/preview": minor +"@tabler/docs": minor +--- + +Added `nav-section-title` group labels to the vertical sidebar, shown as short separators in the folded state. diff --git a/.changeset/sidebar-user-footer.md b/.changeset/sidebar-user-footer.md new file mode 100644 index 000000000..a3bad8fdc --- /dev/null +++ b/.changeset/sidebar-user-footer.md @@ -0,0 +1,7 @@ +--- +"@tabler/core": minor +"@tabler/preview": minor +"@tabler/docs": minor +--- + +Added a `navbar-footer` sidebar zone with a user block; the sidebar nav scrolls between the pinned brand and footer. diff --git a/core/js/src/sidebar.ts b/core/js/src/sidebar.ts new file mode 100644 index 000000000..eea8d27b2 --- /dev/null +++ b/core/js/src/sidebar.ts @@ -0,0 +1,101 @@ +import { Dropdown } from './bootstrap' + +/* +Folded sidebar toggle + */ +// js-docs-start sidebar-folded-toggle +const syncSidebarToggles = (folded: boolean): void => { + for (const toggle of document.querySelectorAll('[data-bs-toggle="sidebar-folded"]')) { + toggle.setAttribute('aria-pressed', String(folded)) + } +} + +const sidebarIsFolded = (): boolean => (document.documentElement.getAttribute('data-bs-sidebar') ?? '').startsWith('folded') || !!document.querySelector('.navbar-vertical.navbar-folded, .navbar-vertical.navbar-folded-hover') + +const hideSidebarDropdowns = (except?: Element | null): void => { + for (const toggle of document.querySelectorAll('.navbar-vertical [data-bs-toggle="dropdown"][aria-expanded="true"]')) { + if (toggle !== except) { + ;(Dropdown.getInstance(toggle) as InstanceType | null)?.hide() + } + } +} + +// A sidebar rendered for the expanded state keeps its submenus open on outside +// clicks (data-bs-auto-close="false"). Folded flyouts must not: close them on +// any click outside their menu, and close the other flyouts when one opens. +document.addEventListener('click', (event: MouseEvent) => { + if (!sidebarIsFolded()) { + return + } + + const target = event.target as Element + if (target.closest('.navbar-vertical .dropdown-menu')) { + return + } + + hideSidebarDropdowns(target.closest('.navbar-vertical [data-bs-toggle="dropdown"]')) +}) + +// When a hover-expanding sidebar collapses back to the rail, close its open +// submenus — an inline submenu left open would flash as a floating flyout. +// Static folded rails are excluded: crossing the small gap between the rail +// and an open flyout must not close it. mouseleave does not bubble, so the +// listener runs in the capture phase. +document.addEventListener( + 'mouseleave', + (event: MouseEvent) => { + const target = event.target as Element + if (!(target instanceof Element) || !target.matches('.navbar-vertical')) { + return + } + + const hoverVariant = document.documentElement.getAttribute('data-bs-sidebar') === 'folded-hover' || target.classList.contains('navbar-folded-hover') + if (hoverVariant) { + hideSidebarDropdowns() + } + }, + true, +) + +// The folded state may come pre-render from localStorage (tabler-theme.js), so +// align the toggle buttons with it once the DOM is available. Server-rendered +// open submenus are also closed, so they don't hang as open flyouts. +document.addEventListener('DOMContentLoaded', () => { + const folded = (document.documentElement.getAttribute('data-bs-sidebar') ?? '').startsWith('folded') + syncSidebarToggles(folded) + + if (folded) { + for (const menu of document.querySelectorAll('.navbar-vertical .dropdown-menu.show')) { + menu.classList.remove('show') + menu.parentElement?.querySelector('[data-bs-toggle="dropdown"]')?.setAttribute('aria-expanded', 'false') + } + } +}) + +document.addEventListener('click', (event: MouseEvent) => { + const trigger = (event.target as Element).closest('[data-bs-toggle="sidebar-folded"]') + if (!trigger) { + return + } + + const html = document.documentElement + const willFold = !(html.getAttribute('data-bs-sidebar') ?? '').startsWith('folded') + + // Unpinning folds to the hover variant: the rail expands on hover, where + // the pin button becomes visible again and can pin the sidebar back open. + if (willFold) { + html.setAttribute('data-bs-sidebar', 'folded-hover') + } else { + html.removeAttribute('data-bs-sidebar') + } + + localStorage.setItem('tabler-sidebar', willFold ? 'folded-hover' : 'default') + syncSidebarToggles(willFold) + + // Close open sidebar dropdowns so an inline submenu doesn't turn into a + // stuck flyout (and vice versa) when the layout mode changes. + hideSidebarDropdowns() + + document.dispatchEvent(new CustomEvent('tabler:sidebar-folded', { detail: { folded: willFold } })) +}) +// js-docs-end sidebar-folded-toggle diff --git a/core/js/tabler-theme.ts b/core/js/tabler-theme.ts index 597a9a19d..d1099ed75 100644 --- a/core/js/tabler-theme.ts +++ b/core/js/tabler-theme.ts @@ -9,6 +9,7 @@ interface ThemeConfig { 'theme-font': string 'theme-primary': string 'theme-radius': string + 'sidebar': string } const themeConfig: ThemeConfig = { @@ -17,6 +18,7 @@ const themeConfig: ThemeConfig = { 'theme-font': 'sans-serif', 'theme-primary': 'blue', 'theme-radius': '1', + 'sidebar': 'default', } const params = new Proxy(new URLSearchParams(window.location.search), { diff --git a/core/js/tabler.ts b/core/js/tabler.ts index 1ddc24a76..35d459052 100644 --- a/core/js/tabler.ts +++ b/core/js/tabler.ts @@ -2,6 +2,7 @@ import './src/autosize' import './src/countup' import './src/input-mask' import './src/dropdown' +import './src/sidebar' import './src/tooltip' import './src/popover' import './src/switch-icon' diff --git a/core/js/tests/unit/sidebar.spec.ts b/core/js/tests/unit/sidebar.spec.ts new file mode 100644 index 000000000..a27443129 --- /dev/null +++ b/core/js/tests/unit/sidebar.spec.ts @@ -0,0 +1,118 @@ +import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest' +import { clearFixture, getFixture } from '../helpers/fixture' +import { Dropdown } from '../../src/bootstrap' + +import '../../src/sidebar' + +describe('sidebar folded toggle', () => { + let fixtureEl: HTMLElement + + beforeAll(() => { + fixtureEl = getFixture() + }) + + beforeEach(() => { + fixtureEl.innerHTML = '' + }) + + afterEach(() => { + clearFixture() + document.documentElement.removeAttribute('data-bs-sidebar') + localStorage.removeItem('tabler-sidebar') + }) + + const trigger = (): HTMLButtonElement => fixtureEl.querySelector('button')! + + it('folds the sidebar to the hover variant on first click', () => { + trigger().click() + + expect(document.documentElement.getAttribute('data-bs-sidebar')).toBe('folded-hover') + expect(localStorage.getItem('tabler-sidebar')).toBe('folded-hover') + expect(trigger().getAttribute('aria-pressed')).toBe('true') + }) + + it('unfolds the sidebar on second click', () => { + trigger().click() + trigger().click() + + expect(document.documentElement.hasAttribute('data-bs-sidebar')).toBe(false) + expect(localStorage.getItem('tabler-sidebar')).toBe('default') + expect(trigger().getAttribute('aria-pressed')).toBe('false') + }) + + it('unfolds from the static folded state', () => { + document.documentElement.setAttribute('data-bs-sidebar', 'folded') + + trigger().click() + + expect(document.documentElement.hasAttribute('data-bs-sidebar')).toBe(false) + expect(localStorage.getItem('tabler-sidebar')).toBe('default') + }) + + it('dispatches tabler:sidebar-folded with the new state', () => { + const listener = vi.fn() + document.addEventListener('tabler:sidebar-folded', listener, { once: true }) + + trigger().click() + + expect(listener).toHaveBeenCalledTimes(1) + expect((listener.mock.calls[0][0] as CustomEvent).detail).toEqual({ folded: true }) + }) + + it('closes an open sidebar dropdown on an outside click when folded', () => { + document.documentElement.setAttribute('data-bs-sidebar', 'folded-hover') + fixtureEl.innerHTML = ['', ''].join('') + const hide = vi.fn() + vi.spyOn(Dropdown, 'getInstance').mockReturnValue({ hide } as never) + + fixtureEl.querySelector('#outside')!.click() + + expect(hide).toHaveBeenCalledTimes(1) + vi.restoreAllMocks() + }) + + it('keeps an open sidebar dropdown when clicking inside its menu when folded', () => { + document.documentElement.setAttribute('data-bs-sidebar', 'folded-hover') + fixtureEl.innerHTML = [''].join('') + const hide = vi.fn() + vi.spyOn(Dropdown, 'getInstance').mockReturnValue({ hide } as never) + + fixtureEl.querySelector('#inside')!.click() + + expect(hide).not.toHaveBeenCalled() + vi.restoreAllMocks() + }) + + it('closes open submenus when the pointer leaves a hover-variant sidebar', () => { + document.documentElement.setAttribute('data-bs-sidebar', 'folded-hover') + fixtureEl.innerHTML = [''].join('') + const hide = vi.fn() + vi.spyOn(Dropdown, 'getInstance').mockReturnValue({ hide } as never) + + fixtureEl.querySelector('aside')!.dispatchEvent(new MouseEvent('mouseleave')) + + expect(hide).toHaveBeenCalledTimes(1) + vi.restoreAllMocks() + }) + + it('keeps flyouts open when the pointer leaves a static folded sidebar', () => { + document.documentElement.setAttribute('data-bs-sidebar', 'folded') + fixtureEl.innerHTML = [''].join('') + const hide = vi.fn() + vi.spyOn(Dropdown, 'getInstance').mockReturnValue({ hide } as never) + + fixtureEl.querySelector('aside')!.dispatchEvent(new MouseEvent('mouseleave')) + + expect(hide).not.toHaveBeenCalled() + vi.restoreAllMocks() + }) + + it('ignores clicks outside a toggle', () => { + fixtureEl.innerHTML = '' + + fixtureEl.querySelector('button')!.click() + + expect(document.documentElement.hasAttribute('data-bs-sidebar')).toBe(false) + expect(localStorage.getItem('tabler-sidebar')).toBeNull() + }) +}) diff --git a/core/package.json b/core/package.json index 605670f7f..d0a17481c 100644 --- a/core/package.json +++ b/core/package.json @@ -84,19 +84,19 @@ "files": [ { "path": "./dist/css/tabler.css", - "maxSize": "80 kB" + "maxSize": "83 kB" }, { "path": "./dist/css/tabler.min.css", - "maxSize": "75 kB" + "maxSize": "78 kB" }, { "path": "./dist/css/tabler.rtl.css", - "maxSize": "79 kB" + "maxSize": "83 kB" }, { "path": "./dist/css/tabler.rtl.min.css", - "maxSize": "75 kB" + "maxSize": "78 kB" }, { "path": "./dist/css/tabler-flags.css", diff --git a/core/scss/_variables.scss b/core/scss/_variables.scss index cb7adcfa8..e924d5c7e 100644 --- a/core/scss/_variables.scss +++ b/core/scss/_variables.scss @@ -1578,7 +1578,7 @@ $navbar-light-brand-color: var(--body-color) !default; $navbar-light-active-color: var(--body-color) !default; $navbar-light-hover-color: var(--body-color) !default; $navbar-light-disabled-color: var(--disabled-color) !default; -$navbar-light-active-bg: rgba(0, 0, 0, 0.2) !default; +$navbar-light-active-bg: color-transparent(var(--primary), 0.08) !default; // Spelled with the literal prefix: this value is url-encoded into the svg data // uri below, so the build-time prefixing in .build/build-css.ts never sees a // `var()` here to rewrite (it arrives as `var%28--body-color%29`). @@ -1589,6 +1589,7 @@ $navbar-light-brand-hover-color: $navbar-light-active-color !default; $navbar-dark-color: rgba($white, $text-secondary-opacity) !default; $navbar-dark-hover-color: rgba($white, 0.75) !default; +$navbar-dark-active-bg: rgba($white, 0.06) !default; $navbar-dark-brand-color: $white !default; $navbar-dark-active-color: $white !default; $navbar-dark-disabled-color: var(--disabled-color) !default; @@ -1612,13 +1613,23 @@ $navbar-toggler-transition: box-shadow 0.15s ease-in-out !default; $navbar-toggler-animation-time: 0.2s !default; $navbar-overlap-height: 9rem !default; -$navbar-nav-link-hover-bg: color-transparent(var(--nav-link-color), 0.04) !default; +$navbar-nav-link-hover-bg: color-transparent(var(--nav-link-color), 0.06) !default; $navbar-active-border-color: var(--primary) !default; // scss-docs-end navbar-variables // Sidebar -$sidebar-width: 15rem !default; +$sidebar-width: 16rem !default; +$sidebar-folded-width: 4rem !default; +$sidebar-inset: 0.5rem !default; +$sidebar-nav-padding-y: 0.75rem !default; +$sidebar-nav-padding-x: 0.75rem !default; +$sidebar-nav-icon-width: 1.5rem !default; +$sidebar-nav-icon-height: 1.25rem !default; +$sidebar-nav-indent: 1.5rem !default; +$sidebar-brand-padding-x: ($sidebar-folded-width - $navbar-brand-image-height) * 0.5 !default; +$sidebar-section-title-font-size: 0.75rem !default; +$sidebar-section-title-margin-top: 1rem !default; // Page $page-title-font-size: var(--font-size-h2) !default; diff --git a/core/scss/bootstrap/_navbar.scss b/core/scss/bootstrap/_navbar.scss index 5e88d7dc5..4e26f6ffe 100644 --- a/core/scss/bootstrap/_navbar.scss +++ b/core/scss/bootstrap/_navbar.scss @@ -274,6 +274,7 @@ --navbar-hover-color: #{$navbar-dark-hover-color}; --navbar-disabled-color: #{$navbar-dark-disabled-color}; --navbar-active-color: #{$navbar-dark-active-color}; + --navbar-active-bg: #{$navbar-dark-active-bg}; --navbar-brand-color: #{$navbar-dark-brand-color}; --navbar-brand-hover-color: #{$navbar-dark-brand-hover-color}; --navbar-toggler-border-color: #{$navbar-dark-toggler-border-color}; diff --git a/core/scss/layout/_navbar.scss b/core/scss/layout/_navbar.scss index 96624a78e..905311dba 100644 --- a/core/scss/layout/_navbar.scss +++ b/core/scss/layout/_navbar.scss @@ -3,6 +3,67 @@ @use '../config' as *; @mixin navbar-vertical-nav { + // Applies to every navbar-nav in the vertical navbar — the menu inside + // .navbar-collapse and e.g. a .navbar-footer list outside of it. + .navbar-nav { + // Sidebar icons carry more meaning than inline tab icons, so they get + // stronger contrast than the global 50% $nav-link-icon-color. Derived + // from --navbar-color, which is defined at this scope (unlike + // --nav-link-color, which only exists inside .nav). + --nav-link-icon-color: #{color-transparent(var(--navbar-color), 0.7)}; + --nav-link-icon-width: #{$sidebar-nav-icon-width}; + --nav-link-icon-height: #{$sidebar-nav-icon-height}; + --sidebar-inset: #{$sidebar-inset}; + --sidebar-nav-padding-y: #{$sidebar-nav-padding-y}; + --sidebar-nav-padding-x: #{$sidebar-nav-padding-x}; + --sidebar-nav-indent: #{$sidebar-nav-indent}; + --sidebar-section-title-font-size: #{$sidebar-section-title-font-size}; + --sidebar-section-title-margin-top: #{$sidebar-section-title-margin-top}; + // Inset the list so the rounded hover/active pills never touch the + // sidebar edges. The same inset and link padding apply in the folded + // state, so icons keep their exact position when the sidebar folds. + padding-inline: var(--sidebar-inset); + margin-inline-start: 0; + margin-inline-end: 0; + + .nav-link { + justify-content: start; + padding: var(--sidebar-nav-padding-y) var(--sidebar-nav-padding-x); + } + + // One line, ellipsized — a title never wraps, so rows keep their height + // while the folding sidebar animates its width. + .nav-link-title { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + // stylelint-disable-next-line selector-max-class + .nav-item.active > .nav-link { + --nav-link-icon-color: currentColor; + font-weight: var(--font-weight-medium); + background: var(--navbar-active-bg); + } + + // Group label — aligned with the item text, muted like the icons. + .nav-section-title { + display: block; + padding: 0.25rem var(--sidebar-nav-padding-x); + margin-top: var(--sidebar-section-title-margin-top); + font-size: var(--sidebar-section-title-font-size); + font-weight: var(--font-weight-medium); + color: #{color-transparent(var(--navbar-color), 0.7)}; + text-transform: uppercase; + letter-spacing: 0.04em; + + &:first-child { + margin-top: 0; + } + } + } + .navbar-collapse { flex-direction: column; @@ -12,16 +73,6 @@ padding: 0; } - .navbar-nav { - margin-inline-start: 0; - margin-inline-end: 0; - - .nav-link { - justify-content: start; - padding: 0.5rem calc(#{$container-padding-x} / 2); - } - } - .dropdown-menu-columns { flex-direction: column; } @@ -40,8 +91,11 @@ display: flex; width: auto; min-width: 0; - padding-inline-start: add(calc(#{$container-padding-x} / 2), 1.75rem); + // Link padding + the icon column — items align with the parent + // link's title, whatever the icon size and paddings are set to. + padding-inline-start: calc(var(--sidebar-nav-padding-x) + var(--nav-link-icon-width) + var(--nav-link-icon-margin-end)); color: inherit; + @include border-radius(var(--border-radius)); &.disabled { color: var(--disabled-color); @@ -51,19 +105,20 @@ &.active, &:active { + font-weight: var(--font-weight-medium); background: var(--navbar-active-bg); } } .dropdown-menu .dropdown-item { - padding-inline-start: add(calc(#{$container-padding-x} / 2), 3.25rem); + padding-inline-start: calc(var(--sidebar-nav-padding-x) + var(--nav-link-icon-width) + var(--nav-link-icon-margin-end) + var(--sidebar-nav-indent)); } // Third-level submenu in the mobile vertical navbar — genuinely nested two // dropdown-menu levels deep. // stylelint-disable-next-line selector-max-class, selector-max-compound-selectors .dropdown-menu .dropdown-menu .dropdown-item { - padding-inline-start: add(calc(#{$container-padding-x} / 2), 4.75rem); + padding-inline-start: calc(var(--sidebar-nav-padding-x) + var(--nav-link-icon-width) + var(--nav-link-icon-margin-end) + 2 * var(--sidebar-nav-indent)); } } @@ -71,12 +126,133 @@ margin-inline-start: auto; } + // The active pill is the selection indicator — no underline or edge bar. .nav-item.active::after { - inset-inline-end: auto; + content: none; + } + } +} + +// Icon-only rail: hides link titles, centers icons and turns the inline submenus +// flattened by navbar-vertical-nav() back into floating flyout dropdowns. +@mixin navbar-vertical-folded { + // Flyouts must escape the rail, so the folded sidebar itself cannot scroll. + overflow: visible; + + // A wide brand (e.g. a full logo) must not push the container beyond the + // rail width; the brand crops instead. + [class^='container'] { + min-width: 0; + } + + // The brand keeps the expanded state's start padding, so the logo does not + // move when the sidebar folds; anything wider than the rail crops. + .navbar-brand { + overflow: hidden; + } + + // The pin button lives on the expanded sidebar only; the folded rail + // unfolds by hovering (folded-hover) or via an outside control. + [data-bs-toggle='sidebar-folded'] { + display: none; + } + + // The list inset and link padding are identical in both states, so the + // icons keep their exact position — only the titles collapse. Aside-wide + // (not .navbar-collapse-scoped), so the footer outside the collapse folds + // the same way. + .navbar-nav .nav-link { + --nav-link-icon-margin-end: 0; + } + + // Collapsed, not display: none — titles stay in the accessibility tree. + .nav-link-title { + width: 0; + overflow: hidden; + white-space: nowrap; + opacity: 0; + } + + .navbar-collapse { + // Flyouts must escape the nav's scroll area; the folded rail therefore + // cannot scroll (documented — the hover variant is the escape hatch). + overflow: visible; + + // The label text cannot fit the rail — indent it away (stays in the + // accessibility tree, line box keeps the element height stable) and show + // a short separator instead, centered within the WHOLE gap between the + // groups: the box's 50% is shifted up by half the label's top margin. + .nav-section-title { + position: relative; + overflow: hidden; + text-indent: -9999px; + white-space: nowrap; + + &::before { + position: absolute; + inset-inline-start: calc(50% - 0.625rem); + top: calc(50% - var(--sidebar-section-title-margin-top) / 2); + width: 1.25rem; + height: var(--border-width); + content: ''; + background: #{color-transparent(var(--navbar-color), 0.2)}; + } + + // Nothing above the first group to separate — no line, and no margin + // shift to compensate for either. + &:first-child::before { + content: none; + } + } + + .nav-link.dropdown-toggle::after { + display: none; + } + + // Restore the multi-column layout that navbar-vertical-nav() stacks — + // it keeps long flyout menus from overflowing the viewport. + .dropdown-menu-columns { + flex-direction: row; + } + + .dropdown-menu { + position: absolute; + inset-inline-start: 100%; top: 0; - bottom: 0; - border-inline-start-width: 3px; - border-bottom-width: 0; + min-width: var(--dropdown-min-width); + padding: var(--dropdown-padding-y) var(--dropdown-padding-x); + margin: 0; + color: var(--dropdown-color); + background-color: var(--dropdown-bg); + background-clip: padding-box; + // No border — like the base .dropdown-menu, whose edge is the 1px ring + // built into --shadow-dropdown. + box-shadow: var(--dropdown-box-shadow); + @include border-radius(var(--dropdown-border-radius)); + + .dropdown-item { + padding: var(--dropdown-item-padding-y) var(--dropdown-item-padding-x); + color: var(--dropdown-link-color); + @include border-radius(0); + } + + // Undo the tree indents navbar-vertical-nav() puts on nested levels. + .dropdown-menu .dropdown-item { + padding-inline-start: var(--dropdown-item-padding-x); + } + + // stylelint-disable-next-line selector-max-class, selector-max-compound-selectors + .dropdown-menu .dropdown-menu .dropdown-item { + padding-inline-start: var(--dropdown-item-padding-x); + } + } + + // The nav list is inset, so 100% of the .dropdown lands short of the rail + // edge — compensate for the inset and add a small gap, so the flyout's + // border does not sit flush against the rail's edge border. Nested + // (dropend) flyouts are not inset and keep the plain 100%. + .nav-item > .dropdown-menu { + inset-inline-start: calc(100% + var(--sidebar-inset) + 0.25rem); } } } @@ -118,6 +294,12 @@ Navbar --nav-link-icon-margin-end: #{$nav-link-icon-margin-end}; min-height: subtract($navbar-height, 2 * $navbar-padding-y); + // Section labels only make sense in a vertical list — the vertical + // navbar styles re-enable them. + .nav-section-title { + display: none; + } + .nav-link { position: relative; justify-content: center; @@ -145,6 +327,21 @@ Navbar &#{$infix} { @include media-breakpoint-down(breakpoint-next($breakpoint)) { @include navbar-vertical-nav(); + + // 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 { + .nav-link-title { + display: none; + } + + .dropup .dropdown-menu { + inset-inline-start: auto; + inset-inline-end: 0; + top: 100%; + bottom: auto; + } + } } @include media-breakpoint-up($next) { @@ -183,7 +380,7 @@ Navbar &.navbar-vertical { ~ .navbar, ~ .page-wrapper { - margin-inline-start: $sidebar-width; + margin-inline-start: var(--sidebar-width); } } @@ -192,7 +389,19 @@ Navbar ~ .navbar, ~ .page-wrapper { margin-inline-start: 0; - margin-inline-end: $sidebar-width; + margin-inline-end: var(--sidebar-width); + } + } + } + + // In the first, tightest segment after a horizontal navbar expands there + // is no room for icons — hide them there, derived from the navbar's own + // navbar-expand-* breakpoint, so the markup needs no display utilities. + // The vertical navbar always shows its icons. + @if $next and breakpoint-next($next, $grid-breakpoints) { + @include media-breakpoint-only($next) { + &:not(.navbar-vertical) .navbar-nav .nav-link-icon { + display: none; } } } @@ -237,6 +446,10 @@ Navbar toggler .navbar-toggler-icon { position: relative; + // The button's padding leaves less room than the bar is wide — without + // this the middle bar gets flex-squeezed while the pseudo bars keep their + // inherited width, and the open-state X loses a leg. + flex-shrink: 0; width: 1.25em; height: 2px; background: currentColor; @@ -321,6 +534,29 @@ Navbar side Navbar vertical */ @if $enable-navbar-vertical { + // Folded sidebar: every geometry consumer (aside width, page paddings, + // page-wrapper/navbar margins) reads --sidebar-width, so flipping this one + // variable re-lays-out the whole page. Two routes to the same state: + // a .navbar-folded/.navbar-folded-hover class on the aside (static markup), + // or data-bs-sidebar="folded"/"folded-hover" on (set pre-render from + // localStorage by tabler-theme.js, toggled by [data-bs-toggle="sidebar-folded"]). + // html[…] beats the :root declaration of --sidebar-width on the same element; + // a bare :where()/[…] selector would lose to it and never take effect. + html[data-bs-sidebar^='folded'] { + --sidebar-width: var(--sidebar-folded-width); + } + + .navbar-vertical:is(.navbar-folded, .navbar-folded-hover) { + --sidebar-width: var(--sidebar-folded-width); + + // The class sits on the aside, so its layout siblings need their own copy. + ~ .page, + ~ .page-wrapper, + ~ .navbar { + --sidebar-width: var(--sidebar-folded-width); + } + } + .navbar-vertical { &.navbar-expand { @each $breakpoint in map.keys($grid-breakpoints) { @@ -335,9 +571,8 @@ Navbar vertical bottom: 0; z-index: $zindex-fixed; align-items: start; - width: $sidebar-width; + width: var(--sidebar-width); padding: 0; - overflow-y: scroll; @include transition(transform $transition-time); &.navbar-right, @@ -346,13 +581,36 @@ Navbar vertical inset-inline-end: 0; } + // Logo on the start side, room for an action (e.g. the pin + // toggle) on the end side. The inline padding centers the logo + // mark in the folded rail and is identical in both states, so the + // logo mark and the nav icons share one center axis and nothing + // moves when the sidebar folds. .navbar-brand { - justify-content: center; - padding: (($navbar-height - $navbar-brand-image-height) * 0.5) 0; + --sidebar-brand-padding-x: #{$sidebar-brand-padding-x}; + justify-content: space-between; + padding: (($navbar-height - $navbar-brand-image-height) * 0.5) var(--sidebar-brand-padding-x); + margin-inline-end: 0; } + // The fold toggle keeps its layout slot (no jumping) but only + // becomes visible while the sidebar is hovered or focused. + [data-bs-toggle='sidebar-folded'] { + opacity: 0; + @include transition(opacity $transition-time); + } + + &:hover [data-bs-toggle='sidebar-folded'], + &:has(:focus-visible) [data-bs-toggle='sidebar-folded'] { + opacity: 1; + } + + // The nav is the sidebar's scroll area — the brand above and the + // footer below stay pinned. .navbar-collapse { align-items: stretch; + min-height: 0; + overflow-y: auto; } .navbar-nav { @@ -366,16 +624,44 @@ Navbar vertical } } + // Bottom-pinned footer area, e.g. a user block. Sits after the + // growing nav, separated with the navbar border. + .navbar-footer { + // In the markup the footer sits before the collapse (so it + // stays in the mobile top bar row); order moves it to the + // bottom of the desktop column. + order: 1; + padding-block: $sidebar-inset; + margin-top: auto; + border-top: var(--navbar-border-width) var(--border-style) var(--navbar-border-color); + + // Tighter link padding than the menu — the avatar brings its + // own bulk. Overrides the variables the nav-link padding reads, + // so it wins regardless of rule order. + .navbar-nav { + --sidebar-nav-padding-y: 0.5rem; + --sidebar-nav-padding-x: 0.5rem; + flex-grow: 0; + } + + .nav-link .avatar { + // Never squeezed by the appearing title during the hover + // unfold animation. + flex-shrink: 0; + margin-inline-end: var(--nav-link-icon-margin-end); + } + } + > [class^='container'] { flex-direction: column; align-items: stretch; justify-content: start; - min-height: 100%; + height: 100%; padding: 0; } ~ .page { - padding-inline-start: $sidebar-width; + padding-inline-start: var(--sidebar-width); [class^='container'] { padding-inline-start: 1.5rem; @@ -386,10 +672,39 @@ Navbar vertical &.navbar-right ~ .page, &.navbar-end ~ .page { padding-inline-start: 0; - padding-inline-end: $sidebar-width; + padding-inline-end: var(--sidebar-width); } @include navbar-vertical-nav(); + + // Folded state, both routes; the hover variant folds only while + // not hovered and not holding visible keyboard focus. Plain + // :focus-within would keep the sidebar stuck open after a mouse + // click, because the clicked link keeps focus. + &:is(.navbar-folded, .navbar-folded-hover:not(:hover):not(:has(:focus-visible))), + :where([data-bs-sidebar='folded']) &, + :where([data-bs-sidebar='folded-hover']) &:not(:hover):not(:has(:focus-visible)) { + @include navbar-vertical-folded(); + } + + &.navbar-folded-hover, + :where([data-bs-sidebar='folded-hover']) & { + @include transition(transform $transition-time, width $transition-time); + + .nav-link-title { + @include transition(opacity $transition-time); + } + + // Temporarily unfolds as an overlay: siblings keep the folded + // --sidebar-width, so page content does not reflow. + &:hover, + &:has(:focus-visible) { + width: $sidebar-width; + box-shadow: + inset calc(-1 * var(--navbar-border-width)) 0 0 0 var(--navbar-border-color), + var(--shadow-lg); + } + } } } } diff --git a/core/scss/layout/_root.scss b/core/scss/layout/_root.scss index bf8c343ed..b8ab9eede 100644 --- a/core/scss/layout/_root.scss +++ b/core/scss/layout/_root.scss @@ -63,6 +63,9 @@ --page-padding: #{$page-padding}; --page-padding-y: #{$page-padding-y}; + --sidebar-width: #{$sidebar-width}; + --sidebar-folded-width: #{$sidebar-folded-width}; + @include media-breakpoint-down($cards-grid-breakpoint) { --page-padding: #{$page-padding-sm}; } diff --git a/core/scss/ui/_buttons.scss b/core/scss/ui/_buttons.scss index 2310abf20..a24ca8b25 100644 --- a/core/scss/ui/_buttons.scss +++ b/core/scss/ui/_buttons.scss @@ -223,7 +223,8 @@ .btn-floating { --btn-floating-offset: #{$btn-floating-offset}; position: fixed; - inset-inline-start: var(--btn-floating-offset); + // End side, Material-FAB style — the start side belongs to the sidebar. + inset-inline-end: var(--btn-floating-offset); bottom: var(--btn-floating-offset); z-index: $zindex-fixed; box-shadow: var(--shadow-dropdown); diff --git a/core/scss/ui/_nav.scss b/core/scss/ui/_nav.scss index df4a74dee..e05d62a48 100644 --- a/core/scss/ui/_nav.scss +++ b/core/scss/ui/_nav.scss @@ -97,10 +97,11 @@ } .nav-link-icon { - width: $nav-link-icon-size; - height: $nav-link-icon-size; + flex-shrink: 0; + width: var(--nav-link-icon-width, #{$nav-link-icon-size}); + height: var(--nav-link-icon-height, #{$nav-link-icon-size}); margin-inline-end: var(--nav-link-icon-margin-end); - color: $nav-link-icon-color; + color: var(--nav-link-icon-color, #{$nav-link-icon-color}); svg { display: block; diff --git a/docs/content/ui/components/button.mdx b/docs/content/ui/components/button.mdx index 2bc1e1ea8..36242b3a7 100644 --- a/docs/content/ui/components/button.mdx +++ b/docs/content/ui/components/button.mdx @@ -575,7 +575,7 @@ Use the `.btn-link` class to create buttons that look like links but maintain bu ## Action buttons -Use the `.btn-action` class to create subtle action buttons that are perfect for card headers, toolbars, or other interface elements where you want minimal visual impact. +Use the `.btn-action` class to create subtle action buttons that are perfect for card headers, toolbars, or other interface elements where you want minimal visual impact. Always combine it with the `btn` class — use `btn btn-action`, never `btn-action` on its own. Add `btn-sm` for a smaller action button.
diff --git a/docs/content/ui/layout/page-layouts.mdx b/docs/content/ui/layout/page-layouts.mdx index 3564da300..3f0b3b1ba 100644 --- a/docs/content/ui/layout/page-layouts.mdx +++ b/docs/content/ui/layout/page-layouts.mdx @@ -6,6 +6,7 @@ related: [/ui/layout/page-headers, /ui/layout/navbars] --- import Example from '@components/Example.astro' +import Icon from '@ui/Icon.astro' Before you start with this section, make sure you have followed the [installation guideline](/ui/getting-started/installation). @@ -102,14 +103,14 @@ To create a sidebar layout, you can use the following code snippet. This code sn
-