From cbd7d01c62fcec6c992a3b4d3e33f3c3f46ac3c0 Mon Sep 17 00:00:00 2001
From: Andreas Reinhold / reini
Date: Tue, 15 Sep 2026 00:09:44 +0200
Subject: [PATCH] Sweep up navigation's last data-navigation-* references
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Plan step 36 (navigation group, second batch, cleanup). Every leftover
data-navigation-*, data-tall, data-hide-on-scroll, data-hidden,
data-width, data-align, data-divider, data-fill and component-level
data-open reference outside the rewritten stylesheets and views moves
to data-md-*, in the files the two component commits left alone:
tests/Feature/Components/ScaffoldTest.php (the scaffold's own rewrite
is a later batch, but it renders the bar and the rail today and asserts
their hooks); tests/Browser/NavigationTest.php, ShowcaseTest.php and
ColourProfilesTest.php (selectors only — document-level attributes,
data-rail, data-rail-auto and data-app-shell*, keep their names).
NavigationTest.php also gets the browser tests the plan owed this
group (docs/plans/material-3-browser-tests.md § Navigation): a tall
bar's vertical layout at a width where the short bar would go
horizontal; hide-on-scroll never firing while a pinned snackbar is on
screen; a narrow rail's 80px width and centred destinations; a rail
that hides when collapsed leaving the layout and coming back only
through an application's own menu button.
resources/css/components/navigation.css and its import in tailwind.css
are deleted now that nothing imports the file any more (the previous
commit's message said this already happened there; it did not — this
is where it actually lands).
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
---
.../livewire-material-development/SKILL.md | 2 +-
resources/css/components/navigation.css | 844 ------------------
resources/css/tailwind.css | 3 -
tests/Browser/ColourProfilesTest.php | 2 +-
tests/Browser/NavigationTest.php | 183 +++-
tests/Browser/ShowcaseTest.php | 4 +-
tests/Feature/Components/ScaffoldTest.php | 32 +-
7 files changed, 167 insertions(+), 903 deletions(-)
delete mode 100644 resources/css/components/navigation.css
diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md
index e5f32c7c..34509a4e 100644
--- a/resources/boost/skills/livewire-material-development/SKILL.md
+++ b/resources/boost/skills/livewire-material-development/SKILL.md
@@ -983,7 +983,7 @@ M3 Expressive's navigation rail: collapsed (96px, icon over label) or expanded (
- `mode`: `collapsed`, `expanded`, `collapsible` (default: expanded until its menu button collapses it; the choice is `$store.rail`, remembered and applied before the first paint), `modal` (collapsed in the layout; the menu button or `$store.rail.show()` opens it expanded over a scrim, focus held until Escape, the scrim or leaving the page), `adaptive` (``'s, one rail per window size class: hidden and opened as a modal on a compact window, collapsed and opened as a modal at `medium`, a standard rail from `expanded` — collapsed there, expanded from `large`).
- Props: `label` ("Main"), `width` (expanded width, `16rem`, held between 220 and 360px — or the word `narrow` for M3's other *collapsed* width, 80px against the default 96, where the items are their icons alone; the labels stay in the accessibility tree and a narrow rail still expands to 16rem), `align` (`top` default, or `center` for M3's centred destinations — preferred on a tablet; the menu button, brand and FAB stay at the top and the footer at the foot), `hide-when-collapsed` (M3's immersive expanded behaviour, `collapsible` and `adaptive` only: collapsing the rail takes it out of the layout instead of narrowing it, and `$store.rail.show()` brings it back expanded over a scrim — so put a menu button in the app bar; the rail's own button then docks it again. Not below `medium` for a collapsible rail nor at `medium` for an adaptive one, where the window rather than the visitor collapses it and M3's collapsed rail may never hide), `menu` (the menu button; on by default for `collapsible`, `modal`, `adaptive`), `divider` (M3's optional vertical divider on the page's side — use it when the page scrolls under a fixed rail), `fill` (`false` for a transparent container, which M3 allows while the items keep 3:1 contrast). Slots: `brand` (beside the menu button, expanded only), `header` (one ``, which the rail morphs into an extended FAB and back as it expands — it also rests at elevation 0, as M3 asks of a nested FAB), the destinations (the only part that scrolls), `footer`. In a flex row the rail sticks to the top of the viewport.
-- Anything else inside a rail takes both shapes with the `rail-collapsed:` variant, true while that rail is drawn collapsed for whatever reason: `…expanded only… `, `…collapsed only… `. Put the variant on a wrapper, never on a component. Nothing that shows while collapsed may be wider than 96px.
+- Anything else inside a rail can take both shapes by matching the rail's own state directly — `data-md-navigation-rail`'s value, `:not([data-md-open])`, and the window band each mode collapses in, the same selectors `resources/css/components/navigation-rail.css` uses for every branch of "collapsed" (that file, not this one, is where the numbers live). The Tailwind `rail-collapsed:` variant this used to teach no longer exists: the package's own CSS is plain now. Nothing that shows while collapsed may be wider than 96px.
- A `collapsible` rail is held to the collapsed 96px below `medium` (600px), where M3 says to use a navigation bar rather than a standard rail. `collapsed` and `expanded` are fixed-width by design: wrap one in a `medium:` element if it must not show on a phone.
- ``: the same props as ``. ``: a group with a heading that shows only while the rail is expanded; it names the group for screen readers either way.
- `$store.rail`: `collapsed`, `toggle()`, `collapse()`, `expand()` (the remembered choice; `auto` is true while nothing is stored, so an adaptive rail takes its window size class's default instead, and the first choice clears it), `open`, `show()`, `hide()` (the modal rail; closed on every `wire:navigate`). `config/livewire-material.php` → `rail.default` (`expanded` or `collapsed`) and `rail.storage_key` (`material-rail`).
diff --git a/resources/css/components/navigation.css b/resources/css/components/navigation.css
deleted file mode 100644
index dae4acc5..00000000
--- a/resources/css/components/navigation.css
+++ /dev/null
@@ -1,844 +0,0 @@
-/*
- * M3 Expressive navigation: the flexible navigation bar and the navigation rail — collapsed,
- * expanded and modal.
- *
- * Values from androidx Compose Material 3 (Apache-2.0) at androidx-main
- * 27cf9a7d5788aa0f5f2d8b6699ce279560daf326: tokens/NavigationBarTokens.kt,
- * NavigationBarVerticalItemTokens.kt, NavigationBarHorizontalItemTokens.kt,
- * NavigationRailCollapsedTokens.kt, NavigationRailExpandedTokens.kt,
- * NavigationRailBaselineItemTokens.kt, NavigationRailVerticalItemTokens.kt,
- * NavigationRailHorizontalItemTokens.kt, NavigationRailColorTokens.kt, and the layout in
- * ShortNavigationBar.kt, WideNavigationRail.kt and NavigationItem.kt.
- *
- * [data-navigation-bar] surface-container, 64px (data-tall: 80px), the bottom
- * safe area under it
- * [data-navigation-bar-items] equal widths; centred from a 600px-wide short bar
- * [data-navigation-bar-item] data-active
- * [data-navigation-pill] icon and label; the indicator itself from 600px
- * [data-navigation-indicator] the 56×32 indicator around the icon below 600px
- * [data-navigation-label]
- *
- * [data-navigation-rail="collapsed|expanded|collapsible|modal|adaptive"] data-open
- * [data-navigation-rail-scrim] modal and adaptive rails
- * [data-navigation-rail-panel] the
- * [data-navigation-rail-header] menu button, brand, FAB — never scrolls
- * [data-navigation-rail-destinations] scrolls when the window is too short
- * [data-navigation-rail-section] a heading (expanded only) and its items
- * [data-navigation-rail-item] data-active; the full-width pill when expanded
- * [data-navigation-indicator] the 56×32 indicator when collapsed
- * [data-navigation-label]
- * [data-navigation-rail-footer] never scrolls
- *
- * `rail-collapsed:` matches a rail, and everything in it, while it is drawn collapsed — whatever
- * made it so: its mode, the visitor's choice on (set before the first paint by
- * ), or a window below `expanded` (840px) for the adaptive rail. A rail item is written once
- * and takes both shapes from it; so can anything an application puts in a rail
- * (``).
- *
- * The two widths the adaptive rail turns on are M3's window size classes, not Tailwind's screens
- * (docs/reference/m3/foundations.md § Layout, foundations-supplement.md § Breakpoints): 37.5rem
- * (600px) is the compact/medium boundary — below it the navigation bar carries the destinations and
- * the rail takes no room in the layout; 52.5rem (840px) is `expanded`, where M3 asks for a standard
- * rail in the layout rather than one that opens over a scrim; 75rem (1200px) is `large`, where the
- * rail starts expanded instead of collapsed. The bar's own item layout is a *container* query at
- * the same 37.5rem, so a bar in a narrow column lays out by its own width.
- *
- * Everything here is in `@layer components` except the blocks after it, which reach past the
- * component into what an application wrote — the bottom offset publishes, a FAB put
- * in the rail: those have to beat a utility, and a layer never does.
- */
-
-/* Every branch below reads `:not([data-open])`: a rail open over a scrim is drawn expanded whatever
- made it collapsed, which only `hide-when-collapsed` can bring about outside the modal modes. */
-@custom-variant rail-collapsed {
- &:where([data-navigation-rail='collapsed'], [data-navigation-rail='collapsed'] *) {
- @slot;
- }
-
- &:where([data-rail='collapsed'] [data-navigation-rail='collapsible']:not([data-open]), [data-rail='collapsed'] [data-navigation-rail='collapsible']:not([data-open]) *) {
- @slot;
- }
-
- &:where([data-navigation-rail='modal']:not([data-open]), [data-navigation-rail='modal']:not([data-open]) *) {
- @slot;
- }
-
- /* Below `medium` a 220–360px rail would be most of the screen, so a rail whose width is the
- visitor's choice is held collapsed there whatever they chose. */
- @media (width < 37.5rem) {
- &:where([data-navigation-rail='collapsible']:not([data-open]), [data-navigation-rail='collapsible']:not([data-open]) *) {
- @slot;
- }
- }
-
- @media (width < 52.5rem) {
- &:where([data-navigation-rail='adaptive']:not([data-open]), [data-navigation-rail='adaptive']:not([data-open]) *) {
- @slot;
- }
- }
-
- /* Expanded (840–1199): a standard rail, collapsed unless the visitor expanded it. `data-rail`
- alone cannot say that — it carries `rail.default` for a visitor who never chose — so
- `data-rail-auto`, which sets while nothing is stored, stands for "no
- choice yet" and the class's own default applies. */
- @media (52.5rem <= width < 75rem) {
- &:where(:is([data-rail='collapsed'], [data-rail-auto]) [data-navigation-rail='adaptive']:not([data-open]), :is([data-rail='collapsed'], [data-rail-auto]) [data-navigation-rail='adaptive']:not([data-open]) *) {
- @slot;
- }
- }
-
- /* Large and extra-large (from 1200): expanded to begin with, which is what M3 prefers there. */
- @media (width >= 75rem) {
- &:where([data-rail='collapsed'] [data-navigation-rail='adaptive']:not([data-open]), [data-rail='collapsed'] [data-navigation-rail='adaptive']:not([data-open]) *) {
- @slot;
- }
- }
-}
-
-@layer components {
- /* ---------------------------------------------------------------- the navigation bar */
-
- [data-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-navigation-bar-items] {
- display: flex;
- min-height: 4rem;
- margin-inline: auto;
- }
-
- /* The tall configuration: NavigationBarTokens.TallContainerHeight, 80dp. It keeps the vertical
- item layout at every width — the horizontal one below is the short bar's alone — so the
- label always sits under its icon, which is what the extra 16px is for. */
- [data-navigation-bar][data-tall] [data-navigation-bar-items] {
- min-height: 5rem;
- }
-
- /* "Scrolling: hides on scroll-down, reappears on scroll-up" (the reference's Navigation bar
- § Behaviour). It slides out of the window on the default spatial spring, which reduced
- motion zeroes to an instant swap along with every other duration token. Whether it may hide
- at all is resources/js/navigation.js's call: never while a bottom sheet, a drawer or a
- snackbar is on screen, since those are anchored to the bar's edge. */
- [data-navigation-bar][data-hide-on-scroll] {
- transition: translate var(--md-sys-motion-spatial-default-duration) var(--md-sys-motion-spatial-default);
- }
-
- [data-navigation-bar][data-hide-on-scroll][data-hidden] {
- translate: 0 100%;
- }
-
- [data-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: 0.375rem;
- color: var(--md-sys-color-on-surface-variant);
- text-decoration: none;
- cursor: pointer;
- outline: none;
- -webkit-tap-highlight-color: transparent;
- }
-
- [data-navigation-bar-item] [data-navigation-pill] {
- display: flex;
- min-width: 0;
- max-width: 100%;
- flex-direction: column;
- align-items: center;
- gap: 0.25rem;
- 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-navigation-bar-item] [data-navigation-label] {
- max-width: 100%;
- padding-inline: 0.25rem;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
-
- [data-navigation-bar-item][data-active] {
- color: var(--md-sys-color-secondary);
- }
-
- /* From 600dp (M3's medium window), icon and label side by side in a 40px indicator, and the
- items centred with the padding ShortNavigationBar's Centered arrangement computes. The
- short bar only: a tall bar is the vertical layout at every width, so every rule here that
- the vertical layout also has an opinion about names the short bar. */
- @container (width >= 37.5rem) {
- [data-navigation-bar]:not([data-tall]) [data-navigation-bar-items] {
- width: calc(10% * (var(--navigation-bar-count, 7) + 3));
- min-width: fit-content;
- max-width: 100%;
- }
-
- [data-navigation-bar]:not([data-tall]) [data-navigation-bar-items]:has(> :last-child:nth-child(1)) { --navigation-bar-count: 1; }
- [data-navigation-bar]:not([data-tall]) [data-navigation-bar-items]:has(> :last-child:nth-child(2)) { --navigation-bar-count: 2; }
- [data-navigation-bar]:not([data-tall]) [data-navigation-bar-items]:has(> :last-child:nth-child(3)) { --navigation-bar-count: 3; }
- [data-navigation-bar]:not([data-tall]) [data-navigation-bar-items]:has(> :last-child:nth-child(4)) { --navigation-bar-count: 4; }
- [data-navigation-bar]:not([data-tall]) [data-navigation-bar-items]:has(> :last-child:nth-child(5)) { --navigation-bar-count: 5; }
- [data-navigation-bar]:not([data-tall]) [data-navigation-bar-items]:has(> :last-child:nth-child(6)) { --navigation-bar-count: 6; }
-
- [data-navigation-bar]:not([data-tall]) [data-navigation-bar-item] {
- min-width: max-content;
- }
-
- /* 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 — NavigationRailHorizontalItemTokens — which is
- a different component.) */
- [data-navigation-bar]:not([data-tall]) [data-navigation-bar-item] [data-navigation-pill] {
- position: relative;
- isolation: isolate;
- flex-direction: row;
- height: 2.5rem;
- padding-inline: 1rem;
- }
-
- [data-navigation-bar]:not([data-tall]) [data-navigation-bar-item] [data-navigation-label] {
- padding-inline: 0;
- }
-
- [data-navigation-bar]:not([data-tall]) [data-navigation-bar-item][data-active] {
- color: var(--md-sys-color-on-secondary-container);
- }
-
- [data-navigation-bar]:not([data-tall]) [data-navigation-bar-item][data-active] [data-navigation-pill] {
- background-image: linear-gradient(var(--md-sys-color-secondary-container), var(--md-sys-color-secondary-container));
- }
- }
-
- /* ---------------------------------------------------------------- the navigation rail */
-
- [data-navigation-rail] {
- --navigation-rail-expanded-width: clamp(13.75rem, var(--navigation-rail-width, 16rem), 22.5rem);
- --navigation-rail-collapsed-width: 6rem;
- position: relative;
- flex-shrink: 0;
- width: var(--navigation-rail-expanded-width);
- transition: width var(--md-sys-motion-spatial-default-duration) var(--md-sys-motion-spatial-default);
-
- @variant rail-collapsed {
- width: var(--navigation-rail-collapsed-width);
- }
- }
-
- /* M3's other collapsed width: NavigationRailCollapsedTokens.NarrowContainerWidth, 80dp against
- the default 96. A variable rather than a width here, so the rules below — which each have
- their own reason to name a collapsed rail's width — do not have to know about it. */
- [data-navigation-rail][data-width='narrow'] {
- --navigation-rail-collapsed-width: 5rem;
- }
-
- /* "Expanded behavior | Hide when collapsed" (the reference's Navigation rail § Variants and
- configurations): instead of narrowing, the rail leaves the layout entirely — for an
- immersive page — and comes back over it when a menu button somewhere else, the app bar's,
- calls `$store.rail.show()`. It is the *expanded* rail that hides, which is why what comes
- back is the expanded rail: "collapsed rail may not hide", and the two bands where a window
- size class rather than the visitor collapses one are given back below. */
- [data-navigation-rail][data-hide-when-collapsed] {
- @variant rail-collapsed {
- width: 0;
- }
- }
-
- /* There is no room for a label under the 56px indicator at 80px, so a narrow rail is the icons
- alone. The label is taken out of the drawing, not out of the page: it is what names the
- destination for a screen reader. */
- [data-navigation-rail][data-width='narrow'] [data-navigation-rail-item] [data-navigation-label] {
- @variant rail-collapsed {
- position: absolute;
- width: 1px;
- height: 1px;
- padding: 0;
- margin: -1px;
- overflow: hidden;
- clip-path: inset(50%);
- white-space: nowrap;
- }
- }
-
- /* A modal rail keeps its collapsed width in the layout while it is open over it, as Compose's
- ModalWideNavigationRail does; the adaptive rail does below `expanded`, and takes no room on a compact window. */
- [data-navigation-rail='modal'] {
- width: var(--navigation-rail-collapsed-width);
- }
-
- @media (width < 52.5rem) {
- [data-navigation-rail='adaptive'] {
- width: var(--navigation-rail-collapsed-width);
- }
- }
-
- @media (width < 37.5rem) {
- [data-navigation-rail='adaptive'] {
- width: 0;
- }
-
- /* "Compact → don't use a standard rail, use a navigation bar." A `collapsible` rail takes
- the width the visitor chose, which on a 360px screen would be two-thirds of it, so it
- is floored at its collapsed width. `collapsed` and `expanded` mean what they say and are
- left alone: wrap one in a `medium:` element if it must not show on a phone. */
- [data-navigation-rail='collapsible'] {
- width: var(--navigation-rail-collapsed-width);
- }
- }
-
- /* `--material-banner` is the height of a bar pinned across the top of the window — what
- 's `banner` slot holds, if the application made it sticky. The rail sticks
- under it rather than behind it; 0 without one. */
- [data-navigation-rail-panel] {
- position: sticky;
- top: var(--material-banner, 0px);
- display: flex;
- flex-direction: column;
- width: 100%;
- height: 100%;
- max-height: calc(100dvh - var(--material-banner, 0px));
- /* Clip, not hide: no scroll container, so the destinations below can still scroll and
- nothing sticky breaks. What only an expanded rail draws — a label, the brand — is drawn
- at once when the rail expands, while the width is still growing; the clip keeps it
- from spilling over the page for those frames. */
- overflow-x: clip;
- padding-bottom: var(--material-safe-bottom, env(safe-area-inset-bottom));
- background-color: var(--md-sys-color-surface);
- color: var(--md-sys-color-on-surface);
- transition:
- width var(--md-sys-motion-spatial-fast-duration) var(--md-sys-motion-spatial-fast),
- background-color var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-effects-default);
- }
-
- /* M3's two optional container treatments: "optional vertical divider separates rail from
- content, placed on the content-adjacent edge" — also its answer to a page scrolling under a
- fixed rail — and "container fill can be turned off (transparent) as long as items keep ≥3:1
- contrast". Neither applies to a rail open over a scrim, which is a surface over the page. */
- [data-navigation-rail][data-divider]:not([data-open]) > [data-navigation-rail-panel] {
- border-inline-end: 1px solid var(--md-sys-color-outline-variant);
- }
-
- [data-navigation-rail][data-fill='false']:not([data-open]) > [data-navigation-rail-panel] {
- background-color: transparent;
- }
-
- /* Open: expanded over a scrim, in surface-container with a large corner at its inner edge. */
- [data-navigation-rail][data-open] > [data-navigation-rail-panel] {
- position: fixed;
- inset-block: 0;
- inset-inline-start: 0;
- z-index: 50;
- width: var(--navigation-rail-expanded-width);
- max-width: calc(100vw - 3.5rem);
- height: 100dvh;
- max-height: none;
- border-start-end-radius: var(--md-sys-shape-corner-lg);
- border-end-end-radius: var(--md-sys-shape-corner-lg);
- background-color: var(--md-sys-color-surface-container);
- box-shadow: var(--md-sys-elevation-2);
- }
-
- /* On a compact window there is no collapsed rail to grow out of: the open rail slides in from the edge,
- on emphasized decelerate rather than a spring, which would overshoot and open a gap. */
- @media (width < 37.5rem) {
- [data-navigation-rail='adaptive'] > [data-navigation-rail-panel] {
- position: fixed;
- inset-block: 0;
- inset-inline-start: 0;
- z-index: 50;
- display: none;
- width: var(--navigation-rail-expanded-width);
- max-width: calc(100vw - 3.5rem);
- height: 100dvh;
- max-height: none;
- border-start-end-radius: var(--md-sys-shape-corner-lg);
- border-end-end-radius: var(--md-sys-shape-corner-lg);
- background-color: var(--md-sys-color-surface-container);
- box-shadow: var(--md-sys-elevation-2);
- translate: -100% 0;
- transition:
- translate var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-easing-emphasized-accelerate),
- display var(--md-sys-motion-effects-default-duration) allow-discrete;
-
- &:dir(rtl) {
- translate: 100% 0;
- }
- }
-
- [data-navigation-rail='adaptive'][data-open] > [data-navigation-rail-panel] {
- display: flex;
- translate: 0 0;
- transition:
- translate var(--md-sys-motion-spatial-default-duration) var(--md-sys-motion-easing-emphasized-decelerate),
- display var(--md-sys-motion-spatial-default-duration) allow-discrete;
-
- @starting-style {
- translate: -100% 0;
- }
-
- &:dir(rtl) {
- translate: 0 0;
-
- @starting-style {
- translate: 100% 0;
- }
- }
- }
- }
-
- /* A rail that hides when collapsed is in the same position: nothing is left in the layout for
- it to grow out of, so it takes the compact window's slide-in. The whole treatment is under
- the `rail-collapsed` variant, which stops the moment the rail is open — that is what makes
- it the expanded rail that comes back, on the rule above. */
- [data-navigation-rail][data-hide-when-collapsed] > [data-navigation-rail-panel] {
- @variant rail-collapsed {
- display: none;
- translate: -100% 0;
- transition:
- translate var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-easing-emphasized-accelerate),
- display var(--md-sys-motion-effects-default-duration) allow-discrete;
-
- &:dir(rtl) {
- translate: 100% 0;
- }
- }
- }
-
- [data-navigation-rail][data-hide-when-collapsed][data-open] > [data-navigation-rail-panel] {
- display: flex;
- translate: 0 0;
- transition:
- translate var(--md-sys-motion-spatial-default-duration) var(--md-sys-motion-easing-emphasized-decelerate),
- display var(--md-sys-motion-spatial-default-duration) allow-discrete;
-
- @starting-style {
- translate: -100% 0;
- }
-
- &:dir(rtl) {
- translate: 0 0;
-
- @starting-style {
- translate: 100% 0;
- }
- }
- }
-
- /* The two bands the configuration does not reach, because there it is the window and not the
- visitor that collapses the rail: below `medium` for a collapsible rail — where a rail is
- held at its collapsed width anyway — and `medium` itself for the adaptive one, which M3
- gives a collapsed rail. (Below `medium` the adaptive rail is already away, over the page,
- and the block above it says so.) resources/js/navigation.js reads the same two numbers, so
- the menu button and the drawing agree at every width. */
- @media (width < 37.5rem) {
- [data-navigation-rail='collapsible'][data-hide-when-collapsed] {
- width: var(--navigation-rail-collapsed-width);
- }
-
- [data-navigation-rail='collapsible'][data-hide-when-collapsed] > [data-navigation-rail-panel] {
- display: flex;
- translate: none;
- }
- }
-
- @media (37.5rem <= width < 52.5rem) {
- [data-navigation-rail='adaptive'][data-hide-when-collapsed] {
- width: var(--navigation-rail-collapsed-width);
- }
-
- [data-navigation-rail='adaptive'][data-hide-when-collapsed] > [data-navigation-rail-panel] {
- display: flex;
- translate: none;
- }
- }
-
- [data-navigation-rail-scrim] {
- position: fixed;
- inset: 0;
- z-index: 40;
- display: none;
- background-color: color-mix(in srgb, var(--md-sys-color-scrim) 32%, transparent);
- opacity: 0;
- transition:
- opacity var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-effects-default),
- display var(--md-sys-motion-effects-default-duration) allow-discrete;
- }
-
- [data-navigation-rail][data-open] > [data-navigation-rail-scrim] {
- display: block;
- opacity: 1;
-
- @starting-style {
- opacity: 0;
- }
- }
-
- /* 44px above the header (TopSpace), 40px under it (HeaderSpaceMinimum) — 32 here and 8 as
- the destinations' own padding, which keeps the first item's focus ring inside the scroller. */
- [data-navigation-rail-header] {
- display: flex;
- flex-shrink: 0;
- flex-direction: column;
- align-items: flex-start;
- gap: 0.5rem;
- padding-top: calc(var(--material-safe-top, env(safe-area-inset-top)) + 2.75rem);
- padding-bottom: 2rem;
- }
-
- [data-navigation-rail-panel] > [data-navigation-rail-destinations]:first-child {
- padding-top: calc(var(--material-safe-top, env(safe-area-inset-top)) + 2.75rem);
- }
-
- [data-navigation-rail-destinations] {
- flex: 1 1 auto;
- min-height: 0;
- overflow-y: auto;
- overscroll-behavior: contain;
- padding-block: 0.5rem;
- scrollbar-width: thin;
- }
-
- [data-navigation-rail-destinations],
- [data-navigation-rail-section],
- [data-navigation-rail-footer] {
- display: flex;
- flex-direction: column;
-
- @variant rail-collapsed {
- gap: 0.25rem;
- }
- }
-
- [data-navigation-rail-footer] {
- flex-shrink: 0;
- padding-block: 0.5rem 1rem;
- }
-
- /* "Alignment: top or center; on tablets prefer center alignment for reach. Menu icon and FAB
- are always top-aligned" (docs/reference/m3/components-navigation-selection-inputs.md
- § Navigation rail). Only the destinations move, then — the header and the footer stay at
- their ends of the rail — and `safe` puts them back at the top the moment there are more of
- them than fit, where centring would otherwise push the first one out of reach above the
- scroller. */
- [data-navigation-rail][data-align='center'] [data-navigation-rail-destinations] {
- justify-content: safe center;
- }
-
- [data-navigation-rail-heading] {
- display: flex;
- align-items: center;
- min-height: 3rem;
- padding-inline: 2.25rem 1.25rem;
- overflow: hidden;
- color: var(--md-sys-color-on-surface-variant);
- font: var(--md-sys-typescale-title-sm);
- letter-spacing: var(--md-sys-typescale-title-sm-tracking);
- white-space: nowrap;
- text-overflow: ellipsis;
-
- @variant rail-collapsed {
- display: none;
- }
- }
-
- [data-navigation-rail-section]:not(:first-child) {
- @variant rail-collapsed {
- margin-top: 0.75rem;
- }
- }
-
- /* Expanded: a 56px full-width pill, icon and label 8px apart, label-large. */
- [data-navigation-rail-item] {
- --navigation-layer: 0;
- position: relative;
- isolation: isolate;
- display: flex;
- flex-shrink: 0;
- align-items: center;
- gap: 0.5rem;
- height: 3.5rem;
- margin-inline: 1.25rem;
- padding-inline: 1rem;
- border-radius: var(--md-sys-shape-corner-full);
- color: var(--md-sys-color-on-surface-variant);
- font: var(--md-sys-typescale-label-lg);
- letter-spacing: var(--md-sys-typescale-label-lg-tracking);
- text-align: start;
- text-decoration: none;
- white-space: nowrap;
- cursor: pointer;
- outline: none;
- -webkit-tap-highlight-color: transparent;
-
- /* Collapsed: the icon in its 56×32 indicator over a label-medium label, 64px tall. */
- @variant rail-collapsed {
- flex-direction: column;
- justify-content: center;
- gap: 0.25rem;
- height: auto;
- min-height: 4rem;
- margin-inline: 0;
- padding: 0.375rem 0.25rem;
- border-radius: 0;
- font: var(--md-sys-typescale-label-md);
- letter-spacing: var(--md-sys-typescale-label-md-tracking);
- white-space: normal;
- }
- }
-
- [data-navigation-rail-item] [data-navigation-label] {
- flex: 1 1 auto;
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
-
- @variant rail-collapsed {
- flex: none;
- display: -webkit-box;
- max-width: 100%;
- -webkit-box-orient: vertical;
- -webkit-line-clamp: 2;
- text-align: center;
- overflow-wrap: anywhere;
- }
- }
-
- [data-navigation-rail-item][data-active] {
- background-image: linear-gradient(var(--md-sys-color-secondary-container), var(--md-sys-color-secondary-container));
- color: var(--md-sys-color-on-secondary-container);
-
- @variant rail-collapsed {
- background-image: none;
- color: var(--md-sys-color-secondary);
- }
- }
-
- [data-navigation-rail-item] [data-navigation-indicator] {
- position: relative;
- isolation: isolate;
- display: flex;
- flex-shrink: 0;
- align-items: center;
- justify-content: center;
- border-radius: var(--md-sys-shape-corner-full);
-
- @variant rail-collapsed {
- width: 3.5rem;
- height: 2rem;
- }
- }
-
- /* ---------------------------------------------------------------- both: indicator, states */
-
- [data-navigation-bar-item] [data-navigation-indicator] {
- position: relative;
- isolation: isolate;
- display: flex;
- flex-shrink: 0;
- align-items: center;
- justify-content: center;
- width: 3.5rem;
- height: 2rem;
- border-radius: var(--md-sys-shape-corner-full);
- }
-
- /* The vertical layout — a compact bar, and a tall one at any width — fills the 56×32 box
- behind the icon; the horizontal layout fills the pill around icon and label instead, so it
- takes the fill off the box again. */
- [data-navigation-bar-item][data-active] [data-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);
- }
-
- @container (width >= 37.5rem) {
- [data-navigation-bar]:not([data-tall]) [data-navigation-bar-item][data-active] [data-navigation-indicator] {
- background-image: none;
- }
- }
-
- [data-navigation-rail-item][data-active] [data-navigation-indicator] {
- @variant rail-collapsed {
- background-image: linear-gradient(var(--md-sys-color-secondary-container), var(--md-sys-color-secondary-container));
- color: var(--md-sys-color-on-secondary-container);
- }
- }
-
- /* The active indicator 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 it. */
- [data-navigation-bar-item] :is([data-navigation-indicator], [data-navigation-pill]),
- [data-navigation-rail-item],
- [data-navigation-rail-item] [data-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);
- }
-
- /* The state layer covers only the indicator: the pill when the indicator holds the label
- too, the 56×32 shape when it holds the icon alone. Hover only where a pointer can hover. */
- @media (hover: hover) {
- :is([data-navigation-bar-item], [data-navigation-rail-item]):hover {
- --navigation-layer: 0.08;
- }
- }
-
- :is([data-navigation-bar-item], [data-navigation-rail-item]):is(:focus-visible, :active) {
- --navigation-layer: 0.1;
- }
-
- [data-navigation-bar-item] [data-navigation-indicator]::before,
- [data-navigation-bar-item] [data-navigation-pill]::before,
- [data-navigation-rail-item] [data-navigation-indicator]::before,
- [data-navigation-rail-item]::before {
- content: '';
- position: absolute;
- inset: 0;
- z-index: -1;
- border-radius: inherit;
- background-color: var(--md-sys-color-on-surface);
- opacity: var(--navigation-layer);
- pointer-events: none;
- transition: opacity var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
- }
-
- /* A navigation item's state layer is on-secondary-container, active or not: the only
- state-layer tokens M3 states for one are NavigationRailColorTokens', which give that colour
- to all six. The rail follows them two blocks below; the bar follows them here, over its
- secondary-container pill. */
- [data-navigation-bar-item] :is([data-navigation-indicator], [data-navigation-pill])::before {
- background-color: var(--md-sys-color-on-secondary-container);
- }
-
- [data-navigation-bar-item] [data-navigation-pill]::before {
- display: none;
- }
-
- @container (width >= 37.5rem) {
- [data-navigation-bar]:not([data-tall]) [data-navigation-bar-item] [data-navigation-pill]::before {
- display: block;
- }
-
- [data-navigation-bar]:not([data-tall]) [data-navigation-bar-item] [data-navigation-indicator]::before {
- display: none;
- }
- }
-
- /* The rail item's layer is on the item while expanded and on its indicator while collapsed. A
- variant cannot follow a pseudo-element in a selector, so the item hands the layer on through
- variables; each rule below outweighs its line in the list above. */
- [data-navigation-rail-item] {
- --navigation-item-layer: var(--navigation-layer);
- --navigation-indicator-layer: 0;
-
- @variant rail-collapsed {
- --navigation-item-layer: 0;
- --navigation-indicator-layer: var(--navigation-layer);
- }
- }
-
- [data-navigation-rail-item][data-navigation-rail-item]::before {
- background-color: var(--md-sys-color-on-secondary-container);
- opacity: var(--navigation-item-layer);
- }
-
- [data-navigation-rail-item][data-navigation-rail-item] [data-navigation-indicator]::before {
- background-color: var(--md-sys-color-on-secondary-container);
- opacity: var(--navigation-indicator-layer);
- }
-
- /* M3's focus indicator, 3px of secondary 2px out, around the same shape. */
- [data-navigation-bar-item]:focus-visible [data-navigation-indicator] {
- outline: 3px solid var(--md-sys-color-secondary);
- outline-offset: 2px;
- }
-
- @container (width >= 37.5rem) {
- [data-navigation-bar]:not([data-tall]) [data-navigation-bar-item]:focus-visible [data-navigation-indicator] {
- outline: none;
- }
-
- [data-navigation-bar]:not([data-tall]) [data-navigation-bar-item]:focus-visible [data-navigation-pill] {
- outline: 3px solid var(--md-sys-color-secondary);
- outline-offset: 2px;
- }
- }
-
- [data-navigation-rail-item]:focus-visible {
- outline: 3px solid var(--md-sys-color-secondary);
- outline-offset: 2px;
-
- @variant rail-collapsed {
- outline: none;
- }
- }
-
- [data-navigation-rail-item]:focus-visible [data-navigation-indicator] {
- @variant rail-collapsed {
- outline: 3px solid var(--md-sys-color-secondary);
- outline-offset: 2px;
- }
- }
-}
-
-/* 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 the bar. Unlayered on purpose: publishes the variable
- * with a utility, and a rule in any layer loses to one. */
-[data-app-shell]:has([data-navigation-bar][data-hide-on-scroll][data-hidden]) {
- --material-bottom-bar: calc(var(--material-safe-bottom, env(safe-area-inset-bottom)) + var(--material-bottom-extra, 0px));
-}
-
-/* A FAB in the rail's header rests at elevation 0, not the 3 a standalone FAB has
- * (docs/reference/m3/components-navigation-selection-inputs.md § Navigation rail: "when nested
- * within another component, such as the navigation rail, the FAB's resting elevation should be
- * level 0"). Unlayered on purpose: the FAB draws its shadow with a utility, and a rule in any
- * layer loses to a utility — the same reason toolbar.css gives for its vibrant recolouring. */
-[data-navigation-rail-header] [data-md-fab],
-[data-navigation-rail-header] [data-md-fab]:hover {
- box-shadow: none;
-}
-
-/* "Rail transitions from collapsed to expanded should animate the FAB into and out of an extended
- * FAB" — so the header takes one and morphs it, rather than swapping two by
- * `display`, which showed the collapsed one at once while the rail's width was still springing.
- * The label's width springs on the rail's own spatial spring; with the gap closed and the extended
- * FAB's own minimum width off, `aspect-ratio` squares what is left against the height its size
- * already sets — 56, 80 or 96px, the three FAB sizes — so no number is repeated here. The label
- * stays in the accessibility tree at both widths, so the FAB keeps its name. Unlayered for the
- * same reason as the rule above: `gap-2` and `min-w-20` are utilities, and a layer would lose to
- * them. The cap is there because a transition needs a length to travel to; a rail FAB's label is a
- * word or two. */
-[data-navigation-rail-header] [data-md-fab] > span {
- max-width: 16rem;
- overflow: hidden;
- white-space: nowrap;
- transition:
- max-width var(--md-sys-motion-spatial-default-duration) var(--md-sys-motion-spatial-default),
- opacity var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-effects-default);
-
- @variant rail-collapsed {
- max-width: 0;
- opacity: 0;
- }
-}
-
-[data-navigation-rail-header] [data-md-fab] {
- @variant rail-collapsed {
- min-width: 0;
- aspect-ratio: 1;
- gap: 0;
- }
-}
diff --git a/resources/css/tailwind.css b/resources/css/tailwind.css
index 3e05f79e..8d88dea8 100644
--- a/resources/css/tailwind.css
+++ b/resources/css/tailwind.css
@@ -16,6 +16,3 @@
@import './tokens/theme.css';
@import './tokens/utilities.css';
-
-/* Navigation — leaving in step 36 */
-@import './components/navigation.css';
diff --git a/tests/Browser/ColourProfilesTest.php b/tests/Browser/ColourProfilesTest.php
index cffd0ac2..c2262b34 100644
--- a/tests/Browser/ColourProfilesTest.php
+++ b/tests/Browser/ColourProfilesTest.php
@@ -73,7 +73,7 @@ it('previews a profile from the picker and the showcase menu, and keeps it throu
->click('[data-scheme-preview="teal"]')
->assertScript("document.documentElement.getAttribute('data-scheme') === 'teal'");
- $page->click('[data-navigation-rail-panel] a[href$="/material/buttons"]')
+ $page->click('[data-md-navigation-rail-panel] a[href$="/material/buttons"]')
->assertScript("location.pathname.endsWith('/material/buttons')")
->assertScript("document.documentElement.getAttribute('data-scheme') === 'teal'")
->assertNoJavaScriptErrors();
diff --git a/tests/Browser/NavigationTest.php b/tests/Browser/NavigationTest.php
index 8a1e1ef8..6e93750b 100644
--- a/tests/Browser/NavigationTest.php
+++ b/tests/Browser/NavigationTest.php
@@ -3,8 +3,8 @@
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Route;
-const RAIL = "document.querySelector('[data-navigation-rail]')";
-const RAIL_PANEL = "document.querySelector('[data-navigation-rail-panel]')";
+const RAIL = "document.querySelector('[data-md-navigation-rail]')";
+const RAIL_PANEL = "document.querySelector('[data-md-navigation-rail-panel]')";
const BOTTOM_BAR = "document.querySelector('[data-app-shell-bar]')";
function navigationReady(mixed $page): mixed
@@ -20,7 +20,7 @@ function shellPage(int $width = 1280, int $height = 900, string $path = '/materi
// of its queries are M3 window size classes: `expanded` (840px) says the rail is standard
// rather than modal, `large` (1200px) that it starts expanded rather than collapsed.
return navigationReady(visit($path)->resize($width, $height))
- ->assertScript("window.eval(\"(() => { const rail = document.querySelector('[data-navigation-rail]'); if (! rail || rail.dataset.navigationRail !== 'adaptive') return true; const state = Alpine.\$data(rail); return state.wide === window.matchMedia('(width >= 840px)').matches && state.roomy === window.matchMedia('(width >= 1200px)').matches; })()\")");
+ ->assertScript("window.eval(\"(() => { const rail = document.querySelector('[data-md-navigation-rail]'); if (! rail || rail.dataset.mdNavigationRail !== 'adaptive') return true; const state = Alpine.\$data(rail); return state.wide === window.matchMedia('(width >= 840px)').matches && state.roomy === window.matchMedia('(width >= 1200px)').matches; })()\")");
}
/**
@@ -46,7 +46,7 @@ function firstPaintProbe(): void
@@ -67,10 +67,10 @@ it('shows the navigation bar on a compact window and marks the current destinati
shellPage(599, 860)
->assertScript('getComputedStyle('.BOTTOM_BAR.").display !== 'none'")
->assertScript('getComputedStyle('.RAIL_PANEL.").display === 'none'")
- ->assertScript(BOTTOM_BAR.".querySelectorAll('[data-navigation-bar-item]').length === 4")
+ ->assertScript(BOTTOM_BAR.".querySelectorAll('[data-md-navigation-bar-item]').length === 4")
->assertScript(BOTTOM_BAR.".querySelector('[aria-current=\"page\"]').textContent.includes('Inbox')")
- ->assertScript('getComputedStyle('.BOTTOM_BAR.".querySelector('[aria-current=\"page\"] [data-navigation-indicator]')).backgroundImage !== 'none'")
- ->assertScript('getComputedStyle('.BOTTOM_BAR.".querySelector('[data-navigation-bar-item]:not([aria-current]) [data-navigation-indicator]')).backgroundImage === 'none'")
+ ->assertScript('getComputedStyle('.BOTTOM_BAR.".querySelector('[aria-current=\"page\"] [data-md-navigation-indicator]')).backgroundImage !== 'none'")
+ ->assertScript('getComputedStyle('.BOTTOM_BAR.".querySelector('[data-md-navigation-bar-item]:not([aria-current]) [data-md-navigation-indicator]')).backgroundImage === 'none'")
->assertNoJavaScriptErrors();
});
@@ -83,9 +83,9 @@ it('collapses the rail across the medium class, icon over label, from its first
shellPage(839)
->assertScript(railWidth(96))
->assertScript('getComputedStyle('.BOTTOM_BAR.").display === 'none'")
- ->assertScript("getComputedStyle(document.querySelector('[data-navigation-rail-item]')).flexDirection === 'column'")
- ->assertScript("getComputedStyle(document.querySelector('[data-navigation-rail-heading]')).display === 'none'")
- ->assertAttribute('[data-navigation-rail-menu]', 'aria-expanded', 'false');
+ ->assertScript("getComputedStyle(document.querySelector('[data-md-navigation-rail-item]')).flexDirection === 'column'")
+ ->assertScript("getComputedStyle(document.querySelector('[data-md-navigation-rail-heading]')).display === 'none'")
+ ->assertAttribute('[data-md-navigation-rail-menu]', 'aria-expanded', 'false');
});
it('starts the rail collapsed across the expanded class and expands it in the layout', function () {
@@ -100,17 +100,17 @@ it('starts the rail collapsed across the expanded class and expands it in the la
$page = shellPage(840)
->assertScript(railWidth(96))
->assertScript('getComputedStyle('.BOTTOM_BAR.").display === 'none'")
- ->assertAttribute('[data-navigation-rail-menu]', 'aria-expanded', 'false');
+ ->assertAttribute('[data-md-navigation-rail-menu]', 'aria-expanded', 'false');
- $page->click('[data-navigation-rail-menu]')
+ $page->click('[data-md-navigation-rail-menu]')
->assertScript(railWidth(256))
- ->assertScript('! '.RAIL.".hasAttribute('data-open')")
- ->assertScript("getComputedStyle(document.querySelector('[data-navigation-rail-scrim]')).display === 'none'")
+ ->assertScript('! '.RAIL.".hasAttribute('data-md-open')")
+ ->assertScript("getComputedStyle(document.querySelector('[data-md-navigation-rail-scrim]')).display === 'none'")
->assertScript("document.getElementById('content').closest('[aria-hidden=\"true\"]') === null")
->assertScript("document.documentElement.getAttribute('data-rail') === 'expanded'")
->assertScript("! document.documentElement.hasAttribute('data-rail-auto')")
->assertScript("localStorage.getItem('material-rail') === 'expanded'")
- ->assertAttribute('[data-navigation-rail-menu]', 'aria-expanded', 'true')
+ ->assertAttribute('[data-md-navigation-rail-menu]', 'aria-expanded', 'true')
->assertNoJavaScriptErrors();
// The choice outlives the reload, and holds to the top of the class.
@@ -126,12 +126,12 @@ it('expands the rail from large, and keeps it collapsed across a reload from the
$page = shellPage(1200, 900, '/rail-probe')
->assertScript(railWidth(256))
->assertScript("window.eval('window.firstPaint.width') === '256px'")
- ->assertAttribute('[data-navigation-rail-menu]', 'aria-expanded', 'true');
+ ->assertAttribute('[data-md-navigation-rail-menu]', 'aria-expanded', 'true');
- $page->click('[data-navigation-rail-menu]')
+ $page->click('[data-md-navigation-rail-menu]')
->assertScript("document.documentElement.getAttribute('data-rail') === 'collapsed'")
->assertScript("localStorage.getItem('material-rail') === 'collapsed'")
- ->assertAttribute('[data-navigation-rail-menu]', 'aria-expanded', 'false')
+ ->assertAttribute('[data-md-navigation-rail-menu]', 'aria-expanded', 'false')
->assertScript(railWidth(96));
navigationReady($page->refresh())
@@ -152,7 +152,7 @@ it('expands the rail by default at extra-large', function () {
shellPage(1600, 900)
->assertScript(railWidth(256))
->assertScript('getComputedStyle('.BOTTOM_BAR.").display === 'none'")
- ->assertAttribute('[data-navigation-rail-menu]', 'aria-expanded', 'true')
+ ->assertAttribute('[data-md-navigation-rail-menu]', 'aria-expanded', 'true')
->assertNoJavaScriptErrors();
});
@@ -160,26 +160,26 @@ it('opens the modal rail from its menu button below expanded, holding focus unti
$page = shellPage(839);
// From the keyboard: WebKit neither focuses a button on click nor returns focus to one that never had it.
- $page->script("document.querySelector('[data-navigation-rail-menu]').focus()");
- $page->keys('[data-navigation-rail-menu]', 'Enter')
- ->assertScript(RAIL.".hasAttribute('data-open')")
+ $page->script("document.querySelector('[data-md-navigation-rail-menu]').focus()");
+ $page->keys('[data-md-navigation-rail-menu]', 'Enter')
+ ->assertScript(RAIL.".hasAttribute('data-md-open')")
->assertScript('Math.round('.RAIL_PANEL.'.getBoundingClientRect().width) === 256')
->assertScript(railWidth(96))
- ->assertScript("getComputedStyle(document.querySelector('[data-navigation-rail-heading]')).display !== 'none'")
+ ->assertScript("getComputedStyle(document.querySelector('[data-md-navigation-rail-heading]')).display !== 'none'")
->assertScript(RAIL_PANEL.'.contains(document.activeElement)')
->assertScript("document.getElementById('content').closest('[aria-hidden=\"true\"]') !== null")
- ->assertAttribute('[data-navigation-rail-menu]', 'aria-expanded', 'true');
+ ->assertAttribute('[data-md-navigation-rail-menu]', 'aria-expanded', 'true');
$page->keys(':focus', 'Escape')
- ->assertScript('! '.RAIL.".hasAttribute('data-open')")
+ ->assertScript('! '.RAIL.".hasAttribute('data-md-open')")
->assertScript("document.getElementById('content').closest('[aria-hidden=\"true\"]') === null")
- ->assertScript("document.activeElement === document.querySelector('[data-navigation-rail-menu]')");
+ ->assertScript("document.activeElement === document.querySelector('[data-md-navigation-rail-menu]')");
- $page->keys('[data-navigation-rail-menu]', 'Enter')
- ->assertScript(RAIL.".hasAttribute('data-open')");
+ $page->keys('[data-md-navigation-rail-menu]', 'Enter')
+ ->assertScript(RAIL.".hasAttribute('data-md-open')");
- $page->click('[data-navigation-rail-scrim]')
- ->assertScript('! '.RAIL.".hasAttribute('data-open')")
+ $page->click('[data-md-navigation-rail-scrim]')
+ ->assertScript('! '.RAIL.".hasAttribute('data-md-open')")
->assertScript("localStorage.getItem('material-rail') === null");
});
@@ -187,32 +187,32 @@ it('slides the modal rail in on a compact window from the app bar\'s menu button
$page = shellPage(599, 860);
$page->click('@shell-menu')
- ->assertScript(RAIL.".hasAttribute('data-open')")
+ ->assertScript(RAIL.".hasAttribute('data-md-open')")
->assertScript('getComputedStyle('.RAIL_PANEL.").display === 'flex'")
->assertScript('Math.round('.RAIL_PANEL.'.getBoundingClientRect().left) === 0')
->assertScript(RAIL_PANEL.'.contains(document.activeElement)');
$page->keys(':focus', 'Escape')
- ->assertScript('! '.RAIL.".hasAttribute('data-open')")
+ ->assertScript('! '.RAIL.".hasAttribute('data-md-open')")
->assertScript('getComputedStyle('.RAIL_PANEL.").display === 'none'");
});
it('keeps the rail and the theme through wire:navigate and moves aria-current', function () {
$page = shellPage(1200);
- $page->click('[data-navigation-rail-menu]')
+ $page->click('[data-md-navigation-rail-menu]')
->assertScript("document.documentElement.getAttribute('data-rail') === 'collapsed'");
$page->script("window.eval(\"Alpine.store('theme').set('dark'); window.samePage = true\")");
- $page->click('[data-navigation-rail-panel] a[href$="/material/shell/starred"]')
+ $page->click('[data-md-navigation-rail-panel] a[href$="/material/shell/starred"]')
->assertSeeIn('@shell-page', 'This is the starred page.')
->assertScript("window.eval('window.samePage') === true")
->assertScript("document.documentElement.getAttribute('data-rail') === 'collapsed'")
->assertScript("document.documentElement.getAttribute('data-theme') === 'dark'")
->assertScript(railWidth(96))
- ->assertScript("document.querySelector('[data-navigation-rail-panel] [aria-current=\"page\"]').getAttribute('href').endsWith('/material/shell/starred')")
- ->assertScript("document.querySelectorAll('[data-navigation-rail-panel] [aria-current=\"page\"]').length === 1")
+ ->assertScript("document.querySelector('[data-md-navigation-rail-panel] [aria-current=\"page\"]').getAttribute('href').endsWith('/material/shell/starred')")
+ ->assertScript("document.querySelectorAll('[data-md-navigation-rail-panel] [aria-current=\"page\"]').length === 1")
->assertNoJavaScriptErrors();
});
@@ -258,3 +258,114 @@ it('lifts the snackbar and the page above something docked on the phone\'s bar',
$page->assertScript("{$snackbarBottom} === 120")
->assertScript("{$contentPadding} === 104");
});
+
+/**
+ * A page for the navigation bar and rail props the shell's own adaptive rail never exercises:
+ * `tall`, `hide-on-scroll` (with a pinned snackbar), a narrow centred rail, and a rail that hides
+ * when collapsed. A wide, fixed viewport throughout, so none of M3's breakpoints interfere with
+ * what each example's own props already decide (docs/plans/material-3-browser-tests.md § Navigation).
+ */
+function navigationExtrasProbe(): mixed
+{
+ Route::middleware('web')->get('/navigation-extras-probe', fn () => Blade::render(<<<'BLADE'
+
+
+
+
+ @vite(config('livewire-material.showcase.vite'))
+ @livewireStyles
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Open navigation
+
+
+
+
+
+ @livewireScripts
+
+
+ BLADE));
+
+ return navigationReady(visit('/navigation-extras-probe')->resize(1000, 900));
+}
+
+it('draws the tall navigation bar, icon over label at every width', function () {
+ $page = navigationExtrasProbe();
+
+ $page->assertScript("Math.round(document.querySelector('#tall-bar [data-md-navigation-bar-items]').getBoundingClientRect().height) === 80")
+ ->assertScript("getComputedStyle(document.querySelector('#tall-bar [data-md-navigation-pill]')).flexDirection === 'column'")
+ ->assertNoJavaScriptErrors();
+});
+
+it('hides the bar on a scroll down and never while a pinned snackbar is on screen', function () {
+ $page = navigationExtrasProbe();
+ $hidden = "document.querySelector('#hide-bar [data-md-navigation-bar]').hasAttribute('data-md-hidden')";
+
+ $page->script('window.scrollTo(0, 400)');
+ $page->assertScript("{$hidden} === true")
+ ->assertNoJavaScriptErrors();
+
+ // Back to the top brings it back, as a fresh scroll-down would; pin a snackbar first.
+ $page->script('window.scrollTo(0, 0)');
+ $page->assertScript("{$hidden} === false");
+
+ $page->script("materialToast('Pinned', { sticky: true })")
+ ->assertScript("document.querySelector('[data-md-toast-snackbar]')?.textContent.includes('Pinned')");
+
+ $page->script('window.scrollTo(0, 400)');
+ $page->assertScript("{$hidden} === false")
+ ->assertNoJavaScriptErrors();
+});
+
+it('draws the narrow collapsed width and centres the destinations', function () {
+ $page = navigationExtrasProbe();
+
+ $page->assertScript("Math.round(document.querySelector('#narrow-rail [data-md-navigation-rail]').getBoundingClientRect().width) === 80")
+ ->assertScript("getComputedStyle(document.querySelector('#narrow-rail [data-md-navigation-rail-destinations]')).justifyContent === 'safe center'")
+ ->assertNoJavaScriptErrors();
+});
+
+it('takes a rail out of the layout entirely when it hides collapsed, and back over the page', function () {
+ $page = navigationExtrasProbe();
+ $rail = "document.querySelector('#hiding-rail [data-md-navigation-rail]')";
+ $railWidth = "Math.round({$rail}.getBoundingClientRect().width)";
+
+ $page->assertScript("{$railWidth} > 200")
+ ->click('#hiding-rail [data-md-navigation-rail-menu]')
+ ->assertScript("{$railWidth} === 0")
+ ->assertScript("! {$rail}.hasAttribute('data-md-open')");
+
+ // The only way back: an application's own menu button calling $store.rail.show().
+ $page->click('#open-hiding-rail')
+ ->assertScript("{$rail}.hasAttribute('data-md-open')")
+ ->assertScript("getComputedStyle(document.querySelector('#hiding-rail [data-md-navigation-rail-panel]')).display === 'flex'")
+ ->assertNoJavaScriptErrors();
+});
diff --git a/tests/Browser/ShowcaseTest.php b/tests/Browser/ShowcaseTest.php
index 01edfe32..5e272890 100644
--- a/tests/Browser/ShowcaseTest.php
+++ b/tests/Browser/ShowcaseTest.php
@@ -10,9 +10,9 @@ it('moves between sections through the rail and the next-section link, keeping t
$page = visit('/material')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
- $page->click('[data-navigation-rail-panel] a[href$="/material/buttons"]')
+ $page->click('[data-md-navigation-rail-panel] a[href$="/material/buttons"]')
->assertSee('Variants')
- ->assertScript("document.querySelector('[data-navigation-rail-panel] [aria-current=\"page\"]').getAttribute('href').endsWith('/material/buttons')")
+ ->assertScript("document.querySelector('[data-md-navigation-rail-panel] [aria-current=\"page\"]').getAttribute('href').endsWith('/material/buttons')")
->assertScript("document.title === 'Buttons · Livewire Material'");
$page->click('[data-test="next-section"]')
diff --git a/tests/Feature/Components/ScaffoldTest.php b/tests/Feature/Components/ScaffoldTest.php
index 745c6fbb..c08bdb4d 100644
--- a/tests/Feature/Components/ScaffoldTest.php
+++ b/tests/Feature/Components/ScaffoldTest.php
@@ -19,27 +19,27 @@ it('draws the adaptive rail, the bar, the content region and the snackbar host',
$html = (string) $this->blade('The page ', ['destinations' => scaffoldDestinations()]);
expect($html)
- ->toContain('data-navigation-rail="adaptive"')
- ->toContain('data-navigation-bar')
+ ->toContain('data-md-navigation-rail="adaptive"')
+ ->toContain('data-md-navigation-bar')
->toContain('toContain('max-expanded:overflow-x-clip')
->toContain('The page')
->toContain('x-data="materialSnackbar"')
->toMatch('/and(strpos($html, 'data-skip-link'))->toBeLessThan(strpos($html, 'data-navigation-rail='));
+ ->and(strpos($html, 'data-skip-link'))->toBeLessThan(strpos($html, 'data-md-navigation-rail='));
});
it('puts every destination in the rail and only those marked for the bar in the bar', function () {
$html = (string) $this->blade(' ', ['destinations' => scaffoldDestinations()]);
- $rail = substr($html, strpos($html, 'data-navigation-rail='), strpos($html, 'data-app-shell-bar') - strpos($html, 'data-navigation-rail='));
+ $rail = substr($html, strpos($html, 'data-md-navigation-rail='), strpos($html, 'data-app-shell-bar') - strpos($html, 'data-md-navigation-rail='));
$bar = substr($html, strpos($html, 'data-app-shell-bar'));
- expect(substr_count($rail, ' toBe(4)
- ->and(substr_count($bar, ' toBe(2)
+ expect(substr_count($rail, ' toBe(4)
+ ->and(substr_count($bar, ' toBe(2)
->and($bar)->toContain('Inbox')->toContain('Sent')->not->toContain('Travel')
- ->and($rail)->toContain('data-navigation-rail-heading>Labels
')
- ->and(substr_count($rail, 'data-navigation-rail-section'))->toBe(1)
+ ->and($rail)->toContain('data-md-navigation-rail-heading>Labels')
+ ->and(substr_count($rail, 'data-md-navigation-rail-section'))->toBe(1)
->and($rail)->toMatch('/href="\/receipts"(?![^>]*wire:navigate)/')
->and(substr_count($html, 'aria-current="page"'))->toBe(2);
});
@@ -99,28 +99,28 @@ it('lifts the snackbar above the bar only when there is a bar', function () {
it('passes M3\'s tall bar through, and the bottom offset grows with it', function () {
expect((string) $this->blade(' ', ['destinations' => scaffoldDestinations()]))
- ->toMatch('/data-navigation-bar\s+data-tall\s/')
+ ->toMatch('/data-md-navigation-bar\s+data-md-tall\s/')
->toContain('max-medium:[--material-bottom-bar:calc(5rem+')
->not->toContain('[--material-bottom-bar:calc(4rem+')
->and((string) $this->blade(' ', ['destinations' => scaffoldDestinations()]))
- ->not->toContain('data-tall')
+ ->not->toContain('data-md-tall')
->toContain('max-medium:[--material-bottom-bar:calc(4rem+');
});
it('lets the bar leave the window while the page scrolls down', function () {
expect((string) $this->blade(' ', ['destinations' => scaffoldDestinations()]))
- ->toContain('data-hide-on-scroll')
+ ->toContain('data-md-hide-on-scroll')
->toContain('x-data="materialNavigationBar"')
->and((string) $this->blade(' ', ['destinations' => scaffoldDestinations()]))
- ->not->toContain('data-hide-on-scroll');
+ ->not->toContain('data-md-hide-on-scroll');
});
it('exposes the rail that hides when collapsed', function () {
expect((string) $this->blade(' ', ['destinations' => scaffoldDestinations()]))
- ->toContain('data-hide-when-collapsed')
+ ->toContain('data-md-hide-when-collapsed')
->toContain("materialNavigationRail('adaptive', true)")
->and((string) $this->blade(' ', ['destinations' => scaffoldDestinations()]))
- ->not->toContain('data-hide-when-collapsed')
+ ->not->toContain('data-md-hide-when-collapsed')
->toContain("materialNavigationRail('adaptive', false)");
});
@@ -150,7 +150,7 @@ it('places each slot once', function () {
expect(substr_count($html, $slot))->toBe(1);
}
- expect($html)->toMatch('/data-navigation-rail-header.*BRAND.*FAB.*data-navigation-rail-footer.*FOOTER.*data-app-shell-actions.*ACTIONS.*APP BAR.*toMatch('/data-md-navigation-rail-header.*BRAND.*FAB.*data-md-navigation-rail-footer.*FOOTER.*data-app-shell-actions.*ACTIONS.*APP BAR.*toMatch('/data-app-shell-banner.*WINDOW BAR.*data-navigation-rail=.*toMatch('/data-app-shell-banner.*WINDOW BAR.*data-md-navigation-rail=.*and((string) $this->blade(' ', ['destinations' => scaffoldDestinations()]))
->not->toContain('data-app-shell-banner');
});