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