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);
+});