From 7e7b2cea34a5766ae7ec9e5aea963680fea5875d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Kuna?= <1282324+codecalm@users.noreply.github.com> Date: Sun, 20 Sep 2026 21:55:57 +0200 Subject: [PATCH] Add floating offcanvas and navbar variants with theme settings (#3100) --- .changeset/fix-navbar-theme-menu-row.md | 5 ++ .changeset/navbar-floating.md | 7 ++ .changeset/offcanvas-floating.md | 7 ++ core/js/src/theme-config.ts | 2 + core/package.json | 8 +- core/scss/_extends.scss | 7 +- core/scss/_variables.scss | 4 + core/scss/bootstrap/_offcanvas.scss | 2 + core/scss/layout/_navbar.scss | 70 +++++++++++++++++- core/scss/tests/layout-attributes.test.mjs | 2 +- core/scss/ui/_offcanvas.scss | 43 +++++++++++ docs/content/ui/components/offcanvas.mdx | 14 ++++ docs/content/ui/layout/navbars.mdx | 34 +++++++++ docs/content/ui/layout/page-layouts.mdx | 26 ++++++- ...lap.astro => layout-navbar-floating.astro} | 4 +- preview/pages/layout-vertical-floating.astro | 10 +++ preview/pages/offcanvas.astro | 73 ++++++++++++------- shared/components/demo/ThemeSettings.astro | 12 ++- shared/components/layout/PageHeader.astro | 6 +- shared/components/navbar/Navbar.astro | 3 - shared/data/menu.json | 10 ++- shared/layouts/DefaultLayout.astro | 11 +-- shared/lib/theme-config.ts | 20 +++++ shared/ui/Offcanvas.astro | 6 +- 24 files changed, 328 insertions(+), 58 deletions(-) create mode 100644 .changeset/fix-navbar-theme-menu-row.md create mode 100644 .changeset/navbar-floating.md create mode 100644 .changeset/offcanvas-floating.md rename preview/pages/{layout-navbar-overlap.astro => layout-navbar-floating.astro} (50%) create mode 100644 preview/pages/layout-vertical-floating.astro diff --git a/.changeset/fix-navbar-theme-menu-row.md b/.changeset/fix-navbar-theme-menu-row.md new file mode 100644 index 000000000..0ef9e05ac --- /dev/null +++ b/.changeset/fix-navbar-theme-menu-row.md @@ -0,0 +1,5 @@ +--- +"@tabler/core": patch +--- + +Fixed the menu row of the two-row navbar staying light with `data-bs-navbar-theme` set to `dark` or `primary`. diff --git a/.changeset/navbar-floating.md b/.changeset/navbar-floating.md new file mode 100644 index 000000000..1989800ae --- /dev/null +++ b/.changeset/navbar-floating.md @@ -0,0 +1,7 @@ +--- +"@tabler/core": minor +"@tabler/preview": minor +"@tabler/docs": patch +--- + +Added `.navbar-floating` and the `data-bs-navbar-style="floating"` theme setting for a navbar and sidebar with a `0.5rem` gap. diff --git a/.changeset/offcanvas-floating.md b/.changeset/offcanvas-floating.md new file mode 100644 index 000000000..032c96307 --- /dev/null +++ b/.changeset/offcanvas-floating.md @@ -0,0 +1,7 @@ +--- +"@tabler/core": minor +"@tabler/preview": minor +"@tabler/docs": patch +--- + +Added `.offcanvas-floating` and the `data-bs-offcanvas-style="floating"` theme setting for an offcanvas with a `0.5rem` gap. diff --git a/core/js/src/theme-config.ts b/core/js/src/theme-config.ts index 9882b7394..7ed3c266a 100644 --- a/core/js/src/theme-config.ts +++ b/core/js/src/theme-config.ts @@ -14,7 +14,9 @@ export const themeDefaults = { 'layout': 'default', 'navbar': 'default', 'navbar-position': 'horizontal', + 'navbar-style': 'default', 'navbar-theme': 'default', + 'offcanvas-style': 'default', 'sidebar': 'default', } as const diff --git a/core/package.json b/core/package.json index bab3d7c18..07a3e8106 100644 --- a/core/package.json +++ b/core/package.json @@ -82,19 +82,19 @@ "files": [ { "path": "./dist/css/tabler.css", - "maxSize": "86 kB" + "maxSize": "87 kB" }, { "path": "./dist/css/tabler.min.css", - "maxSize": "80 kB" + "maxSize": "81 kB" }, { "path": "./dist/css/tabler.rtl.css", - "maxSize": "86 kB" + "maxSize": "87 kB" }, { "path": "./dist/css/tabler.rtl.min.css", - "maxSize": "80 kB" + "maxSize": "81 kB" }, { "path": "./dist/css/tabler-flags.css", diff --git a/core/scss/_extends.scss b/core/scss/_extends.scss index 780d0cbe6..e76ec7db9 100644 --- a/core/scss/_extends.scss +++ b/core/scss/_extends.scss @@ -92,8 +92,13 @@ // 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. + // + // The two-row navbar keeps its menu row in a collapse wrapper next to the + // header, so that row is matched on its own. html[data-bs-navbar-theme='dark'] .page > .navbar, - html[data-bs-navbar-theme='primary'] .page > .navbar { + html[data-bs-navbar-theme='primary'] .page > .navbar, + html[data-bs-navbar-theme='dark'] .page > [class*='navbar-expand'] .navbar, + html[data-bs-navbar-theme='primary'] .page > [class*='navbar-expand'] .navbar { @extend [data-bs-theme='dark']; } } diff --git a/core/scss/_variables.scss b/core/scss/_variables.scss index 6d4b48648..18eb610bd 100644 --- a/core/scss/_variables.scss +++ b/core/scss/_variables.scss @@ -1567,6 +1567,8 @@ $navbar-hover-color: var(--body-color) !default; $navbar-border-width: var(--border-width) !default; $navbar-border-color: var(--border-color) !default; +$navbar-floating-margin: 0.5rem !default; +$navbar-floating-border-radius: var(--border-radius-lg) !default; $navbar-light-color: var(--body-color) !default; $navbar-light-brand-color: var(--body-color) !default; @@ -2163,6 +2165,8 @@ $offcanvas-box-shadow: $modal-content-box-shadow-xs !default; $offcanvas-backdrop-bg: $backdrop-bg !default; $offcanvas-backdrop-opacity: $modal-backdrop-opacity !default; $offcanvas-narrow-width: 20rem !default; +$offcanvas-margin: 0.5rem !default; +$offcanvas-border-radius: $modal-content-border-radius !default; // scss-docs-end offcanvas-variables // Chat diff --git a/core/scss/bootstrap/_offcanvas.scss b/core/scss/bootstrap/_offcanvas.scss index c7f1dc68c..c7a4ef8b2 100644 --- a/core/scss/bootstrap/_offcanvas.scss +++ b/core/scss/bootstrap/_offcanvas.scss @@ -11,6 +11,7 @@ --offcanvas-height: #{$offcanvas-vertical-height}; --offcanvas-padding-x: #{$offcanvas-padding-x}; --offcanvas-padding-y: #{$offcanvas-padding-y}; + --offcanvas-margin: #{$offcanvas-margin}; --offcanvas-color: #{$offcanvas-color}; --offcanvas-bg: #{$offcanvas-bg-color}; --offcanvas-border-width: #{$offcanvas-border-width}; @@ -103,6 +104,7 @@ @include media-breakpoint-up($next) { --offcanvas-height: auto; --offcanvas-border-width: 0; + --offcanvas-margin: 0px; background-color: transparent !important; // stylelint-disable-line declaration-no-important .offcanvas-header { diff --git a/core/scss/layout/_navbar.scss b/core/scss/layout/_navbar.scss index 56c638c94..3ba8ff669 100644 --- a/core/scss/layout/_navbar.scss +++ b/core/scss/layout/_navbar.scss @@ -446,7 +446,7 @@ Navbar &.navbar-vertical { ~ .navbar, ~ .page-wrapper { - margin-inline-start: var(--sidebar-width); + margin-inline-start: calc(var(--sidebar-width) + var(--sidebar-gap, 0px)); } } @@ -455,7 +455,7 @@ Navbar ~ .navbar, ~ .page-wrapper { margin-inline-start: 0; - margin-inline-end: var(--sidebar-width); + margin-inline-end: calc(var(--sidebar-width) + var(--sidebar-gap, 0px)); } } } @@ -624,6 +624,12 @@ html[data-bs-navbar-theme='primary'] .page > .navbar { --navbar-border-color: transparent; } +// The menu row of the two-row navbar, which is not a direct child of .page. +html[data-bs-navbar-theme='primary'] .page > [class*='navbar-expand'] .navbar { + --navbar-bg: var(--primary); + --navbar-border-color: transparent; +} + /** Navbar vertical */ @@ -677,9 +683,12 @@ Navbar vertical // 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. + // A length, not a bare 0: the offset is added up in calc(). A floating + // navbar keeps its own gap from the viewport edge. > .navbar, > .page-wrapper { - --sidebar-width: 0; + --sidebar-width: 0px; + --sidebar-gap: var(--navbar-margin, 0px); } } @@ -864,3 +873,58 @@ Navbar vertical box-shadow: inherit; } } + +/** +Navbar floating + */ +// The navbar keeps a gap from the edges around it, like .offcanvas-floating, +// with an outline on every side instead of the single border line. +// +// Two routes to the same state: the .navbar-floating class on the navbar +// (static markup), or data-bs-navbar-style="floating" on (the theme +// settings), which covers whichever navigation the page shows — the menu row +// of the two-row navbar included, which inherits the variables from its +// navbar-expand-* wrapper. +// +// The variables exist only from the navbar-expand-* breakpoint up, so below it +// every fallback applies and the navbar stays attached. +$navbar-floating-attr: "html[data-bs-navbar-style='floating'] .page >"; + +.navbar-floating, +#{$navbar-floating-attr} .navbar, +#{$navbar-floating-attr} [class*='navbar-expand'] .navbar { + margin: var(--navbar-margin, 0); + outline: var(--navbar-outline-width, 0) solid var(--navbar-border-color); + outline-offset: calc(-1 * var(--navbar-outline-width, 0px)); + @include border-radius(var(--navbar-border-radius, 0)); +} + +#{$navbar-floating-attr} [class*='navbar-expand'] .navbar { + margin-top: 0; +} + +@each $breakpoint in map.keys($grid-breakpoints) { + $next: breakpoint-next($breakpoint, $grid-breakpoints); + $infix: breakpoint-infix($next, $grid-breakpoints); + + @include media-breakpoint-up($next) { + .navbar-floating.navbar-expand#{$infix}, + #{$navbar-floating-attr} .navbar-expand#{$infix} { + --navbar-margin: #{$navbar-floating-margin}; + --navbar-border-radius: #{$navbar-floating-border-radius}; + --navbar-outline-width: var(--navbar-border-width); + } + } +} + +// The page next to a floating sidebar moves over by the sidebar's two margins, +// and a floating navbar next to any sidebar keeps its own gap from it. The +// sibling offsets read --sidebar-gap only from the sidebar's breakpoint up. +.navbar-vertical ~ .navbar-floating { + --sidebar-gap: var(--navbar-margin, 0px); +} + +.navbar-floating.navbar-vertical ~ :is(.navbar, .page-wrapper), +#{$navbar-floating-attr} .navbar-vertical ~ :is(.navbar, .page-wrapper) { + --sidebar-gap: calc(2 * #{$navbar-floating-margin}); +} diff --git a/core/scss/tests/layout-attributes.test.mjs b/core/scss/tests/layout-attributes.test.mjs index ca686f283..b81c9b074 100644 --- a/core/scss/tests/layout-attributes.test.mjs +++ b/core/scss/tests/layout-attributes.test.mjs @@ -40,7 +40,7 @@ describe('navbar position', () => { }) 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; \}/) + expect(flat).toMatch(/html:not\(\[data-bs-navbar-position=vertical\]\) \.page:has\(> \[class\*=navbar-expand\]:not\(\.navbar-vertical\)\) > \.navbar,[^{]*> \.page-wrapper \{ --sidebar-width: 0px; --sidebar-gap: var\(--navbar-margin, 0px\); \}/) }) it('hides the horizontal navbar in the vertical position', () => { diff --git a/core/scss/ui/_offcanvas.scss b/core/scss/ui/_offcanvas.scss index 17f83aedb..a1b302f8f 100644 --- a/core/scss/ui/_offcanvas.scss +++ b/core/scss/ui/_offcanvas.scss @@ -30,3 +30,46 @@ --offcanvas-narrow-width: #{$offcanvas-narrow-width}; --offcanvas-width: var(--offcanvas-narrow-width); } + +// Floating variant: the panel keeps a gap from the viewport edges. Two routes +// to the same state: the .offcanvas-floating class on the panel, or +// data-bs-offcanvas-style="floating" on (the theme settings), which +// covers every panel on the page — found by its direction class. +// +// --offcanvas-margin is 0px above the breakpoint of a responsive variant, so +// the inline content there is left alone. +$offcanvas-floating-attr: "html[data-bs-offcanvas-style='floating']"; + +.offcanvas-floating, +#{$offcanvas-floating-attr} :is(.offcanvas-start, .offcanvas-end, .offcanvas-top, .offcanvas-bottom) { + --offcanvas-transition: #{transform $offcanvas-transition-duration ease-in-out, translate $offcanvas-transition-duration ease-in-out}; + max-width: calc(100% - 2 * var(--offcanvas-margin)); + max-height: calc(100% - 2 * var(--offcanvas-margin)); + margin: var(--offcanvas-margin); + border: var(--offcanvas-border-width) solid var(--offcanvas-border-color); + border-radius: $offcanvas-border-radius; + + // The closed panel has to travel past its own margin to leave the viewport. + &.offcanvas-start { + /* rtl:ignore */ + translate: calc(var(--dir) * -1 * var(--offcanvas-margin)); + } + + &.offcanvas-end { + /* rtl:ignore */ + translate: calc(var(--dir) * var(--offcanvas-margin)); + } + + &.offcanvas-top { + translate: 0 calc(-1 * var(--offcanvas-margin)); + } + + &.offcanvas-bottom { + translate: 0 var(--offcanvas-margin); + } + + &.showing, + &.show:not(.hiding) { + translate: none; + } +} diff --git a/docs/content/ui/components/offcanvas.mdx b/docs/content/ui/components/offcanvas.mdx index 34873c6e7..0f0b53849 100644 --- a/docs/content/ui/components/offcanvas.mdx +++ b/docs/content/ui/components/offcanvas.mdx @@ -20,6 +20,8 @@ classnames: modifier: - class: offcanvas-narrow desc: Narrower panel, 20rem instead of the default width + - class: offcanvas-floating + desc: Keeps a 0.5rem gap between the panel and the viewport edges direction: - class: offcanvas-start desc: Slides in from the leading edge @@ -64,6 +66,18 @@ Add `offcanvas-narrow` for a panel thinner than the default. It suits a short li

Filters

Pick the filters you want to apply.
+## Floating panel + +Add `offcanvas-floating` to keep a gap between the panel and the edges of the screen. The panel gets a border on every side and rounded corners, so it looks like a card over the page. It works with every direction and with the responsive `offcanvas-{breakpoint}` classes. + +

Settings

The panel does not touch the edges of the screen.
`}> +

Settings

The panel does not touch the edges of the screen.
+
+ +The gap is `0.5rem` by default. Change it with the `--tblr-offcanvas-margin` variable, or with `$offcanvas-margin` in SCSS. + +To float every panel on the page without touching the markup, set [`data-bs-offcanvas-style="floating"`](/ui/layout/page-layouts#floating-offcanvas) on ``. + ## Cookies banner An offcanvas at the bottom edge also works as a cookie banner. It stays visible until the user answers, and it doesn't cover the page. diff --git a/docs/content/ui/layout/navbars.mdx b/docs/content/ui/layout/navbars.mdx index ab54f87d5..61c783cbe 100644 --- a/docs/content/ui/layout/navbars.mdx +++ b/docs/content/ui/layout/navbars.mdx @@ -82,6 +82,40 @@ Inside it, a `container-xl` keeps the content aligned with the page. The full ma +## Floating navbar + +Add `navbar-floating` to keep a gap between the navbar and the edges around it. The navbar gets a border on every side and rounded corners. It floats only from its `navbar-expand-*` breakpoint up. Below the breakpoint it stays attached to the edges, like a regular navbar. + +...`}> + + + +The class works on the vertical sidebar too. The sidebar keeps its width, also when it is folded, and the page content next to it moves over by the same gap. + +```html + +``` + +The gap is `0.5rem` by default. Change it with `$navbar-floating-margin` in SCSS. + +To float the navigation of the whole page without touching the markup, set [`data-bs-navbar-style="floating"`](/ui/layout/page-layouts#floating-navigation) on ``. + ## Language selector Add a language selector to the navbar as a dropdown. The toggle shows the current language code and the menu lists the languages with a `data-lang` attribute, so a script can switch the locale. Give the toggle an `aria-label`, because the code alone does not say what the control does. diff --git a/docs/content/ui/layout/page-layouts.mdx b/docs/content/ui/layout/page-layouts.mdx index 780aac3a9..f067c20f3 100644 --- a/docs/content/ui/layout/page-layouts.mdx +++ b/docs/content/ui/layout/page-layouts.mdx @@ -416,7 +416,7 @@ You can also add tooltips with the link title to icon-only links, so users see t ## 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. +Beyond the sidebar, six 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 @@ -444,6 +444,30 @@ Use one route or the other, not both. The boxed frame is the containing block of 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. +### Floating navigation + +`data-bs-navbar-style="floating"` keeps a gap between the navigation and the edges around it, and gives it a border and rounded corners: + +```html + +``` + +The attribute covers whichever navigation the page shows. The sidebar keeps its width, and the page next to it moves over by the same gap. On the two-row navbar both rows float. The navigation floats only from its `navbar-expand-*` breakpoint up. Without the attribute, add the [`navbar-floating`](/ui/layout/navbars#floating-navbar) class to a navbar in your markup: + +```html + +``` + +### Floating offcanvas + +`data-bs-offcanvas-style="floating"` does the same for every [offcanvas](/ui/components/offcanvas#floating-panel) on the page: + +```html + +``` + +Without the attribute, add the `offcanvas-floating` class to a single panel. + ### Navigation position `data-bs-navbar-position` picks which navigation the page shows: diff --git a/preview/pages/layout-navbar-overlap.astro b/preview/pages/layout-navbar-floating.astro similarity index 50% rename from preview/pages/layout-navbar-overlap.astro rename to preview/pages/layout-navbar-floating.astro index d60a78290..9abbc23c8 100644 --- a/preview/pages/layout-navbar-overlap.astro +++ b/preview/pages/layout-navbar-floating.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-floating.astro b/preview/pages/layout-vertical-floating.astro new file mode 100644 index 000000000..41bbb8c4d --- /dev/null +++ b/preview/pages/layout-vertical-floating.astro @@ -0,0 +1,10 @@ +--- +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/offcanvas.astro b/preview/pages/offcanvas.astro index 2a9d6b1ed..c21cedeb0 100644 --- a/preview/pages/offcanvas.astro +++ b/preview/pages/offcanvas.astro @@ -4,46 +4,63 @@ import { ucFirst as capitalize } from '@shared/lib/string-format' import ButtonList from '@ui/ButtonList.astro' import Card from '@ui/Card.astro' import CardBody from '@ui/CardBody.astro' +import CardHeader from '@ui/CardHeader.astro' +import CardTitle from '@ui/CardTitle.astro' import Offcanvas from '@ui/Offcanvas.astro' import OffcanvasHeader from '@ui/OffcanvasHeader.astro' import OffcanvasBody from '@ui/OffcanvasBody.astro' import DocsLink from '@ui/DocsLink.astro' const directions = ['start', 'end', 'top', 'bottom'] as const +const variants = [ + { id: 'offcanvas', title: 'Default', floating: false }, + { id: 'offcanvasFloating', title: 'Floating', floating: true }, +] --- - - - - { - directions.map((direction) => ( - - Toggle {direction} offcanvas - - )) - } - - - +
+ { + variants.map((variant) => ( +
+ + + {variant.title} + + + + {directions.map((direction) => ( + + Toggle {direction} offcanvas + + ))} + + + +
+ )) + } +
{ - directions.map((direction) => ( - - - - - - - )) + variants.map((variant) => + directions.map((direction) => ( + + + + + + + )), + ) }
diff --git a/shared/components/demo/ThemeSettings.astro b/shared/components/demo/ThemeSettings.astro index 67c8d8b75..ab17ba780 100644 --- a/shared/components/demo/ThemeSettings.astro +++ b/shared/components/demo/ThemeSettings.astro @@ -68,6 +68,16 @@ const tileSvg = (key: string, value: string): string => { 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 'navbar-style': + // Floating: the bar keeps a gap from the edges and gets its own corners. + return value === 'floating' + ? svg(frame(page) + `` + [11, 29, 47].map((x) => ``).join('') + lines(12, 30, [46, 72, 30], border) + outline) + : svg(frame(page) + topBar(primary) + [8, 26, 44].map((x) => ``).join('') + lines(12, 30, [46, 72, 30], border) + outline) + case 'offcanvas-style': + // A panel open on the end side, over the dimmed page: flush with the edges, or a card with a gap. + return value === 'floating' + ? svg(frame(page) + lines(10, 12, [40, 48, 28], border) + `` + lines(59, 12, [20, 26, 14], border) + outline) + : svg(frame(page) + lines(10, 12, [40, 48, 28], border) + `` + lines(64, 12, [20, 26, 14], border) + 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) @@ -93,7 +103,7 @@ const tileSvg = (key: string, value: string): string => { -