From 63d421ccb67f3307da99eea91d437d9599330359 Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Tue, 15 Sep 2026 00:08:34 +0200 Subject: [PATCH] Draw the navigation bar and bar item without Tailwind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan step 36 (navigation group, second batch): 's and 's class lists move into resources/css/components/navigation-bar.css and navigation-bar-item.css, keyed on data-md-navigation-bar (data-md-tall, data-md-hide-on-scroll/ -hidden) and data-md-navigation-bar-item (data-md-active), both in material.components. The indicator's growing fill and its state layer, shared with the rail's item, move into a new navigation-item.css both files import — the day's own layer in datepicker.css is the precedent for keeping it out of the shared foundation classes: the element focused and pressed is the whole item, the layer drawn only on the smaller indicator or pill inside it, which md-state-layer cannot do and :focus-visible never matches. The wash colour moves from a dead on-surface base overridden by both items to a shared on-secondary- container declaration, and its opacities from literal 0.08/0.1 to state.css's own tokens — no visible change, since the numbers matched. Every size is px (16dp Tailwind quirks aside, this file had none); the bar's own item-count and container-query layout, N-08's label-medium fix and N-19's on-secondary-container wash already matched the audit, so only hooks, units and layer needed to change. The scaffold's hide-on-scroll offset rule (`--material-bottom-bar`, navigation-bar.css) stays unlayered: still publishes that variable with a Tailwind utility until its own rewrite, and a rule in any layer loses to it regardless of specificity — unlike the FAB overrides the rail commit moves into material.components, which only have to beat another material.components rule. navigation.css is not deleted yet (still Tailwind's for the rail); its "Navigation" comment in tailwind.css narrows as each stream leaves. navigation-bar.css is not in NavigationStylesheetsTest.php's dataset: its one unlayered rule breaks that test's "every block is material.components" assumption, so the same checks are in NavigationBarTest.php instead, the same reason ErrorPagesTest.php carries error-page.css's. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9 --- resources/css/components.css | 3 + .../css/components/navigation-bar-item.css | 151 ++++++++++++++++++ resources/css/components/navigation-bar.css | 91 +++++++++++ resources/css/components/navigation-item.css | 70 ++++++++ .../components/navigation-bar-item.blade.php | 16 +- .../views/components/navigation-bar.blade.php | 12 +- .../Feature/Components/NavigationBarTest.php | 119 +++++++++----- .../Components/NavigationStylesheetsTest.php | 7 +- 8 files changed, 414 insertions(+), 55 deletions(-) create mode 100644 resources/css/components/navigation-bar-item.css create mode 100644 resources/css/components/navigation-bar.css create mode 100644 resources/css/components/navigation-item.css diff --git a/resources/css/components.css b/resources/css/components.css index 24ae1582..f244ec77 100644 --- a/resources/css/components.css +++ b/resources/css/components.css @@ -72,3 +72,6 @@ @import './components/app-bar.css'; @import './components/toolbar.css'; @import './components/tabs.css'; +@import './components/navigation-item.css'; +@import './components/navigation-bar.css'; +@import './components/navigation-bar-item.css'; diff --git a/resources/css/components/navigation-bar-item.css b/resources/css/components/navigation-bar-item.css new file mode 100644 index 00000000..1a8f4eb2 --- /dev/null +++ b/resources/css/components/navigation-bar-item.css @@ -0,0 +1,151 @@ +/* + * ``: one destination in an `` + * (NavigationBarVerticalItemTokens.kt, NavigationBarHorizontalItemTokens.kt, androidx Compose + * Material 3, Apache-2.0). + * + * [data-md-navigation-bar-item] data-md-active; the link or button itself + * [data-md-navigation-pill] icon and label; becomes the indicator from 600px + * [data-md-navigation-indicator] the 56×32 indicator around the icon below 600px + * [data-md-navigation-icon] the floating badge's anchor (navigation-item.css) + * [data-md-navigation-label] + * + * Below 600px (the bar's own width — a container query, so a bar in a narrow column keeps this + * layout) the icon sits in a 56×32 `CornerFull` indicator over a label-medium label, both equally + * wide. From 600px icon and label share a 40px horizontal indicator with 16px leading and + * trailing space; the label stays label-medium — `NavigationBarTokens.LabelTextFont` is the bar's + * only label token, and Compose's `ShortNavigationBarItem` passes it for both icon positions (the + * *rail's* horizontal item is label-large, a different component's token; N-08). An active item is + * secondary-container behind on-secondary-container (icon and, from 600px, label); its indicator's + * fill is a background image so it can grow from its centre (navigation-item.css) rather than + * stretch the icon. + * + * The indicator's growing fill and its state layer are shared with the rail's item + * (navigation-item.css); only this file's focus ring and the display swap between the two + * indicator shapes are the bar's own. + */ + +@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility; + +@import './icon.css'; +@import './badge.css'; +@import './navigation-item.css'; + +@layer material.components { + [data-md-navigation-bar-item] { + --navigation-layer: 0; + + position: relative; + display: flex; + flex: 1 1 0%; + min-width: 0; + align-items: center; + justify-content: center; + padding-block: 6px; + color: var(--md-sys-color-on-surface-variant); + text-decoration: none; + cursor: pointer; + outline: none; + -webkit-tap-highlight-color: transparent; + } + + [data-md-navigation-bar-item][data-md-active] { + color: var(--md-sys-color-secondary); + } + + [data-md-navigation-bar-item] [data-md-navigation-pill] { + display: flex; + min-width: 0; + max-width: 100%; + flex-direction: column; + align-items: center; + gap: 4px; + border-radius: var(--md-sys-shape-corner-full); + font: var(--md-sys-typescale-label-md); + letter-spacing: var(--md-sys-typescale-label-md-tracking); + } + + [data-md-navigation-bar-item] [data-md-navigation-label] { + max-width: 100%; + padding-inline: 4px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + [data-md-navigation-bar-item] [data-md-navigation-indicator] { + position: relative; + isolation: isolate; + display: flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + width: 56px; + height: 32px; + border-radius: var(--md-sys-shape-corner-full); + } + + /* Vertical layout (below 600px, or a tall bar at any width): the indicator fills behind the + icon; the pill draws no layer of its own. */ + [data-md-navigation-bar-item][data-md-active] [data-md-navigation-indicator] { + background-image: linear-gradient(var(--md-sys-color-secondary-container), var(--md-sys-color-secondary-container)); + color: var(--md-sys-color-on-secondary-container); + } + + [data-md-navigation-bar-item] [data-md-navigation-pill]::before { + display: none; + } + + [data-md-navigation-bar-item]:focus-visible [data-md-navigation-indicator] { + outline: 3px solid var(--md-sys-color-secondary); + outline-offset: 2px; + } + + /* Horizontal layout, from 600px: the pill itself fills and takes the layer; the indicator + stops drawing either. Only the short bar (a tall bar keeps the vertical layout). */ + @container (width >= 600px) { + [data-md-navigation-bar]:not([data-md-tall]) [data-md-navigation-bar-item] { + min-width: max-content; + } + + [data-md-navigation-bar]:not([data-md-tall]) [data-md-navigation-bar-item] [data-md-navigation-pill] { + position: relative; + isolation: isolate; + flex-direction: row; + height: 40px; + padding-inline: 16px; + } + + [data-md-navigation-bar]:not([data-md-tall]) [data-md-navigation-bar-item] [data-md-navigation-label] { + padding-inline: 0; + } + + [data-md-navigation-bar]:not([data-md-tall]) [data-md-navigation-bar-item][data-md-active] { + color: var(--md-sys-color-on-secondary-container); + } + + [data-md-navigation-bar]:not([data-md-tall]) [data-md-navigation-bar-item][data-md-active] [data-md-navigation-pill] { + background-image: linear-gradient(var(--md-sys-color-secondary-container), var(--md-sys-color-secondary-container)); + } + + [data-md-navigation-bar]:not([data-md-tall]) [data-md-navigation-bar-item][data-md-active] [data-md-navigation-indicator] { + background-image: none; + } + + [data-md-navigation-bar]:not([data-md-tall]) [data-md-navigation-bar-item] [data-md-navigation-pill]::before { + display: block; + } + + [data-md-navigation-bar]:not([data-md-tall]) [data-md-navigation-bar-item] [data-md-navigation-indicator]::before { + display: none; + } + + [data-md-navigation-bar]:not([data-md-tall]) [data-md-navigation-bar-item]:focus-visible [data-md-navigation-indicator] { + outline: none; + } + + [data-md-navigation-bar]:not([data-md-tall]) [data-md-navigation-bar-item]:focus-visible [data-md-navigation-pill] { + outline: 3px solid var(--md-sys-color-secondary); + outline-offset: 2px; + } + } +} diff --git a/resources/css/components/navigation-bar.css b/resources/css/components/navigation-bar.css new file mode 100644 index 00000000..cc92d480 --- /dev/null +++ b/resources/css/components/navigation-bar.css @@ -0,0 +1,91 @@ +/* + * ``: M3 Expressive's flexible navigation bar, three to five destinations + * pinned to the bottom of a compact or medium window (NavigationBarTokens.kt, androidx Compose + * Material 3, Apache-2.0, and the layout of ShortNavigationBar.kt; + * docs/reference/m3/components-navigation-selection-inputs.md § Navigation Bar). + * + * [data-md-navigation-bar] 64px in surface-container; data-md-tall (80px), + * data-md-hide-on-scroll and data-md-hidden + * [data-md-navigation-bar-items] equal widths below 600px; centred with Compose's + * calculateCenteredContentHorizontalPadding from it + * [data-md-navigation-bar-item] navigation-bar-item.css + * + * `data-md-tall` is `NavigationBarTokens.TallContainerHeight`, 80px against the short bar's 64 + * (`ContainerHeight`) — Missing until this rewrite. It keeps the vertical, icon-over-label item + * layout at every width, so every rule below that only the short bar's horizontal layout needs + * says `:not([data-md-tall])` first. + * + * `data-md-hide-on-scroll` is M3's scrolling behaviour, also Missing until now: "hides on + * scroll-down, reappears on scroll-up… never hide it while a screen reader is active" + * (§ Navigation Bar/Behaviour). resources/js/navigation.js drives `data-md-hidden` and never hides + * the bar while a snackbar, a bottom sheet or a drawer rests on its edge — those are anchored to + * its bottom edge and would slide with it — and focus reaching the bar brings it back, the web's + * nearest equivalent of "a screen reader is active". It slides on the default spatial spring, + * which reduced motion zeroes along with every other duration token. + * + * The item's own indicator, pill and state layer (shared with the rail item) are + * navigation-bar-item.css and navigation-item.css. + */ + +@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility; + +@layer material.components { + [data-md-navigation-bar] { + container-type: inline-size; + padding-inline: var(--material-safe-left, env(safe-area-inset-left)) var(--material-safe-right, env(safe-area-inset-right)); + padding-bottom: var(--material-safe-bottom, env(safe-area-inset-bottom)); + background-color: var(--md-sys-color-surface-container); + color: var(--md-sys-color-on-surface-variant); + } + + [data-md-navigation-bar-items] { + display: flex; + min-height: 64px; + margin-inline: auto; + } + + [data-md-navigation-bar][data-md-tall] [data-md-navigation-bar-items] { + min-height: 80px; + } + + [data-md-navigation-bar][data-md-hide-on-scroll] { + transition: translate var(--md-sys-motion-spatial-default-duration) var(--md-sys-motion-spatial-default); + } + + [data-md-navigation-bar][data-md-hide-on-scroll][data-md-hidden] { + translate: 0 100%; + } + + /* From 600px (M3's medium window, the bar's own width): icon and label side by side in a + 40px pill, the items gathered in the middle with Centered arrangement's own padding. The + short bar only — a tall bar is the vertical layout everywhere. */ + @container (width >= 600px) { + [data-md-navigation-bar]:not([data-md-tall]) [data-md-navigation-bar-items] { + width: calc(10% * (var(--navigation-bar-count, 7) + 3)); + min-width: fit-content; + max-width: 100%; + } + + [data-md-navigation-bar]:not([data-md-tall]) [data-md-navigation-bar-items]:has(> :last-child:nth-child(1)) { --navigation-bar-count: 1; } + [data-md-navigation-bar]:not([data-md-tall]) [data-md-navigation-bar-items]:has(> :last-child:nth-child(2)) { --navigation-bar-count: 2; } + [data-md-navigation-bar]:not([data-md-tall]) [data-md-navigation-bar-items]:has(> :last-child:nth-child(3)) { --navigation-bar-count: 3; } + [data-md-navigation-bar]:not([data-md-tall]) [data-md-navigation-bar-items]:has(> :last-child:nth-child(4)) { --navigation-bar-count: 4; } + [data-md-navigation-bar]:not([data-md-tall]) [data-md-navigation-bar-items]:has(> :last-child:nth-child(5)) { --navigation-bar-count: 5; } + [data-md-navigation-bar]:not([data-md-tall]) [data-md-navigation-bar-items]:has(> :last-child:nth-child(6)) { --navigation-bar-count: 6; } + } +} + +/* + * A bar that has slid off the bottom of the window is no longer there to clear, so the offset + * everything pinned to the bottom reads drops to the bottom safe area and whatever the application + * has docked on the bar — a `fab` button, the snackbar and the page's own bottom padding all follow + * it down and come back up with it. Unlayered on purpose, still: (its own rewrite is a + * later batch) publishes --material-bottom-bar with a Tailwind utility + * (`max-medium:[--material-bottom-bar:…]`), and a rule in any layer loses to an unlayered utility + * regardless of specificity — unlike toolbar.css's and fab.css's overrides, which only have to beat + * another `material.components` rule. Keyed on `data-md-scaffold`, the hook the scaffold already + * renders, not `data-app-shell`, which belongs to that later rewrite. + */ +[data-md-scaffold]:has([data-md-navigation-bar][data-md-hide-on-scroll][data-md-hidden]) { + --material-bottom-bar: calc(var(--material-safe-bottom, env(safe-area-inset-bottom)) + var(--material-bottom-extra, 0px)); +} diff --git a/resources/css/components/navigation-item.css b/resources/css/components/navigation-item.css new file mode 100644 index 00000000..a1d63dfe --- /dev/null +++ b/resources/css/components/navigation-item.css @@ -0,0 +1,70 @@ +/* + * Shared between `` and `` (imported by both + * navigation-bar-item.css and navigation-rail-item.css): the destination indicator's growing fill + * and its state layer, on androidx's one stated colour for a navigation item's layer. + * + * `NavigationRailColorTokens.kt` gives one colour for all six states, active or not: + * `ItemActiveHoveredStateLayer = … = ItemInactivePressedStateLayer = OnSecondaryContainer`. + * `NavigationBarTokens.kt` states no state-layer tokens of its own, so the bar's item takes the + * rail's (N-19). Hover only where a pointer can hover, so a touch screen does not keep the last + * hover after a tap; state.css's own opacities (8% hover, 10% focus and press) rather than the + * package's `md-state-layer` class, because the element focused and pressed is the whole item + * while the layer is drawn only on the smaller indicator or pill inside it — `md-state-layer` + * cannot draw on a different element than the one it decorates, and `:focus-visible` never + * matches a child (the same reason datepicker.css keeps the day's own layer). + * + * The active indicator itself is a secondary-container fill painted as a background image so it + * can grow out of its centre without stretching the icon: when a page arrives through + * wire:navigate, resources/js/navigation.js starts it at zero width for a moment and it springs + * open on the default spatial spring, as Compose's indicator does when the selection changes + * (NavigationItem.kt). A full page load draws it at once; reduced motion zeroes the transition. + */ + +@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility; + +@layer material.components { + [data-md-navigation-bar-item] :is([data-md-navigation-indicator], [data-md-navigation-pill]), + [data-md-navigation-rail-item], + [data-md-navigation-rail-item] [data-md-navigation-indicator] { + background-position: center; + background-repeat: no-repeat; + background-size: 100% 100%; + transition: background-size var(--md-sys-motion-spatial-default-duration) var(--md-sys-motion-spatial-default); + } + + @media (hover: hover) { + :is([data-md-navigation-bar-item], [data-md-navigation-rail-item]):hover { + --navigation-layer: var(--md-sys-state-hover-state-layer-opacity); + } + } + + :is([data-md-navigation-bar-item], [data-md-navigation-rail-item]):focus-visible { + --navigation-layer: var(--md-sys-state-focus-state-layer-opacity); + } + + :is([data-md-navigation-bar-item], [data-md-navigation-rail-item]):active { + --navigation-layer: var(--md-sys-state-pressed-state-layer-opacity); + } + + [data-md-navigation-bar-item] [data-md-navigation-indicator]::before, + [data-md-navigation-bar-item] [data-md-navigation-pill]::before, + [data-md-navigation-rail-item]::before, + [data-md-navigation-rail-item] [data-md-navigation-indicator]::before { + content: ''; + position: absolute; + inset: 0; + z-index: -1; + border-radius: inherit; + background-color: var(--md-sys-color-on-secondary-container); + opacity: var(--navigation-layer, 0); + pointer-events: none; + transition: opacity var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast); + } + + /* The wrapper a floating badge anchors to: tight around the glyph alone, not the wider + indicator or pill around it, so the badge sits at the icon's own corner (badge.css). */ + [data-md-navigation-icon] { + position: relative; + display: inline-flex; + } +} diff --git a/resources/views/components/navigation-bar-item.blade.php b/resources/views/components/navigation-bar-item.blade.php index a6d1c6bc..c959a732 100644 --- a/resources/views/components/navigation-bar-item.blade.php +++ b/resources/views/components/navigation-bar-item.blade.php @@ -40,16 +40,16 @@ 'wire:navigate' => $isLink && ! $external && ! $noWireNavigate && ! $attributes->has('wire:navigate') ? true : null, 'type' => $isLink ? null : 'button', 'aria-current' => $active ? 'page' : null, - 'data-active' => $active ? true : null, + 'data-md-active' => $active ? true : null, ], fn ($value): bool => $value !== null)); @endphp -<{{ $tag }} data-navigation-bar-item {{ $attributes }}> - - - +<{{ $tag }} data-md-navigation-bar-item {{ $attributes }}> + + + @if ($icon) - + @endif @if ($dot) @@ -60,10 +60,10 @@ - {{ $label ?? $slot }} + {{ $label ?? $slot }} @if ($spoken !== null) - , {{ $spoken }} + , {{ $spoken }} @endif diff --git a/resources/views/components/navigation-bar.blade.php b/resources/views/components/navigation-bar.blade.php index 7b10d433..8474eee6 100644 --- a/resources/views/components/navigation-bar.blade.php +++ b/resources/views/components/navigation-bar.blade.php @@ -40,7 +40,7 @@ NavigationBarVerticalItemTokens.kt, NavigationBarHorizontalItemTokens.kt and ShortNavigationBar.kt, all under https://github.com/androidx/androidx/tree/androidx-main/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3 - — the styles are resources/css/components/navigation.css. --}} + — the styles are resources/css/components/navigation-bar.css. --}} @props([ 'label' => null, @@ -50,17 +50,17 @@ diff --git a/tests/Feature/Components/NavigationBarTest.php b/tests/Feature/Components/NavigationBarTest.php index de0f3267..2683b297 100644 --- a/tests/Feature/Components/NavigationBarTest.php +++ b/tests/Feature/Components/NavigationBarTest.php @@ -1,17 +1,20 @@ blade('')) - ->toMatch('/toContain('data-navigation-bar-items') + ->toMatch('/toContain('data-md-navigation-bar-items') ->and((string) $this->blade(''))->toContain('aria-label="Sections"'); }); it('links a destination through wire:navigate unless told not to', function () { expect((string) $this->blade('')) - ->toContain('toContain('toContain('href="/inbox"') ->toContain('wire:navigate') ->and((string) $this->blade(''))->not->toContain('wire:navigate') @@ -22,7 +25,7 @@ it('links a destination through wire:navigate unless told not to', function () { it('is a button without a link', function () { expect((string) $this->blade('')) - ->toContain('