From ab692b66bbc62f56abf1432269753efa37ba46e9 Mon Sep 17 00:00:00 2001
From: Andreas Reinhold / reini
Date: Sun, 13 Sep 2026 08:54:51 +0200
Subject: [PATCH] Add the navigation bar, navigation rail and app shell
M3 Expressive's flexible navigation bar, the collapsed, expanded and
modal navigation rail with its state applied before the first paint,
and an adaptive app shell composing them. The head script now restores
the theme and rail attributes that wire:navigate strips from .
Completes Phase 8.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
---
config/livewire-material.php | 16 +
docs/plans/livewire-material.md | 22 +
.../livewire-material-development/SKILL.md | 78 +++
resources/css/components/navigation.css | 584 ++++++++++++++++++
resources/css/material.css | 1 +
resources/js/material.js | 1 +
resources/js/navigation.js | 141 +++++
.../views/components/app-shell.blade.php | 146 +++++
.../components/navigation-bar-item.blade.php | 69 +++
.../views/components/navigation-bar.blade.php | 40 ++
.../components/navigation-rail-item.blade.php | 74 +++
.../navigation-rail-section.blade.php | 24 +
.../components/navigation-rail.blade.php | 135 ++++
.../views/components/theme-script.blade.php | 42 +-
resources/views/showcase/index.blade.php | 1 +
resources/views/showcase/layout.blade.php | 2 +-
.../showcase/sections/navigation.blade.php | 114 ++++
resources/views/showcase/shell.blade.php | 87 +++
routes/showcase.php | 4 +
tests/Browser/NavigationTest.php | 175 ++++++
tests/Feature/Components/AppShellTest.php | 95 +++
.../Feature/Components/NavigationBarTest.php | 48 ++
.../Feature/Components/NavigationRailTest.php | 77 +++
tests/Feature/Components/ThemeScriptTest.php | 24 +-
24 files changed, 1995 insertions(+), 5 deletions(-)
create mode 100644 resources/css/components/navigation.css
create mode 100644 resources/js/navigation.js
create mode 100644 resources/views/components/app-shell.blade.php
create mode 100644 resources/views/components/navigation-bar-item.blade.php
create mode 100644 resources/views/components/navigation-bar.blade.php
create mode 100644 resources/views/components/navigation-rail-item.blade.php
create mode 100644 resources/views/components/navigation-rail-section.blade.php
create mode 100644 resources/views/components/navigation-rail.blade.php
create mode 100644 resources/views/showcase/sections/navigation.blade.php
create mode 100644 resources/views/showcase/shell.blade.php
create mode 100644 tests/Browser/NavigationTest.php
create mode 100644 tests/Feature/Components/AppShellTest.php
create mode 100644 tests/Feature/Components/NavigationBarTest.php
create mode 100644 tests/Feature/Components/NavigationRailTest.php
diff --git a/config/livewire-material.php b/config/livewire-material.php
index 6ddfc30c..eaf4f2f2 100644
--- a/config/livewire-material.php
+++ b/config/livewire-material.php
@@ -34,6 +34,22 @@ return [
'legacy_keys' => [],
],
+ /*
+ |--------------------------------------------------------------------------
+ | Navigation rail
+ |--------------------------------------------------------------------------
+ |
+ | Whether a collapsible navigation rail starts 'expanded' or 'collapsed'
+ | until the visitor toggles it. The head script applies the choice before
+ | the first paint, from localStorage under 'storage_key'.
+ |
+ */
+
+ 'rail' => [
+ 'default' => 'expanded',
+ 'storage_key' => 'material-rail',
+ ],
+
/*
|--------------------------------------------------------------------------
| Fields
diff --git a/docs/plans/livewire-material.md b/docs/plans/livewire-material.md
index 9e369782..43948aa8 100644
--- a/docs/plans/livewire-material.md
+++ b/docs/plans/livewire-material.md
@@ -524,6 +524,28 @@ worktrees. What changed from the step above:
the theme script), content region with `wire:transition.navigate`, snackbar host. Nothing
app-specific inside; apps pass destinations and extra chrome as slots.
+**Phase 8 is done (2026-09-13).** What changed from the steps above:
+
+- **App bars, toolbars, tabs, section nav, account menu and theme toggle were built in main; the
+ navigation bar, rail and app shell by an agent in its own worktree.**
+- **A medium or large app bar collapses without script moving anything**: the bar is sticky at a
+ negative top (its measured height less the 64px row, so a wrapped title still fits) and its row
+ is sticky at 0 inside it; script only reports `scrolled` and `collapsed`.
+- **The tab indicator moves in a view transition** (`view-transition-name` from a per-tablist
+ custom property, `view-transition-class` for the timing); every tab draws its own indicator, so it
+ is right before Alpine starts.
+- **Livewire 4.4's `wire:navigate` gives `` the next page's attributes and removes the rest**,
+ which dropped `data-theme` on every in-app navigation. The head script saves the theme and rail
+ attributes on `livewire:navigating` and puts them back in `onSwap`, before anything paints.
+- **The rail's collapsed state is applied before first paint** by the head script
+ (``, `rail.default`, `rail.storage_key`) and read by a `rail-collapsed:` variant;
+ `$store.rail` changes it. The shell draws no phone menu button: the app bar in `top` calls
+ `$store.rail.show()`.
+- **Playwright's locators are strict**: `assertAttribute` on a selector matching two elements fails.
+- **Tailwind only compiles classes it can see**: a class written only in a test probe (`h-[200vh]`)
+ does not exist in the Workbench build; use inline styles there.
+- **A Tailwind `@variant` nested under a pseudo-element or a `* +` selector compiles to broken CSS.**
+
### Phase 9 — Data, pages, mail
30. `table` (`.data-table`, descendant selectors, fine-pointer density, `position: relative`),
diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md
index a1b5e73b..449c3d07 100644
--- a/resources/boost/skills/livewire-material-development/SKILL.md
+++ b/resources/boost/skills/livewire-material-development/SKILL.md
@@ -535,6 +535,84 @@ M3 search bar that opens into a search view: docked under the bar from `sm`, ful
The docked view overlaps what is under it; never place a search inside an element with `overflow-hidden` (a card), which clips it.
+### ``
+
+The adaptive app shell, a whole layout's body: a navigation bar below `sm`, a collapsed rail that opens as a modal to `lg`, an expanded rail the visitor can collapse from `lg`, the page as `` behind a skip link, and the snackbar host (do not add another ` `). It needs ` ` in ``.
+
+```blade
+
+ SealShare
+
+
+
+
+
+
+
+
+ {{-- the page's app bar; its menu button opens the modal rail on a phone --}}
+
+
+
+ {{ $slot }}
+
+```
+
+- `destinations`: `title`, `icon`, `url`; optional `active` (default: the URL is the current one), `badge` (`true` for a dot, or a count), `section` (a heading in the rail, shown only while it is expanded; consecutive destinations with the same section are grouped), `bar` (default `true`; `false` keeps it out of the bottom bar — M3 wants three to five there), `navigate` (`false` for a full page load instead of `wire:navigate`).
+- Slots, each rendered once: `brand` (beside the rail's menu button, expanded only), `rail-header` (a FAB), `rail-footer` (pinned to the foot of the rail), `actions` (a row of icon buttons at the very foot, stacked when collapsed), `top` (the app bar, above the page at every width), and the page. `label` names the landmarks ("Main"); `rail-width` is the expanded width (`16rem`).
+- The rail is one element at every width: what is in it is also what a phone sees in the modal rail. Below `sm` nothing opens it but `$store.rail.show()`, so a page whose destinations are not all in the bar needs a menu button in its app bar (hidden from `sm`).
+- Below `sm` the shell sets `--material-bottom-bar`, so the snackbar and a `fab` button clear the bar; pad anything else you pin to the bottom with it.
+- The content region is `max-lg:overflow-x-clip`. Never make a page wrapper `overflow-x-hidden`: it turns the region into a scroll container and breaks every `sticky` inside.
+
+### ``, ``
+
+M3 Expressive's flexible navigation bar, for three to five destinations. It does not position itself; wrap it (`` does):
+
+```blade
+
+
+
+
+
+
+```
+
+64px in surface-container with the bottom safe area under it. Narrower than 600px the icon sits in a 56×32 indicator over the label; from 600px (the bar's own width) icon and label share a 40px pill and the items gather in the middle. ``: `label` ("Main"). ``: `label` / slot, `icon`, `link` (with `wire:navigate` unless `external` or `no-wire-navigate`; without a link it is a button), `active` (`aria-current="page"`, filled icon, secondary-container indicator), `badge` (`true` for a dot, a number for a count, 999+ at most), `badge-label` (what a screen reader hears instead of ", 3").
+
+### ``, ``, ``
+
+M3 Expressive's navigation rail: collapsed (96px, icon over label) or expanded (a 56px full-width pill, icon beside label, count at the end).
+
+```blade
+
+
+ SealShare
+
+
+
+
+
+
+
+
+
+
+
+
+ …
+
+```
+
+- `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: hidden and opened as a modal below `sm`, collapsed and opened as a modal to `lg`, collapsible from `lg`).
+- Props: `label` ("Main"), `width` (expanded width, `16rem`, held between 220 and 360px), `menu` (the menu button; on by default for `collapsible`, `modal`, `adaptive`). Slots: `brand` (beside the menu button, expanded only), `header` (a FAB), the destinations (the only part that scrolls), `footer`. In a flex row the rail sticks to the top of the viewport.
+- Anything 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.
+- ``: 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), `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`).
+
### ``
M3 Expressive top app bar, sticky by default (`:sticky="false"` to scroll away), turning surface-container once content scrolls under it. `variant`: `small` (default), `center`, `medium` and `large` (a big title that collapses into the row as the page scrolls — CSS sticky, no layout shift), `search` (put an `` in the slot). Props: `title`, `subtitle`, `heading` (`h1` default). Slots: `navigation` (leading icon button), `actions` (trailing icon buttons, avatar).
diff --git a/resources/css/components/navigation.css b/resources/css/components/navigation.css
new file mode 100644
index 00000000..3c83990c
--- /dev/null
+++ b/resources/css/components/navigation.css
@@ -0,0 +1,584 @@
+/*
+ * 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, the bottom safe area under it
+ * [data-navigation-bar-items] equal widths; centred from a 600px-wide 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 under `lg` 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
+ * (``).
+ */
+
+@custom-variant rail-collapsed {
+ &:where([data-navigation-rail='collapsed'], [data-navigation-rail='collapsed'] *) {
+ @slot;
+ }
+
+ &:where([data-rail='collapsed'] [data-navigation-rail='collapsible'], [data-rail='collapsed'] [data-navigation-rail='collapsible'] *) {
+ @slot;
+ }
+
+ &:where([data-navigation-rail='modal']:not([data-open]), [data-navigation-rail='modal']:not([data-open]) *) {
+ @slot;
+ }
+
+ @media (width < 64rem) {
+ &:where([data-navigation-rail='adaptive']:not([data-open]), [data-navigation-rail='adaptive']:not([data-open]) *) {
+ @slot;
+ }
+ }
+
+ @media (width >= 64rem) {
+ &:where([data-rail='collapsed'] [data-navigation-rail='adaptive'], [data-rail='collapsed'] [data-navigation-rail='adaptive'] *) {
+ @slot;
+ }
+ }
+}
+
+@layer components {
+ /* ---------------------------------------------------------------- the navigation bar */
+
+ [data-navigation-bar] {
+ container-type: inline-size;
+ padding-inline: env(safe-area-inset-left) env(safe-area-inset-right);
+ padding-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;
+ }
+
+ [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. */
+ @container (width >= 37.5rem) {
+ [data-navigation-bar-items] {
+ width: calc(10% * (var(--navigation-bar-count, 7) + 3));
+ min-width: fit-content;
+ max-width: 100%;
+ }
+
+ [data-navigation-bar-items]:has(> :last-child:nth-child(1)) { --navigation-bar-count: 1; }
+ [data-navigation-bar-items]:has(> :last-child:nth-child(2)) { --navigation-bar-count: 2; }
+ [data-navigation-bar-items]:has(> :last-child:nth-child(3)) { --navigation-bar-count: 3; }
+ [data-navigation-bar-items]:has(> :last-child:nth-child(4)) { --navigation-bar-count: 4; }
+ [data-navigation-bar-items]:has(> :last-child:nth-child(5)) { --navigation-bar-count: 5; }
+ [data-navigation-bar-items]:has(> :last-child:nth-child(6)) { --navigation-bar-count: 6; }
+
+ [data-navigation-bar-item] {
+ min-width: max-content;
+ }
+
+ [data-navigation-bar-item] [data-navigation-pill] {
+ position: relative;
+ isolation: isolate;
+ flex-direction: row;
+ height: 2.5rem;
+ padding-inline: 1rem;
+ font: var(--md-sys-typescale-label-lg);
+ letter-spacing: var(--md-sys-typescale-label-lg-tracking);
+ }
+
+ [data-navigation-bar-item] [data-navigation-label] {
+ padding-inline: 0;
+ }
+
+ [data-navigation-bar-item][data-active] {
+ color: var(--md-sys-color-on-secondary-container);
+ }
+
+ [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);
+ 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: 6rem;
+ }
+ }
+
+ /* 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 lg, and takes no room below sm. */
+ [data-navigation-rail='modal'] {
+ width: 6rem;
+ }
+
+ @media (width < 64rem) {
+ [data-navigation-rail='adaptive'] {
+ width: 6rem;
+ }
+ }
+
+ @media (width < 40rem) {
+ [data-navigation-rail='adaptive'] {
+ width: 0;
+ }
+ }
+
+ [data-navigation-rail-panel] {
+ position: sticky;
+ top: 0;
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ height: 100%;
+ max-height: 100dvh;
+ /* 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: 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);
+ }
+
+ /* 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);
+ }
+
+ /* Below sm 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 < 40rem) {
+ [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;
+ }
+ }
+ }
+ }
+
+ [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(env(safe-area-inset-top) + 2.75rem);
+ padding-bottom: 2rem;
+ }
+
+ [data-navigation-rail-panel] > [data-navigation-rail-destinations]:first-child {
+ padding-top: calc(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;
+ }
+
+ [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);
+ }
+
+ [data-navigation-bar-item][data-active] [data-navigation-indicator] {
+ color: var(--md-sys-color-on-secondary-container);
+ }
+
+ @container (width < 37.5rem) {
+ [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));
+ }
+ }
+
+ [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);
+ }
+
+ @container (width < 37.5rem) {
+ [data-navigation-bar-item] [data-navigation-pill]::before {
+ display: none;
+ }
+ }
+
+ @container (width >= 37.5rem) {
+ [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. */
+ @container (width < 37.5rem) {
+ [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-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;
+ }
+ }
+}
diff --git a/resources/css/material.css b/resources/css/material.css
index ff0ea197..4ffefa6c 100644
--- a/resources/css/material.css
+++ b/resources/css/material.css
@@ -30,6 +30,7 @@
@import './components/timepicker.css';
@import './components/tabs.css';
@import './components/app-bar.css';
+@import './components/navigation.css';
@import './components/toolbar.css';
@import './components/table.css';
diff --git a/resources/js/material.js b/resources/js/material.js
index ac2640e7..aa6a83f5 100644
--- a/resources/js/material.js
+++ b/resources/js/material.js
@@ -25,4 +25,5 @@ import './timepicker.js'
import './slider.js'
import './tabs.js'
import './app-bar.js'
+import './navigation.js'
import './toolbar.js'
diff --git a/resources/js/navigation.js b/resources/js/navigation.js
new file mode 100644
index 00000000..ffced60b
--- /dev/null
+++ b/resources/js/navigation.js
@@ -0,0 +1,141 @@
+/**
+ * Navigation: the rail's state, shared by every rail and menu button on the page.
+ *
+ * `$store.rail.collapsed` is the visitor's choice for a collapsible rail, remembered in
+ * localStorage. has already applied it before the first paint as
+ * , which is what the stylesheet keys on (the
+ * `rail-collapsed:` variant); the store starts from that attribute and writes it back.
+ *
+ * `$store.rail.open` is the modal rail: on a window too narrow for an expanded rail, a menu
+ * button opens it over a scrim (`show()`), and Escape, the scrim or leaving the page closes it
+ * (`hide()`). It is never remembered.
+ *
+ * `materialNavigationRail` is one rail's view of the store for its `mode` — see
+ * resources/views/components/navigation-rail.blade.php.
+ */
+const WIDE = '(min-width: 64rem)'
+
+/*
+ * The active indicator grows out of its centre when a page arrives through wire:navigate. The
+ * new page's indicator is new markup, so the only way to animate it is a starting style — and
+ * only while a navigation swaps the page in, or every full load would animate it too. The sheet
+ * is adopted as the navigation starts and dropped two frames after it ends; the transition itself
+ * is resources/css/components/navigation.css.
+ */
+const arriving = new CSSStyleSheet()
+
+arriving.replaceSync(`@starting-style {
+ :is([data-navigation-bar-item], [data-navigation-rail-item])[data-active],
+ :is([data-navigation-bar-item], [data-navigation-rail-item])[data-active] :is([data-navigation-indicator], [data-navigation-pill]) {
+ background-size: 0% 100%;
+ }
+}`)
+
+document.addEventListener('livewire:navigating', () => {
+ if (!document.adoptedStyleSheets.includes(arriving)) {
+ document.adoptedStyleSheets = [...document.adoptedStyleSheets, arriving]
+ }
+})
+
+document.addEventListener('livewire:navigated', () => {
+ requestAnimationFrame(() => requestAnimationFrame(() => {
+ document.adoptedStyleSheets = document.adoptedStyleSheets.filter((sheet) => sheet !== arriving)
+ }))
+})
+
+document.addEventListener('alpine:init', () => {
+ const root = document.documentElement
+
+ window.Alpine.store('rail', {
+ collapsed: root.dataset.rail === 'collapsed',
+ open: false,
+
+ toggle() {
+ this.set(!this.collapsed)
+ },
+
+ collapse() {
+ this.set(true)
+ },
+
+ expand() {
+ this.set(false)
+ },
+
+ set(collapsed) {
+ this.collapsed = collapsed
+ root.dataset.rail = collapsed ? 'collapsed' : 'expanded'
+
+ try {
+ localStorage.setItem(root.dataset.railKey || 'material-rail', root.dataset.rail)
+ } catch {
+ // Blocked storage: the rail still toggles, it just will not remember.
+ }
+ },
+
+ show() {
+ this.open = true
+ },
+
+ hide() {
+ this.open = false
+ },
+ })
+
+ // A destination chosen in the modal rail leaves the page; the next one starts with it shut.
+ document.addEventListener('livewire:navigating', () => window.Alpine.store('rail').hide())
+
+ window.Alpine.data('materialNavigationRail', (mode) => ({
+ wide: mode === 'adaptive' ? window.matchMedia(WIDE).matches : false,
+ query: null,
+ onWidth: null,
+
+ init() {
+ if (mode !== 'adaptive') {
+ return
+ }
+
+ // From lg the adaptive rail is a standard, collapsible rail: a modal left open while
+ // the window widens is shut, or its focus trap would hold a page that has no scrim.
+ this.query = window.matchMedia(WIDE)
+ this.onWidth = (event) => {
+ this.wide = event.matches
+
+ if (event.matches) {
+ this.$store.rail.hide()
+ }
+ }
+ this.query.addEventListener('change', this.onWidth)
+ },
+
+ destroy() {
+ this.query?.removeEventListener('change', this.onWidth)
+ },
+
+ /** Whether this rail expands over a scrim rather than in the layout. */
+ get modal() {
+ return mode === 'modal' || (mode === 'adaptive' && !this.wide)
+ },
+
+ get open() {
+ return this.modal && this.$store.rail.open
+ },
+
+ get expanded() {
+ if (this.open || mode === 'expanded') {
+ return true
+ }
+
+ return (mode === 'collapsible' || (mode === 'adaptive' && this.wide)) && !this.$store.rail.collapsed
+ },
+
+ /** The rail's own menu button: open or close the modal, or collapse and expand in place. */
+ menu() {
+ if (this.modal) {
+ this.$store.rail.open ? this.$store.rail.hide() : this.$store.rail.show()
+ } else {
+ this.$store.rail.toggle()
+ }
+ },
+ }))
+})
diff --git a/resources/views/components/app-shell.blade.php b/resources/views/components/app-shell.blade.php
new file mode 100644
index 00000000..336f16c6
--- /dev/null
+++ b/resources/views/components/app-shell.blade.php
@@ -0,0 +1,146 @@
+{{-- The adaptive app shell: navigation that changes shape with the window, around the page.
+
+
+ SealShare
+ …the page's app bar…
+
+ …the page…
+
+
+ - Below `sm`: a navigation bar with the destinations marked `bar`, pinned to the bottom.
+ Everything else is in the modal rail, which slides in when something calls
+ `$store.rail.show()` — put a menu button in the app bar for it, hidden from `sm`:
+ ` `.
+ - `sm` to `lg`: the collapsed rail, whose menu button opens it expanded, as a modal.
+ - From `lg`: the expanded rail, collapsed and expanded again by its menu button; the choice is
+ remembered and applied before the first paint (`$store.rail`, ).
+
+ `destinations` is a list of arrays: `title`, `icon` (a Material Symbol), `url`, and optionally
+ `active` (by default: the URL is the current one), `badge` (`true` for a dot, or a count),
+ `section` (a heading the destination is grouped under in the rail; only an expanded rail shows
+ it), `bar` (`false` keeps it out of the bottom bar; M3 wants three to five there) and
+ `navigate` (`false` for a full page load instead of `wire:navigate`).
+
+ Slots, each rendered once: `brand` (beside the rail's menu button while it is expanded),
+ `rail-header` (under it: a FAB — see `` for its two shapes), `rail-footer`
+ (at the foot of the rail: footer destinations, an account), `actions` (a row of icon buttons at
+ the very foot, stacked when the rail is collapsed: a theme toggle, sign out), `top` (the app
+ bar, above the page at every width) and the page itself. The rail is one element at every
+ width, so what is in it is also in the modal rail a phone opens. `label` names both navigation
+ landmarks ("Main"); `rail-width` is the expanded rail's width.
+
+ The page is `` with `wire:transition.navigate`, behind a skip link that is
+ the first thing a keyboard reaches. The snackbar host (` `) is part of the shell;
+ below `sm` it, and a `fab` button, sit above the bottom bar through `--material-bottom-bar`.
+
+ `max-lg:overflow-x-clip` on the content region is the backstop under every page, and it stays
+ `clip`: `overflow-x: hidden` would force `overflow-y` to `auto`, turn the region into a scroll
+ container and break every `position: sticky` inside it (an app bar, a list-detail pane). Below
+ `lg` only, so a wide window never clips what overhangs on purpose.
+
+ Nothing application-specific belongs in here: an app's destinations and chrome come in through
+ the props and slots. --}}
+
+@props([
+ 'destinations' => [],
+ 'label' => null,
+ 'railWidth' => '16rem',
+])
+
+@php
+ $label ??= __('Main');
+ $current = request()->url();
+
+ $items = collect($destinations)
+ ->filter(fn ($item): bool => is_array($item) && filled($item['title'] ?? null))
+ ->map(fn (array $item): array => [
+ 'title' => (string) $item['title'],
+ 'icon' => $item['icon'] ?? null,
+ 'url' => $item['url'] ?? null,
+ 'active' => (bool) ($item['active'] ?? (filled($item['url'] ?? null) && rtrim(url($item['url']), '/') === rtrim($current, '/'))),
+ 'badge' => $item['badge'] ?? null,
+ 'section' => filled($item['section'] ?? null) ? (string) $item['section'] : null,
+ 'bar' => ($item['bar'] ?? true) !== false,
+ 'navigate' => ($item['navigate'] ?? true) !== false,
+ ])
+ ->values();
+
+ $barItems = $items->where('bar', true)->values();
+
+ // Consecutive destinations under the same heading form one group, in the order given.
+ $groups = $items->chunkWhile(fn (array $item, int $key, $chunk): bool => $item['section'] === $chunk->last()['section']);
+@endphp
+
+ $barItems->isNotEmpty(),
+ ])
+>
+
{{ __('Skip to content') }}
+
+
+ @isset($brand)
+ {{ $brand }}
+ @endisset
+
+ @isset($railHeader)
+ {{ $railHeader }}
+ @endisset
+
+ @foreach ($groups as $group)
+ @if ($group->first()['section'] !== null)
+
+ @foreach ($group as $item)
+
+ @endforeach
+
+ @else
+ @foreach ($group as $item)
+
+ @endforeach
+ @endif
+ @endforeach
+
+ @if (isset($railFooter) || isset($actions))
+
+ {{ $railFooter ?? '' }}
+
+ @isset($actions)
+
+ {{ $actions }}
+
+ @endisset
+
+ @endif
+
+
+
+ {{ $top ?? '' }}
+
+
+ {{ $slot }}
+
+
+
+ @if ($barItems->isNotEmpty())
+
+
+ @foreach ($barItems as $item)
+
+ @endforeach
+
+
+ @endif
+
+
+
diff --git a/resources/views/components/navigation-bar-item.blade.php b/resources/views/components/navigation-bar-item.blade.php
new file mode 100644
index 00000000..e5f99c79
--- /dev/null
+++ b/resources/views/components/navigation-bar-item.blade.php
@@ -0,0 +1,69 @@
+{{-- One destination in an ``.
+
+
+
+ `link` renders an anchor with `wire:navigate` (unless `external` or `no-wire-navigate`);
+ without one it is a button, for a destination a Livewire action switches to. `active` marks
+ the current destination: `aria-current="page"`, the filled icon in on-secondary-container on
+ the secondary-container indicator, and the label in secondary (on-secondary-container inside
+ the medium bar's pill).
+
+ `badge` puts M3's badge on the icon: `true` for the small dot, a number or a few characters
+ for the large one (999+ at most). Screen readers hear a count after the label (", 12");
+ `badge-label` replaces it with words ("12 unread") and gives a dot something to say.
+
+ Values from NavigationBarTokens.kt and NavigationBarVerticalItemTokens.kt (androidx Compose
+ Material 3, Apache-2.0); see ``. --}}
+
+@props([
+ 'label' => null,
+ 'icon' => null,
+ 'link' => null,
+ 'external' => false,
+ 'noWireNavigate' => false,
+ 'active' => false,
+ 'badge' => null,
+ 'badgeLabel' => null,
+])
+
+@php
+ $isLink = filled($link);
+ $tag = $isLink ? 'a' : 'button';
+ $dot = $badge === true;
+ $count = ! $dot && $badge !== null && $badge !== false && $badge !== '';
+ $spoken = $badgeLabel ?? ($count ? $badge : null);
+
+ $attributes = $attributes->merge(array_filter([
+ 'href' => $isLink ? $link : null,
+ 'target' => $isLink && $external ? '_blank' : null,
+ 'rel' => $isLink && $external ? 'noopener' : null,
+ '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,
+ ], fn ($value): bool => $value !== null));
+@endphp
+
+<{{ $tag }} data-navigation-bar-item {{ $attributes }}>
+
+
+
+ @if ($icon)
+
+ @endif
+
+ @if ($dot)
+
+ @elseif ($count)
+
+ @endif
+
+
+
+ {{ $label ?? $slot }}
+
+
+ @if ($spoken !== null)
+ , {{ $spoken }}
+ @endif
+{{ $tag }}>
diff --git a/resources/views/components/navigation-bar.blade.php b/resources/views/components/navigation-bar.blade.php
new file mode 100644
index 00000000..874c64af
--- /dev/null
+++ b/resources/views/components/navigation-bar.blade.php
@@ -0,0 +1,40 @@
+{{-- M3 Expressive's flexible navigation bar: three to five destinations at the bottom of a
+ compact window.
+
+
+
+
+
+
+
+
+
+ The short bar, 64px tall in surface-container, with the bottom safe area added under it. On a
+ bar narrower than 600px (M3's compact window) each item is the icon in a 56×32 indicator over a
+ label-medium label, and the items share the width equally. From 600px (medium) icon and label
+ sit side by side in a 40px pill and the items gather in the middle, with the padding Compose's
+ Centered arrangement gives three to six items. Both follow the bar's own width (a container
+ query), so a bar in a narrow column keeps the compact items.
+
+ It does not position itself: wrap it in the element that pins it (`fixed inset-x-0 bottom-0`)
+ and hides it where a rail takes over. `` does both, and lifts the snackbar and a
+ `fab` button above it through `--material-bottom-bar`.
+
+ `label` names the landmark ("Main" by default).
+
+ Values from androidx Compose Material 3 (Apache-2.0), androidx-main
+ 27cf9a7d5788aa0f5f2d8b6699ce279560daf326: NavigationBarTokens.kt,
+ 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. --}}
+
+@props([
+ 'label' => null,
+])
+
+
+
+ {{ $slot }}
+
+
diff --git a/resources/views/components/navigation-rail-item.blade.php b/resources/views/components/navigation-rail-item.blade.php
new file mode 100644
index 00000000..182b434d
--- /dev/null
+++ b/resources/views/components/navigation-rail-item.blade.php
@@ -0,0 +1,74 @@
+{{-- One destination in an ``, drawn in whichever shape the rail has.
+
+
+
+ Expanded, it is a 56px full-width pill: the icon, the label-large label beside it and a count
+ at its end. Collapsed, the icon sits in a 56×32 indicator over a label-medium label (two lines
+ at most), with the count on the icon. The current destination (`active`) is
+ `aria-current="page"` with the filled icon on secondary-container, its label in secondary when
+ collapsed.
+
+ `link` renders an anchor with `wire:navigate` (unless `external` or `no-wire-navigate`);
+ without one it is a button. `badge`: `true` for M3's small dot on the icon, a number or a few
+ characters for the large badge (999+ at most). Screen readers hear a count after the label
+ (", 12"); `badge-label` replaces it with words ("12 unread") and gives a dot something to say.
+
+ Values from NavigationRailVerticalItemTokens.kt, NavigationRailHorizontalItemTokens.kt,
+ NavigationRailBaselineItemTokens.kt and NavigationRailColorTokens.kt (androidx Compose
+ Material 3, Apache-2.0); see ``. Compose's expanded item hugs its label; the
+ package draws the full-width pill, which leaves room for the count at the end. --}}
+
+@props([
+ 'label' => null,
+ 'icon' => null,
+ 'link' => null,
+ 'external' => false,
+ 'noWireNavigate' => false,
+ 'active' => false,
+ 'badge' => null,
+ 'badgeLabel' => null,
+])
+
+@php
+ $isLink = filled($link);
+ $tag = $isLink ? 'a' : 'button';
+ $dot = $badge === true;
+ $count = ! $dot && $badge !== null && $badge !== false && $badge !== '';
+ $spoken = $badgeLabel ?? ($count ? $badge : null);
+
+ $attributes = $attributes->merge(array_filter([
+ 'href' => $isLink ? $link : null,
+ 'target' => $isLink && $external ? '_blank' : null,
+ 'rel' => $isLink && $external ? 'noopener' : null,
+ '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,
+ ], fn ($value): bool => $value !== null));
+@endphp
+
+<{{ $tag }} data-navigation-rail-item {{ $attributes }}>
+
+
+ @if ($icon)
+
+ @endif
+
+ @if ($dot)
+
+ @elseif ($count)
+
+ @endif
+
+
+
+ {{ $label ?? $slot }}
+
+ @if ($count)
+
+ @endif
+
+ @if ($spoken !== null)
+ , {{ $spoken }}
+ @endif
+{{ $tag }}>
diff --git a/resources/views/components/navigation-rail-section.blade.php b/resources/views/components/navigation-rail-section.blade.php
new file mode 100644
index 00000000..7b0b9eb0
--- /dev/null
+++ b/resources/views/components/navigation-rail-section.blade.php
@@ -0,0 +1,24 @@
+{{-- A group of destinations in an `` under a heading.
+
+
+
+
+
+
+ The heading (title-small, on-surface-variant, in line with the icons) shows only while the
+ rail is expanded, as M3 draws section headers; collapsed, a little space sets the group apart.
+ It names the group for screen readers either way. --}}
+
+@props([
+ 'label',
+])
+
+@php
+ $headingId = 'navigation-section-'.substr(md5((string) $label), 0, 10);
+@endphp
+
+
+
{{ $label }}
+
+ {{ $slot }}
+
diff --git a/resources/views/components/navigation-rail.blade.php b/resources/views/components/navigation-rail.blade.php
new file mode 100644
index 00000000..035ddf9e
--- /dev/null
+++ b/resources/views/components/navigation-rail.blade.php
@@ -0,0 +1,135 @@
+{{-- M3 Expressive's navigation rail: destinations down the start edge of a medium or wider
+ window, collapsed (96px, icon over label) or expanded (icon beside label in a full-width pill).
+
+
+
+ Mail
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ …
+
+
+ `mode` says what decides its width:
+ - `collapsed` — always collapsed; `expanded` — always expanded.
+ - `collapsible` (the default) — the visitor's choice: expanded until the menu button collapses
+ it. The choice is `$store.rail`, remembered in localStorage and applied by
+ before the first paint (), so the rail never paints wide and snaps shut.
+ - `modal` — collapsed in the layout; the menu button (or `$store.rail.show()` from anywhere)
+ opens it expanded over a scrim, holding focus until Escape, the scrim, the menu button or
+ leaving the page closes it (Compose's ModalWideNavigationRail).
+ - `adaptive` — what `` uses: below `sm` nothing until `$store.rail.show()` slides
+ it in as a modal; from `sm` collapsed, opening as a modal; from `lg` collapsible.
+
+ Slots: `brand` beside the menu button, only while expanded; `header` under it — a FAB, drawn
+ as an extended FAB when expanded (`rail-collapsed:` below); the destinations in the default
+ slot, which alone scroll when the window is too short; `footer`, pinned to the foot. Header and
+ footer never scroll, so nothing in them is cut off by the scroller's edge.
+
+ Anything inside can take both shapes with the `rail-collapsed:` variant, which applies while
+ the rail is drawn collapsed for whatever reason:
+ ` `
+ ` `.
+ Nothing that shows while collapsed may be wider than 96px.
+
+ Props: `label` names the landmark ("Main"); `width` is the expanded width (`16rem`, held
+ between M3's 220 and 360dp); `menu` shows the menu button (by default for `collapsible`,
+ `modal` and `adaptive`). The rail does not scroll with the page: in a flex row it sticks to
+ the top of the viewport, as tall as the viewport at most.
+
+ Values from androidx Compose Material 3 (Apache-2.0), androidx-main
+ 27cf9a7d5788aa0f5f2d8b6699ce279560daf326: NavigationRailCollapsedTokens.kt,
+ NavigationRailExpandedTokens.kt, NavigationRailBaselineItemTokens.kt and
+ WideNavigationRail.kt under
+ https://github.com/androidx/androidx/tree/androidx-main/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3
+ — surface (surface-container and elevation 2 with a large inner corner when modal), 44px above
+ the header and 40px under it, 4px between collapsed items. The styles are
+ resources/css/components/navigation.css; the behaviour resources/js/navigation.js. --}}
+
+@props([
+ 'mode' => 'collapsible',
+ 'label' => null,
+ 'width' => '16rem',
+ 'menu' => null,
+])
+
+@php
+ $mode = in_array($mode, ['collapsed', 'expanded', 'collapsible', 'modal', 'adaptive'], true) ? $mode : 'collapsible';
+ $interactive = in_array($mode, ['collapsible', 'modal', 'adaptive'], true);
+ $canOpen = in_array($mode, ['modal', 'adaptive'], true);
+ $menu ??= $interactive;
+ $collapsedAtFirst = in_array($mode, ['collapsed', 'modal'], true);
+@endphp
+
+merge(['style' => "--navigation-rail-width: {$width}"]) }}
+>
+ @if ($canOpen)
+
+ @endif
+
+
+ @if ($menu || isset($brand) || isset($header))
+
+ @if ($menu || isset($brand))
+
$menu, 'ps-5' => ! $menu])>
+ @if ($menu)
+
+
+
+
+ @endif
+
+ @isset($brand)
+
{{ $brand }}
+ @endisset
+
+ @endif
+
+ @isset($header)
+
attributes->class(['flex w-full flex-col items-start gap-2 px-5']) }}>{{ $header }}
+ @endisset
+
+ @endif
+
+
+ {{ $slot }}
+
+
+ @isset($footer)
+ attributes }}>{{ $footer }}
+ @endisset
+
+
diff --git a/resources/views/components/theme-script.blade.php b/resources/views/components/theme-script.blade.php
index 1cea7baa..2df86b1d 100644
--- a/resources/views/components/theme-script.blade.php
+++ b/resources/views/components/theme-script.blade.php
@@ -1,4 +1,5 @@
-{{-- The theme, decided before the first paint. Include it in , ahead of @vite.
+{{-- The theme and the navigation rail's width, decided before the first paint. Include it in
+ , ahead of @vite.
It reads the visitor's choice from localStorage (`livewire-material.theme.storage_key`):
`light`, `dark` or `system`, falling back to `theme.default`. `system` follows the
@@ -10,15 +11,29 @@
JSON-encoded (maryUI's `"dark"`) — is adopted once and removed. Nothing is written for a
visitor who never chose, so changing `theme.default` later reaches them too.
- survives wire:navigate, so this only has to run on a full load. --}}
+ The rail rides along for the theme's reason: is `expanded` or `collapsed`
+ (`livewire-material.rail.storage_key`, falling back to `rail.default`), and a collapsible
+ rail's width is CSS keyed on it (the `rail-collapsed:` variant). Set any later, a collapsed
+ rail would paint wide and snap shut on every load. `$store.rail` (resources/js/navigation.js)
+ changes it.
+
+ wire:navigate swaps the body, merges the head without running this again, and gives
+ the next page's attributes — which the server rendered without any of these, so Livewire
+ removes them. They are put back as the new page is swapped in (`onSwap`, in the same task,
+ before anything paints), so this only has to run on a full load. --}}
@php
$theme = config('livewire-material.theme');
+ $rail = config('livewire-material.rail');
$settings = [
'default' => in_array($theme['default'] ?? null, ['light', 'dark', 'system'], true) ? $theme['default'] : 'system',
'key' => $theme['storage_key'] ?? 'material-theme',
'legacy' => array_values($theme['legacy_keys'] ?? []),
+ 'rail' => [
+ 'default' => ($rail['default'] ?? null) === 'collapsed' ? 'collapsed' : 'expanded',
+ 'key' => $rail['storage_key'] ?? 'material-rail',
+ ],
];
@endphp
@@ -28,8 +43,15 @@
var media = window.matchMedia('(prefers-color-scheme: dark)');
var valid = function (value) { return value === 'light' || value === 'dark' || value === 'system'; };
var choice = settings.default;
+ var rail = settings.rail.default;
try {
+ var storedRail = localStorage.getItem(settings.rail.key);
+
+ if (storedRail === 'collapsed' || storedRail === 'expanded') {
+ rail = storedRail;
+ }
+
var stored = localStorage.getItem(settings.key);
if (valid(stored)) {
@@ -58,8 +80,24 @@
root.setAttribute('data-theme-key', settings.key);
root.setAttribute('data-theme-choice', choice);
+ root.setAttribute('data-rail-key', settings.rail.key);
+ root.setAttribute('data-rail', rail);
apply();
media.addEventListener('change', apply);
+
+ document.addEventListener('livewire:navigating', function (event) {
+ var kept = ['data-theme', 'data-theme-choice', 'data-theme-key', 'data-rail', 'data-rail-key'].map(function (name) {
+ return [name, root.getAttribute(name)];
+ });
+
+ event.detail.onSwap(function () {
+ kept.forEach(function (attribute) {
+ if (attribute[1] !== null) {
+ root.setAttribute(attribute[0], attribute[1]);
+ }
+ });
+ });
+ });
})(@json($settings));
diff --git a/resources/views/showcase/index.blade.php b/resources/views/showcase/index.blade.php
index af8c3308..2fc7ad34 100644
--- a/resources/views/showcase/index.blade.php
+++ b/resources/views/showcase/index.blade.php
@@ -24,6 +24,7 @@
@include('livewire-material::showcase.sections.pickers')
@include('livewire-material::showcase.sections.timepickers')
@include('livewire-material::showcase.sections.bars')
+ @include('livewire-material::showcase.sections.navigation')
@include('livewire-material::showcase.sections.data')
@endsection
diff --git a/resources/views/showcase/layout.blade.php b/resources/views/showcase/layout.blade.php
index 05be5076..0f619705 100644
--- a/resources/views/showcase/layout.blade.php
+++ b/resources/views/showcase/layout.blade.php
@@ -18,7 +18,7 @@
Livewire Material
- @foreach (['colour' => 'Colour', 'type' => 'Type', 'shape' => 'Shape', 'elevation' => 'Elevation', 'motion' => 'Motion', 'icons' => 'Icons', 'buttons' => 'Buttons', 'menus' => 'Menus', 'communication' => 'Communication', 'progress' => 'Progress', 'containment' => 'Containment', 'carousel' => 'Carousel', 'fields' => 'Fields', 'chips' => 'Chips', 'sliders' => 'Sliders', 'pickers' => 'Date pickers', 'timepickers' => 'Time pickers', 'bars' => 'Bars', 'data' => 'Data'] as $anchor => $section)
+ @foreach (['colour' => 'Colour', 'type' => 'Type', 'shape' => 'Shape', 'elevation' => 'Elevation', 'motion' => 'Motion', 'icons' => 'Icons', 'buttons' => 'Buttons', 'menus' => 'Menus', 'communication' => 'Communication', 'progress' => 'Progress', 'containment' => 'Containment', 'carousel' => 'Carousel', 'fields' => 'Fields', 'chips' => 'Chips', 'sliders' => 'Sliders', 'pickers' => 'Date pickers', 'timepickers' => 'Time pickers', 'bars' => 'Bars', 'navigation' => 'Navigation', 'data' => 'Data'] as $anchor => $section)
{{ $section }}
@endforeach
diff --git a/resources/views/showcase/sections/navigation.blade.php b/resources/views/showcase/sections/navigation.blade.php
new file mode 100644
index 00000000..796f711b
--- /dev/null
+++ b/resources/views/showcase/sections/navigation.blade.php
@@ -0,0 +1,114 @@
+@php
+ $examples = [
+ 'Navigation bar' => <<<'BLADE'
+
+
+
Compact: icon over label
+
+
+
+
+
From 600px wide: icon beside label, centred
+
+
+
+ BLADE,
+ 'Collapsed and expanded rails' => <<<'BLADE'
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ BLADE,
+ 'Collapsible rail' => <<<'BLADE'
+
+
+
+ Mail
+
+
+
+
+
+
+
+
+
+
+
+
+
+ The menu button collapses and expands the rail. The choice is remembered and applied before the next page paints — every collapsible rail follows it, <x-app-shell>'s from lg too.
+
+
+ BLADE,
+ ];
+@endphp
+
+
diff --git a/resources/views/showcase/shell.blade.php b/resources/views/showcase/shell.blade.php
new file mode 100644
index 00000000..5a39e20d
--- /dev/null
+++ b/resources/views/showcase/shell.blade.php
@@ -0,0 +1,87 @@
+{{-- The app shell, as a whole page: /material/shell and its sibling pages, so the rail, the bar, the
+ modal rail and wire:navigate between pages can be tried at any window width. --}}
+
+@php
+ $page ??= 'inbox';
+
+ $pages = [
+ 'inbox' => ['title' => 'Inbox', 'icon' => 'inbox', 'badge' => 12],
+ 'starred' => ['title' => 'Starred', 'icon' => 'star'],
+ 'sent' => ['title' => 'Sent', 'icon' => 'send'],
+ 'drafts' => ['title' => 'Drafts', 'icon' => 'draft', 'badge' => true],
+ 'travel' => ['title' => 'Travel', 'icon' => 'flight', 'section' => 'Labels', 'bar' => false],
+ 'receipts' => ['title' => 'Receipts', 'icon' => 'receipt_long', 'section' => 'Labels', 'bar' => false],
+ ];
+
+ $destinations = collect($pages)
+ ->map(fn (array $destination, string $key): array => $destination + [
+ 'url' => route('livewire-material.shell', $key === 'inbox' ? [] : ['page' => $key]),
+ 'active' => $key === $page,
+ ])
+ ->values()
+ ->all();
+
+ $current = $pages[$page];
+@endphp
+
+
+
+
+
+
+
+
+ {{ $current['title'] }} · App shell · Livewire Material
+
+
+
+ @vite(config('livewire-material.showcase.vite'))
+ @livewireStyles
+
+
+
+
+ Livewire Material
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $current['title'] }}
+
+
+
+
+
+
This is the {{ strtolower($current['title']) }} page.
+
+
+ @foreach (range(1, 14) as $index)
+
+ @endforeach
+
+
+
+
+
+
+
+
+
+ @livewireScripts
+
+
diff --git a/routes/showcase.php b/routes/showcase.php
index 7a8a30e0..ba7efe13 100644
--- a/routes/showcase.php
+++ b/routes/showcase.php
@@ -7,3 +7,7 @@ Route::view('/', 'livewire-material::showcase.index')->name('showcase');
Route::get('symbols.json', [ShowcaseSymbolController::class, 'index'])->name('symbols');
Route::get('symbols/{style}/{name}.svg', [ShowcaseSymbolController::class, 'show'])->name('symbol');
+
+Route::view('shell/{page?}', 'livewire-material::showcase.shell')
+ ->whereIn('page', ['inbox', 'starred', 'sent', 'drafts', 'travel', 'receipts'])
+ ->name('shell');
diff --git a/tests/Browser/NavigationTest.php b/tests/Browser/NavigationTest.php
new file mode 100644
index 00000000..74f26b8d
--- /dev/null
+++ b/tests/Browser/NavigationTest.php
@@ -0,0 +1,175 @@
+waitForEvent('networkidle')
+ ->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
+}
+
+function shellPage(int $width = 1280, int $height = 900, string $path = '/material/shell'): mixed
+{
+ return navigationReady(visit($path)->resize($width, $height));
+}
+
+/**
+ * A shell whose page records, while the document is still being parsed — after the rail's
+ * markup and the stylesheet, before Alpine exists — what and the rail looked like.
+ * That is what the first paint shows.
+ */
+function firstPaintProbe(): void
+{
+ Route::middleware('web')->get('/rail-probe', fn () => Blade::render(<<<'BLADE'
+
+
+
+
+ @vite(config('livewire-material.showcase.vite'))
+ @livewireStyles
+
+
+
+
+ The page
+
+ @livewireScripts
+
+
+ BLADE));
+}
+
+function railWidth(int $pixels): string
+{
+ return 'Math.round('.RAIL.".getBoundingClientRect().width) === {$pixels}";
+}
+
+it('shows the navigation bar on a phone and marks the current destination', function () {
+ shellPage(400, 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.".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'")
+ ->assertNoJavaScriptErrors();
+});
+
+it('collapses the rail on a medium window, icon over label', function () {
+ shellPage(800)
+ ->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');
+});
+
+it('expands the rail from lg, and keeps it collapsed across a reload from the first paint', function () {
+ firstPaintProbe();
+
+ $page = shellPage(1280, 900, '/rail-probe')
+ ->assertScript(railWidth(256))
+ ->assertScript("window.eval('window.firstPaint.width') === '256px'")
+ ->assertAttribute('[data-navigation-rail-menu]', 'aria-expanded', 'true');
+
+ $page->click('[data-navigation-rail-menu]')
+ ->assertScript("document.documentElement.getAttribute('data-rail') === 'collapsed'")
+ ->assertScript("localStorage.getItem('material-rail') === 'collapsed'")
+ ->assertAttribute('[data-navigation-rail-menu]', 'aria-expanded', 'false')
+ ->assertScript(railWidth(96));
+
+ navigationReady($page->refresh())
+ ->assertScript("window.eval('window.firstPaint.alpine') === 'undefined'")
+ ->assertScript("window.eval('window.firstPaint.rail') === 'collapsed'")
+ ->assertScript("window.eval('window.firstPaint.width') === '96px'")
+ ->assertScript(railWidth(96));
+});
+
+it('opens the modal rail from its menu button below lg, holding focus until Escape or the scrim', function () {
+ $page = shellPage(800);
+
+ // 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')")
+ ->assertScript('Math.round('.RAIL_PANEL.'.getBoundingClientRect().width) === 256')
+ ->assertScript(railWidth(96))
+ ->assertScript("getComputedStyle(document.querySelector('[data-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');
+
+ $page->keys(':focus', 'Escape')
+ ->assertScript('! '.RAIL.".hasAttribute('data-open')")
+ ->assertScript("document.getElementById('content').closest('[aria-hidden=\"true\"]') === null")
+ ->assertScript("document.activeElement === document.querySelector('[data-navigation-rail-menu]')");
+
+ $page->keys('[data-navigation-rail-menu]', 'Enter')
+ ->assertScript(RAIL.".hasAttribute('data-open')");
+
+ $page->click('[data-navigation-rail-scrim]')
+ ->assertScript('! '.RAIL.".hasAttribute('data-open')")
+ ->assertScript("localStorage.getItem('material-rail') === null");
+});
+
+it('slides the modal rail in on a phone from the app bar\'s menu button', function () {
+ $page = shellPage(400, 860);
+
+ $page->click('@shell-menu')
+ ->assertScript(RAIL.".hasAttribute('data-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('getComputedStyle('.RAIL_PANEL.").display === 'none'");
+});
+
+it('keeps the rail and the theme through wire:navigate and moves aria-current', function () {
+ $page = shellPage(1280);
+
+ $page->click('[data-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"]')
+ ->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")
+ ->assertNoJavaScriptErrors();
+});
+
+it('skips to the content', function () {
+ $page = shellPage();
+
+ $page->assertScript("document.querySelector('a[href], button, input, select, textarea, [tabindex]:not([tabindex=\"-1\"])').matches('[data-skip-link]')");
+
+ // Safari and WebKit leave links out of the Tab order, so focus it directly.
+ $page->script("document.querySelector('[data-skip-link]').focus()");
+ $page->assertScript("getComputedStyle(document.querySelector('[data-skip-link]')).position === 'fixed'")
+ ->assertVisible('[data-skip-link]');
+
+ $page->keys('[data-skip-link]', 'Enter')
+ ->assertScript("location.hash === '#content'")
+ ->assertScript("document.activeElement === document.getElementById('content')");
+});
diff --git a/tests/Feature/Components/AppShellTest.php b/tests/Feature/Components/AppShellTest.php
new file mode 100644
index 00000000..5c6f9742
--- /dev/null
+++ b/tests/Feature/Components/AppShellTest.php
@@ -0,0 +1,95 @@
+ 'Inbox', 'icon' => 'inbox', 'url' => '/inbox', 'badge' => 4],
+ ['title' => 'Sent', 'icon' => 'send', 'url' => '/sent', 'active' => true],
+ ['title' => 'Travel', 'icon' => 'flight', 'url' => '/travel', 'section' => 'Labels', 'bar' => false],
+ ['title' => 'Receipts', 'icon' => 'receipt_long', 'url' => '/receipts', 'section' => 'Labels', 'bar' => false, 'navigate' => false],
+ ];
+}
+
+it('draws the adaptive rail, the bar, the content region and the snackbar host', function () {
+ $html = (string) $this->blade('The page ', ['destinations' => shellDestinations()]);
+
+ expect($html)
+ ->toContain('data-navigation-rail="adaptive"')
+ ->toContain('data-navigation-bar')
+ ->toContain('toContain('max-lg:overflow-x-clip')
+ ->toContain('The page')
+ ->toContain('x-data="materialSnackbar"')
+ ->toMatch('/and(strpos($html, 'data-skip-link'))->toBeLessThan(strpos($html, 'data-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' => shellDestinations()]);
+
+ $rail = substr($html, strpos($html, 'data-navigation-rail='), strpos($html, 'data-app-shell-bar') - strpos($html, 'data-navigation-rail='));
+ $bar = substr($html, strpos($html, 'data-app-shell-bar'));
+
+ 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)->toMatch('/href="\/receipts"(?![^>]*wire:navigate)/')
+ ->and(substr_count($html, 'aria-current="page"'))->toBe(2);
+});
+
+it('marks the destination at the current URL when none says it is active', function () {
+ Route::get('/shell-probe/inbox', fn () => Blade::render(' ', ['destinations' => [
+ ['title' => 'Inbox', 'icon' => 'inbox', 'url' => url('/shell-probe/inbox')],
+ ['title' => 'Sent', 'icon' => 'send', 'url' => url('/shell-probe/sent')],
+ ]]));
+
+ $html = $this->withoutVite()->get('/shell-probe/inbox')->assertOk()->getContent();
+
+ expect($html)->toMatch('/href="[^"]*\/shell-probe\/inbox"[^>]*aria-current="page"/')
+ ->and(substr_count($html, 'aria-current="page"'))->toBe(2);
+});
+
+it('lifts the snackbar above the bar only when there is a bar', function () {
+ expect((string) $this->blade(' ', ['destinations' => shellDestinations()]))
+ ->toContain('max-sm:[--material-bottom-bar:calc(4rem+env(safe-area-inset-bottom))]')
+ ->and((string) $this->blade(' ', ['destinations' => [['title' => 'Inbox', 'icon' => 'inbox', 'url' => '/inbox', 'bar' => false]]]))
+ ->not->toContain('data-app-shell-bar')
+ ->not->toContain('--material-bottom-bar:');
+});
+
+it('places each slot once', function () {
+ $html = (string) $this->blade(<<<'BLADE'
+
+ BRAND
+ FAB
+ FOOTER
+ ACTIONS
+
+ PAGE
+
+ BLADE, ['destinations' => shellDestinations()]);
+
+ foreach (['BRAND', 'FAB', 'FOOTER', 'ACTIONS', 'APP BAR', 'PAGE'] as $slot) {
+ 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.*withoutVite()
+ ->get('/material/shell/starred')
+ ->assertOk()
+ ->assertSee('This is the starred page.')
+ ->getContent();
+
+ expect($html)->toMatch('/href="[^"]*\/material\/shell\/starred"[^>]*aria-current="page"/');
+
+ $this->withoutVite()->get('/material/shell')->assertOk()->assertSee('This is the inbox page.');
+ $this->withoutVite()->get('/material/shell/elsewhere')->assertNotFound();
+});
diff --git a/tests/Feature/Components/NavigationBarTest.php b/tests/Feature/Components/NavigationBarTest.php
new file mode 100644
index 00000000..25a05cad
--- /dev/null
+++ b/tests/Feature/Components/NavigationBarTest.php
@@ -0,0 +1,48 @@
+blade(' '))
+ ->toContain('toContain('data-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('href="/inbox"')
+ ->toContain('wire:navigate')
+ ->and((string) $this->blade(' '))->not->toContain('wire:navigate')
+ ->and((string) $this->blade(' '))
+ ->toContain('target="_blank"')
+ ->not->toContain('wire:navigate');
+});
+
+it('is a button without a link', function () {
+ expect((string) $this->blade(' '))
+ ->toContain('toContain('type="button"')
+ ->not->toContain('href=');
+});
+
+it('marks the current destination with aria-current and the filled icon', function () {
+ $active = (string) $this->blade(' ');
+ $inactive = (string) $this->blade(' ');
+
+ $symbol = fn (string $html): string => preg_match('//s', $html, $svg) ? $svg[0] : '';
+
+ expect($active)->toContain('aria-current="page"')->toContain('data-active')
+ ->and($inactive)->not->toContain('aria-current')->not->toContain('data-active')
+ ->and($symbol($active))->toBe(trim((string) $this->blade(' ')))
+ ->and($symbol($inactive))->toBe(trim((string) $this->blade(' ')))
+ ->and($symbol($active))->not->toBe($symbol($inactive));
+});
+
+it('badges the icon with a dot or a count that screen readers hear', function () {
+ $count = (string) $this->blade(' ');
+ $dot = (string) $this->blade(' ');
+
+ expect($count)->toContain('999+')->toContain(', 1200 ')
+ ->and($dot)->toContain('size-1.5')->toContain(', New mail ')
+ ->and((string) $this->blade(' '))->not->toContain('sr-only');
+});
diff --git a/tests/Feature/Components/NavigationRailTest.php b/tests/Feature/Components/NavigationRailTest.php
new file mode 100644
index 00000000..cc571388
--- /dev/null
+++ b/tests/Feature/Components/NavigationRailTest.php
@@ -0,0 +1,77 @@
+blade(' ', ['mode' => $mode]))
+ ->toContain("data-navigation-rail=\"{$expected}\"")
+ ->toContain('toContain('aria-label="Main"');
+})->with([
+ ['collapsed', 'collapsed'],
+ ['expanded', 'expanded'],
+ ['collapsible', 'collapsible'],
+ ['modal', 'modal'],
+ ['adaptive', 'adaptive'],
+ ['sideways', 'collapsible'],
+]);
+
+it('gives the collapsible, modal and adaptive rails a menu button and the store', function () {
+ $collapsible = (string) $this->blade(' ');
+
+ expect($collapsible)
+ ->toContain('x-data="materialNavigationRail(\'collapsible\')"')
+ ->toContain('data-navigation-rail-menu')
+ ->toContain('aria-label="Collapse navigation"')
+ ->toContain('aria-expanded="true"')
+ ->not->toContain('data-navigation-rail-scrim')
+ ->not->toContain('x-trap')
+ ->and((string) $this->blade(' '))
+ ->toContain('aria-label="Expand navigation"')
+ ->toContain('data-navigation-rail-scrim')
+ ->toContain('x-trap.inert.noscroll="open"')
+ ->and((string) $this->blade(' '))
+ ->not->toContain('data-navigation-rail-menu')
+ ->not->toContain('x-data')
+ ->and((string) $this->blade(' '))->not->toContain('data-navigation-rail-menu');
+});
+
+it('keeps the header and footer out of the scrolling destinations', function () {
+ $html = (string) $this->blade(<<<'BLADE'
+
+ Brand
+ FAB
+
+ Account
+
+ BLADE);
+
+ expect($html)
+ ->toContain('--navigation-rail-width: 20rem')
+ ->toMatch('/data-navigation-rail-header.*Brand.*FAB.*data-navigation-rail-destinations.*Inbox.*data-navigation-rail-footer.*Account/s');
+});
+
+it('draws a destination in both shapes, with its count on the icon and at the end', function () {
+ $html = (string) $this->blade(' ');
+
+ expect($html)
+ ->toContain('toContain('wire:navigate')
+ ->toContain('aria-current="page"')
+ ->toContain('data-navigation-indicator')
+ ->toContain('Inbox ')
+ ->toContain('hidden rail-collapsed:contents')
+ ->toContain('rail-collapsed:hidden')
+ ->toContain(', 12 ')
+ ->and(substr_count($html, '>12'))->toBe(2)
+ ->and((string) $this->blade(' '))->not->toContain('wire:navigate')
+ ->and((string) $this->blade(' '))->toContain('blade(' ');
+
+ preg_match('/aria-labelledby="([^"]+)"/', $html, $labelledBy);
+
+ expect($html)->toContain('role="group"')
+ ->and($labelledBy[1] ?? null)->not->toBeNull()
+ ->and($html)->toContain("Labels
");
+});
diff --git a/tests/Feature/Components/ThemeScriptTest.php b/tests/Feature/Components/ThemeScriptTest.php
index dc619453..0dc0bd8a 100644
--- a/tests/Feature/Components/ThemeScriptTest.php
+++ b/tests/Feature/Components/ThemeScriptTest.php
@@ -2,7 +2,7 @@
it('follows the operating system until the visitor chooses, by default', function () {
$this->blade(' ')
- ->assertSee('({"default":"system","key":"material-theme","legacy":[]})', false);
+ ->assertSee('({"default":"system","key":"material-theme","legacy":[],"rail":{"default":"expanded","key":"material-rail"}})', false);
});
it('takes the application\'s default, key and legacy keys', function () {
@@ -13,7 +13,7 @@ it('takes the application\'s default, key and legacy keys', function () {
]]);
$this->blade(' ')
- ->assertSee('({"default":"dark","key":"sealshare-theme","legacy":["mary-theme"]})', false);
+ ->assertSee('"default":"dark","key":"sealshare-theme","legacy":["mary-theme"]', false);
});
it('falls back to the operating system for a default it does not know', function () {
@@ -22,3 +22,23 @@ it('falls back to the operating system for a default it does not know', function
$this->blade(' ')
->assertSee('"default":"system"', false);
});
+
+it('starts a collapsible rail as the application says, expanded otherwise', function () {
+ config(['livewire-material.rail' => ['default' => 'collapsed', 'storage_key' => 'sealshare-rail']]);
+
+ $this->blade(' ')
+ ->assertSee('"rail":{"default":"collapsed","key":"sealshare-rail"}', false)
+ ->assertSee("root.setAttribute('data-rail', rail);", false);
+
+ config(['livewire-material.rail.default' => 'sideways']);
+
+ $this->blade(' ')
+ ->assertSee('"rail":{"default":"expanded","key":"sealshare-rail"}', false);
+});
+
+it('puts its attributes back on when wire:navigate swaps the page', function () {
+ $this->blade(' ')
+ ->assertSee("document.addEventListener('livewire:navigating'", false)
+ ->assertSee("['data-theme', 'data-theme-choice', 'data-theme-key', 'data-rail', 'data-rail-key']", false)
+ ->assertSee('event.detail.onSwap(', false);
+});