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 <html>.
Completes Phase 8.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
This commit is contained in:
Andreas Reinhold / reini
2026-09-13 08:54:51 +02:00
co-authored by Claude Opus 5
parent f4be9848e2
commit ab692b66bb
24 changed files with 1995 additions and 5 deletions
+16
View File
@@ -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
+22
View File
@@ -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 `<html>` 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
(`<html data-rail>`, `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`),
@@ -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.
### `<x-app-shell>`
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 `<main id="content" wire:transition.navigate>` behind a skip link, and the snackbar host (do not add another `<x-toast />`). It needs `<x-theme-script />` in `<head>`.
```blade
<x-app-shell :destinations="[
['title' => 'Shares', 'icon' => 'folder_shared', 'url' => route('shares.index'), 'active' => request()->routeIs('shares.*'), 'badge' => $expiringCount],
['title' => 'Upload', 'icon' => 'upload', 'url' => route('upload')],
['title' => 'Users', 'icon' => 'group', 'url' => route('users'), 'section' => 'Admin', 'bar' => false],
]">
<x-slot:brand><a href="{{ route('home') }}" wire:navigate class="type-title-lg">SealShare</a></x-slot:brand>
<x-slot:rail-header>
<span class="rail-collapsed:hidden"><x-fab label="New share" icon="add" link="{{ route('upload') }}" /></span>
<span class="hidden rail-collapsed:inline-flex"><x-fab icon="add" tooltip-right="New share" link="{{ route('upload') }}" /></span>
</x-slot:rail-header>
<x-slot:rail-footer>
<x-navigation-rail-item label="Settings" icon="settings" link="{{ route('settings') }}" :active="request()->routeIs('settings')" />
</x-slot:rail-footer>
<x-slot:top>
{{-- the page's app bar; its menu button opens the modal rail on a phone --}}
<span class="sm:hidden"><x-button icon="menu" tooltip="Open navigation" x-on:click="$store.rail.show()" /></span>
</x-slot:top>
{{ $slot }}
</x-app-shell>
```
- `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.
### `<x-navigation-bar>`, `<x-navigation-bar-item>`
M3 Expressive's flexible navigation bar, for three to five destinations. It does not position itself; wrap it (`<x-app-shell>` does):
```blade
<div class="fixed inset-x-0 bottom-0 z-30 sm:hidden">
<x-navigation-bar>
<x-navigation-bar-item label="Shares" icon="folder_shared" link="{{ route('shares.index') }}" :active="request()->routeIs('shares.*')" badge="3" />
<x-navigation-bar-item label="Upload" icon="upload" link="{{ route('upload') }}" />
</x-navigation-bar>
</div>
```
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. `<x-navigation-bar>`: `label` ("Main"). `<x-navigation-bar-item>`: `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").
### `<x-navigation-rail>`, `<x-navigation-rail-item>`, `<x-navigation-rail-section>`
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
<div class="flex min-h-dvh">
<x-navigation-rail mode="collapsible">
<x-slot:brand><span class="type-title-lg">SealShare</span></x-slot:brand>
<x-slot:header><x-fab icon="add" tooltip-right="New share" /></x-slot:header>
<x-navigation-rail-item label="Shares" icon="folder_shared" link="{{ route('shares.index') }}" active badge="3" />
<x-navigation-rail-section label="Admin">
<x-navigation-rail-item label="Users" icon="group" link="{{ route('users') }}" />
</x-navigation-rail-section>
<x-slot:footer>
<x-navigation-rail-item label="Settings" icon="settings" link="{{ route('settings') }}" />
</x-slot:footer>
</x-navigation-rail>
<main class="min-w-0 flex-1">…</main>
</div>
```
- `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` (`<x-app-shell>`'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: `<span class="rail-collapsed:hidden">…expanded only…</span>`, `<span class="hidden rail-collapsed:inline-flex">…collapsed only…</span>`. Put the variant on a wrapper, never on a component. Nothing that shows while collapsed may be wider than 96px.
- `<x-navigation-rail-item>`: the same props as `<x-navigation-bar-item>`. `<x-navigation-rail-section label="…">`: 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`).
### `<x-app-bar>`
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 `<x-search>` in the slot). Props: `title`, `subtitle`, `heading` (`h1` default). Slots: `navigation` (leading icon button), `actions` (trailing icon buttons, avatar).
+584
View File
@@ -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 <nav>
* [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 <html data-rail> (set before the first paint by
* <x-theme-script>), 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
* (`<span class="rail-collapsed:hidden">`).
*/
@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;
}
}
}
+1
View File
@@ -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';
+1
View File
@@ -25,4 +25,5 @@ import './timepicker.js'
import './slider.js'
import './tabs.js'
import './app-bar.js'
import './navigation.js'
import './toolbar.js'
+141
View File
@@ -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. <x-theme-script> has already applied it before the first paint as
* <html data-rail="expanded|collapsed">, 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()
}
},
}))
})
@@ -0,0 +1,146 @@
{{-- The adaptive app shell: navigation that changes shape with the window, around the page.
<x-app-shell :destinations="[
['title' => 'Shares', 'icon' => 'folder_shared', 'url' => route('shares'), 'active' => request()->routeIs('shares*'), 'badge' => 3],
['title' => 'Upload', 'icon' => 'upload', 'url' => route('upload')],
['title' => 'Users', 'icon' => 'group', 'url' => route('users'), 'section' => 'Admin', 'bar' => false],
]">
<x-slot:brand><a href="/" wire:navigate class="type-title-lg">SealShare</a></x-slot:brand>
<x-slot:top>…the page's app bar…</x-slot:top>
…the page…
</x-app-shell>
- 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`:
`<span class="sm:hidden"><x-button icon="menu" tooltip="Open navigation" x-on:click="$store.rail.show()" /></span>`.
- `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`, <x-theme-script>).
`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 `<x-navigation-rail>` 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 `<main id="content">` with `wire:transition.navigate`, behind a skip link that is
the first thing a keyboard reaches. The snackbar host (`<x-toast />`) 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
<div
data-app-shell
@class([
'min-h-dvh bg-surface text-on-surface sm:flex',
'max-sm:[--material-bottom-bar:calc(4rem+env(safe-area-inset-bottom))]' => $barItems->isNotEmpty(),
])
>
<a
href="#content"
data-skip-link
class="sr-only focus:not-sr-only focus:fixed focus:start-4 focus:top-[calc(env(safe-area-inset-top)+1rem)] focus:z-[60] focus:rounded-corner-full focus:bg-inverse-surface focus:px-4 focus:py-2 focus:type-label-lg focus:text-inverse-on-surface focus:shadow-elevation-3 focus:outline-none"
>{{ __('Skip to content') }}</a>
<x-navigation-rail mode="adaptive" :label="$label" :width="$railWidth">
@isset($brand)
<x-slot:brand>{{ $brand }}</x-slot:brand>
@endisset
@isset($railHeader)
<x-slot:header>{{ $railHeader }}</x-slot:header>
@endisset
@foreach ($groups as $group)
@if ($group->first()['section'] !== null)
<x-navigation-rail-section :label="$group->first()['section']">
@foreach ($group as $item)
<x-navigation-rail-item :label="$item['title']" :icon="$item['icon']" :link="$item['url']" :active="$item['active']" :badge="$item['badge']" :no-wire-navigate="! $item['navigate']" />
@endforeach
</x-navigation-rail-section>
@else
@foreach ($group as $item)
<x-navigation-rail-item :label="$item['title']" :icon="$item['icon']" :link="$item['url']" :active="$item['active']" :badge="$item['badge']" :no-wire-navigate="! $item['navigate']" />
@endforeach
@endif
@endforeach
@if (isset($railFooter) || isset($actions))
<x-slot:footer>
{{ $railFooter ?? '' }}
@isset($actions)
<div data-app-shell-actions class="flex items-center gap-1 px-5 pt-2 rail-collapsed:flex-col">
{{ $actions }}
</div>
@endisset
</x-slot:footer>
@endif
</x-navigation-rail>
<div class="flex min-w-0 flex-1 flex-col">
{{ $top ?? '' }}
<main id="content" tabindex="-1" wire:transition.navigate class="min-w-0 flex-1 outline-none max-lg:overflow-x-clip max-sm:pb-(--material-bottom-bar)">
{{ $slot }}
</main>
</div>
@if ($barItems->isNotEmpty())
<div data-app-shell-bar class="fixed inset-x-0 bottom-0 z-30 sm:hidden">
<x-navigation-bar :label="$label">
@foreach ($barItems as $item)
<x-navigation-bar-item :label="$item['title']" :icon="$item['icon']" :link="$item['url']" :active="$item['active']" :badge="$item['badge']" :no-wire-navigate="! $item['navigate']" />
@endforeach
</x-navigation-bar>
</div>
@endif
<x-toast />
</div>
@@ -0,0 +1,69 @@
{{-- One destination in an `<x-navigation-bar>`.
<x-navigation-bar-item label="Inbox" icon="inbox" link="{{ route('inbox') }}" :active="request()->routeIs('inbox')" badge="12" />
`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 `<x-navigation-bar>`. --}}
@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 }}>
<span data-navigation-pill>
<span data-navigation-indicator>
<span class="relative inline-flex">
@if ($icon)
<x-icon :name="$icon" :filled="$active" class="size-6" />
@endif
@if ($dot)
<x-badge floating />
@elseif ($count)
<x-badge :value="$badge" max="999" floating />
@endif
</span>
</span>
<span data-navigation-label>{{ $label ?? $slot }}</span>
</span>
@if ($spoken !== null)
<span class="sr-only">, {{ $spoken }}</span>
@endif
</{{ $tag }}>
@@ -0,0 +1,40 @@
{{-- M3 Expressive's flexible navigation bar: three to five destinations at the bottom of a
compact window.
<div class="fixed inset-x-0 bottom-0 z-30 sm:hidden">
<x-navigation-bar>
<x-navigation-bar-item label="Inbox" icon="inbox" link="/inbox" active badge="12" />
<x-navigation-bar-item label="Starred" icon="star" link="/starred" />
<x-navigation-bar-item label="Sent" icon="send" link="/sent" />
</x-navigation-bar>
</div>
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. `<x-app-shell>` 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,
])
<nav aria-label="{{ $label ?? __('Main') }}" data-navigation-bar {{ $attributes }}>
<div data-navigation-bar-items>
{{ $slot }}
</div>
</nav>
@@ -0,0 +1,74 @@
{{-- One destination in an `<x-navigation-rail>`, drawn in whichever shape the rail has.
<x-navigation-rail-item label="Inbox" icon="inbox" link="{{ route('inbox') }}" :active="request()->routeIs('inbox')" badge="12" />
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 `<x-navigation-rail>`. 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 }}>
<span data-navigation-indicator>
<span class="relative inline-flex">
@if ($icon)
<x-icon :name="$icon" :filled="$active" class="size-6" />
@endif
@if ($dot)
<x-badge floating />
@elseif ($count)
<span class="hidden rail-collapsed:contents"><x-badge :value="$badge" max="999" floating /></span>
@endif
</span>
</span>
<span data-navigation-label>{{ $label ?? $slot }}</span>
@if ($count)
<span class="flex shrink-0 rail-collapsed:hidden"><x-badge :value="$badge" max="999" /></span>
@endif
@if ($spoken !== null)
<span class="sr-only">, {{ $spoken }}</span>
@endif
</{{ $tag }}>
@@ -0,0 +1,24 @@
{{-- A group of destinations in an `<x-navigation-rail>` under a heading.
<x-navigation-rail-section label="Labels">
<x-navigation-rail-item label="Travel" icon="label" link="/labels/travel" />
<x-navigation-rail-item label="Receipts" icon="label" link="/labels/receipts" />
</x-navigation-rail-section>
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
<div role="group" aria-labelledby="{{ $headingId }}" data-navigation-rail-section {{ $attributes }}>
<p id="{{ $headingId }}" data-navigation-rail-heading>{{ $label }}</p>
{{ $slot }}
</div>
@@ -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).
<div class="flex min-h-dvh">
<x-navigation-rail mode="collapsible">
<x-slot:brand><span class="type-title-lg">Mail</span></x-slot:brand>
<x-slot:header>
<x-fab icon="edit" tooltip-right="Compose" />
</x-slot:header>
<x-navigation-rail-item label="Inbox" icon="inbox" link="/inbox" active badge="12" />
<x-navigation-rail-section label="Labels">
<x-navigation-rail-item label="Travel" icon="label" link="/labels/travel" />
</x-navigation-rail-section>
<x-slot:footer>
<x-navigation-rail-item label="Settings" icon="settings" link="/settings" />
</x-slot:footer>
</x-navigation-rail>
<main class="min-w-0 flex-1">…</main>
</div>
`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 <x-theme-script>
before the first paint (<html data-rail>), 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 `<x-app-shell>` 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:
`<span class="rail-collapsed:hidden"><x-fab label="Compose" icon="edit" /></span>`
`<span class="hidden rail-collapsed:inline-flex"><x-fab icon="edit" tooltip-right="Compose" /></span>`.
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
<div
data-navigation-rail="{{ $mode }}"
@if ($interactive)
x-data="materialNavigationRail('{{ $mode }}')"
x-bind:data-open="open"
@endif
{{ $attributes->merge(['style' => "--navigation-rail-width: {$width}"]) }}
>
@if ($canOpen)
<div data-navigation-rail-scrim aria-hidden="true" x-on:click="$store.rail.hide()"></div>
@endif
<nav
data-navigation-rail-panel
aria-label="{{ $label ?? __('Main') }}"
@if ($canOpen)
x-trap.inert.noscroll="open"
x-on:keydown.escape.window="open && $store.rail.hide()"
@endif
>
@if ($menu || isset($brand) || isset($header))
<div data-navigation-rail-header>
@if ($menu || isset($brand))
<div @class(['flex w-full min-w-0 items-center gap-3 pe-5', 'ps-7' => $menu, 'ps-5' => ! $menu])>
@if ($menu)
<button
type="button"
data-navigation-rail-menu
aria-label="{{ $collapsedAtFirst ? __('Expand navigation') : __('Collapse navigation') }}"
aria-expanded="{{ $collapsedAtFirst ? 'false' : 'true' }}"
x-on:click="menu()"
x-bind:aria-label="expanded ? @js(__('Collapse navigation')) : @js(__('Expand navigation'))"
x-bind:aria-expanded="expanded.toString()"
class="state-layer focus-ring inline-flex size-10 shrink-0 cursor-pointer items-center justify-center rounded-corner-full text-on-surface-variant after:absolute after:top-1/2 after:left-1/2 after:size-12 after:-translate-x-1/2 after:-translate-y-1/2"
>
<span class="contents rail-collapsed:hidden"><x-icon name="menu_open" class="size-6" /></span>
<span class="hidden rail-collapsed:contents"><x-icon name="menu" class="size-6" /></span>
</button>
@endif
@isset($brand)
<div class="min-w-0 flex-1 rail-collapsed:hidden">{{ $brand }}</div>
@endisset
</div>
@endif
@isset($header)
<div {{ $header->attributes->class(['flex w-full flex-col items-start gap-2 px-5']) }}>{{ $header }}</div>
@endisset
</div>
@endif
<div data-navigation-rail-destinations>
{{ $slot }}
</div>
@isset($footer)
<div data-navigation-rail-footer {{ $footer->attributes }}>{{ $footer }}</div>
@endisset
</nav>
</div>
@@ -1,4 +1,5 @@
{{-- The theme, decided before the first paint. Include it in <head>, ahead of @vite.
{{-- The theme and the navigation rail's width, decided before the first paint. Include it in
<head>, 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.
<html> survives wire:navigate, so this only has to run on a full load. --}}
The rail rides along for the theme's reason: <html data-rail> 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 <html>
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));
</script>
+1
View File
@@ -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')
</main>
@endsection
+1 -1
View File
@@ -18,7 +18,7 @@
<a href="{{ route('livewire-material.showcase') }}" class="shrink-0 type-title-lg max-sm:hidden">Livewire Material</a>
<nav class="-my-2 flex min-w-0 flex-1 gap-x-4 overflow-x-auto py-2 whitespace-nowrap type-label-lg text-on-surface-variant [scrollbar-width:none]" aria-label="Sections">
@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)
<a href="#{{ $anchor }}" class="rounded-corner-xs hover:text-on-surface focus-ring">{{ $section }}</a>
@endforeach
</nav>
@@ -0,0 +1,114 @@
@php
$examples = [
'Navigation bar' => <<<'BLADE'
<div class="w-full space-y-6">
<div class="max-w-[25rem] space-y-2">
<p class="type-label-lg text-on-surface-variant">Compact: icon over label</p>
<div class="flex h-56 flex-col overflow-hidden rounded-corner-lg border border-outline-variant bg-surface">
<div class="flex-1 p-4 type-body-md text-on-surface-variant">The page</div>
<x-navigation-bar>
<x-navigation-bar-item label="Shares" icon="folder_shared" link="#navigation" no-wire-navigate active />
<x-navigation-bar-item label="Upload" icon="upload" link="#navigation" no-wire-navigate />
<x-navigation-bar-item label="Inbox" icon="inbox" link="#navigation" no-wire-navigate badge="3" />
<x-navigation-bar-item label="Account" icon="account_circle" link="#navigation" no-wire-navigate :badge="true" badge-label="Needs attention" />
</x-navigation-bar>
</div>
</div>
<div class="max-w-[50rem] space-y-2">
<p class="type-label-lg text-on-surface-variant">From 600px wide: icon beside label, centred</p>
<div class="overflow-x-auto rounded-corner-lg border border-outline-variant">
<div class="flex h-56 min-w-[37.5rem] flex-col bg-surface">
<div class="flex-1 p-4 type-body-md text-on-surface-variant">The page</div>
<x-navigation-bar>
<x-navigation-bar-item label="Shares" icon="folder_shared" link="#navigation" no-wire-navigate active />
<x-navigation-bar-item label="Upload" icon="upload" link="#navigation" no-wire-navigate />
<x-navigation-bar-item label="Inbox" icon="inbox" link="#navigation" no-wire-navigate badge="3" />
<x-navigation-bar-item label="Account" icon="account_circle" link="#navigation" no-wire-navigate />
</x-navigation-bar>
</div>
</div>
</div>
</div>
BLADE,
'Collapsed and expanded rails' => <<<'BLADE'
<div class="flex w-full flex-wrap items-start gap-6">
<div class="flex h-[36rem] overflow-hidden rounded-corner-lg border border-outline-variant">
<x-navigation-rail mode="collapsed" label="Collapsed example">
<x-slot:header>
<x-fab icon="edit" tooltip-right="Compose" />
</x-slot:header>
<x-navigation-rail-item label="Inbox" icon="inbox" link="#navigation" no-wire-navigate active badge="12" />
<x-navigation-rail-item label="Starred" icon="star" link="#navigation" no-wire-navigate />
<x-navigation-rail-item label="Sent" icon="send" link="#navigation" no-wire-navigate />
<x-navigation-rail-section label="Labels">
<x-navigation-rail-item label="Travel" icon="flight" link="#navigation" no-wire-navigate :badge="true" badge-label="New" />
<x-navigation-rail-item label="Receipts" icon="receipt_long" link="#navigation" no-wire-navigate />
</x-navigation-rail-section>
<x-slot:footer>
<x-navigation-rail-item label="Settings" icon="settings" link="#navigation" no-wire-navigate />
</x-slot:footer>
</x-navigation-rail>
</div>
<div class="flex h-[36rem] overflow-hidden rounded-corner-lg border border-outline-variant">
<x-navigation-rail mode="expanded" label="Expanded example">
<x-slot:header>
<x-fab label="Compose" icon="edit" />
</x-slot:header>
<x-navigation-rail-item label="Inbox" icon="inbox" link="#navigation" no-wire-navigate active badge="12" />
<x-navigation-rail-item label="Starred" icon="star" link="#navigation" no-wire-navigate />
<x-navigation-rail-item label="Sent" icon="send" link="#navigation" no-wire-navigate />
<x-navigation-rail-section label="Labels">
<x-navigation-rail-item label="Travel" icon="flight" link="#navigation" no-wire-navigate :badge="true" badge-label="New" />
<x-navigation-rail-item label="Receipts" icon="receipt_long" link="#navigation" no-wire-navigate />
</x-navigation-rail-section>
<x-slot:footer>
<x-navigation-rail-item label="Settings" icon="settings" link="#navigation" no-wire-navigate />
</x-slot:footer>
</x-navigation-rail>
</div>
</div>
BLADE,
'Collapsible rail' => <<<'BLADE'
<div class="flex h-[30rem] w-full overflow-hidden rounded-corner-lg border border-outline-variant">
<x-navigation-rail label="Collapsible example">
<x-slot:brand>
<span class="block truncate type-title-lg">Mail</span>
</x-slot:brand>
<x-slot:header>
<span class="rail-collapsed:hidden"><x-fab label="Compose" icon="edit" /></span>
<span class="hidden rail-collapsed:inline-flex"><x-fab icon="edit" tooltip-right="Compose" /></span>
</x-slot:header>
<x-navigation-rail-item label="Inbox" icon="inbox" link="#navigation" no-wire-navigate active badge="12" />
<x-navigation-rail-item label="Starred" icon="star" link="#navigation" no-wire-navigate />
<x-navigation-rail-item label="Sent" icon="send" link="#navigation" no-wire-navigate />
</x-navigation-rail>
<div class="min-w-0 flex-1 bg-surface-container-low p-6 type-body-md text-on-surface-variant">
The menu button collapses and expands the rail. The choice is remembered and applied before the next page paints every collapsible rail follows it, <code>&lt;x-app-shell&gt;</code>'s from <code>lg</code> too.
</div>
</div>
BLADE,
];
@endphp
<section id="navigation" class="scroll-mt-24 space-y-6">
<h2 class="type-headline-md">Navigation</h2>
<p class="max-w-3xl type-body-md text-on-surface-variant">
<code>&lt;x-navigation-bar&gt;</code> and <code>&lt;x-navigation-bar-item&gt;</code>, <code>&lt;x-navigation-rail&gt;</code>, <code>&lt;x-navigation-rail-item&gt;</code> and <code>&lt;x-navigation-rail-section&gt;</code>,
and <code>&lt;x-app-shell&gt;</code>, which puts them together: a bar below <code>sm</code>, a collapsed rail that opens as a modal to <code>lg</code>, a collapsible rail from there.
<a href="{{ route('livewire-material.shell') }}" class="link text-primary">Open the app shell</a> and change the window's width.
</p>
@foreach ($examples as $title => $code)
<x-showcase::example :$title :$code />
@endforeach
</section>
+87
View File
@@ -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
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="robots" content="noindex" />
<title>{{ $current['title'] }} · App shell · Livewire Material</title>
<x-theme-script />
@vite(config('livewire-material.showcase.vite'))
@livewireStyles
</head>
<body class="bg-surface font-sans text-on-surface antialiased">
<x-app-shell :destinations="$destinations">
<x-slot:brand>
<a href="{{ route('livewire-material.showcase') }}" class="block truncate rounded-corner-xs type-title-lg focus-ring">Livewire Material</a>
</x-slot:brand>
<x-slot:rail-header>
<span class="rail-collapsed:hidden"><x-fab label="Compose" icon="edit" x-on:click="materialToast('Compose opens here')" /></span>
<span class="hidden rail-collapsed:inline-flex"><x-fab icon="edit" tooltip-right="Compose" x-on:click="materialToast('Compose opens here')" /></span>
</x-slot:rail-header>
<x-slot:rail-footer>
<x-navigation-rail-item label="Settings" icon="settings" link="#settings" no-wire-navigate />
</x-slot:rail-footer>
<x-slot:actions>
<x-button icon="dark_mode" tooltip-right="Switch theme" x-data x-on:click="$store.theme.toggle()" />
<x-button icon="help" tooltip-right="Help" x-on:click="materialToast('Help opens here')" />
</x-slot:actions>
<x-slot:top>
<header class="sticky top-0 z-20 flex h-16 items-center gap-1 bg-surface px-1 pt-[env(safe-area-inset-top)] sm:px-4">
<span class="sm:hidden"><x-button icon="menu" tooltip="Open navigation" x-data x-on:click="$store.rail.show()" data-test="shell-menu" /></span>
<h1 class="min-w-0 flex-1 truncate px-3 type-title-lg sm:px-0">{{ $current['title'] }}</h1>
<x-button icon="search" tooltip="Search" />
</header>
</x-slot:top>
<div class="mx-auto w-full max-w-5xl space-y-4 px-4 pb-6 sm:px-6">
<p class="type-body-md text-on-surface-variant" data-test="shell-page">This is the {{ strtolower($current['title']) }} page.</p>
<x-list segmented>
@foreach (range(1, 14) as $index)
<x-list-item :title="$current['title'].' item '.$index" description="A line of supporting text for this item" :icon="$current['icon']" trailing="{{ $index }}h" wire:key="item-{{ $index }}" />
@endforeach
</x-list>
<div class="flex flex-wrap gap-2">
<x-button label="Show a toast" variant="tonal" x-data x-on:click="materialToast('Moved to archive', { action: { label: 'Undo', handler: () => {} } })" />
<x-button label="Back to the showcase" link="{{ route('livewire-material.showcase') }}#navigation" no-wire-navigate />
</div>
</div>
</x-app-shell>
@livewireScripts
</body>
</html>
+4
View File
@@ -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');
+175
View File
@@ -0,0 +1,175 @@
<?php
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Route;
const RAIL = "document.querySelector('[data-navigation-rail]')";
const RAIL_PANEL = "document.querySelector('[data-navigation-rail-panel]')";
const BOTTOM_BAR = "document.querySelector('[data-app-shell-bar]')";
function navigationReady(mixed $page): mixed
{
return $page->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 <html> 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'
<!DOCTYPE html>
<html>
<head>
<x-theme-script />
@vite(config('livewire-material.showcase.vite'))
@livewireStyles
</head>
<body>
<x-app-shell :destinations="[
['title' => 'Inbox', 'icon' => 'inbox', 'url' => '/rail-probe', 'active' => true],
['title' => 'Sent', 'icon' => 'send', 'url' => '/rail-probe'],
]">
<script>
window.firstPaint = {
rail: document.documentElement.getAttribute('data-rail'),
width: getComputedStyle(document.querySelector('[data-navigation-rail]')).width,
alpine: typeof window.Alpine,
};
</script>
<p>The page</p>
</x-app-shell>
@livewireScripts
</body>
</html>
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')");
});
+95
View File
@@ -0,0 +1,95 @@
<?php
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Route;
function shellDestinations(): array
{
return [
['title' => '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('<x-app-shell :destinations="$destinations">The page</x-app-shell>', ['destinations' => shellDestinations()]);
expect($html)
->toContain('data-navigation-rail="adaptive"')
->toContain('data-navigation-bar')
->toContain('<main id="content" tabindex="-1" wire:transition.navigate')
->toContain('max-lg:overflow-x-clip')
->toContain('The page')
->toContain('x-data="materialSnackbar"')
->toMatch('/<a\s+href="#content"\s+data-skip-link/')
->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('<x-app-shell :destinations="$destinations" />', ['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, '<a data-navigation-rail-item'))->toBe(4)
->and(substr_count($bar, '<a data-navigation-bar-item'))->toBe(2)
->and($bar)->toContain('Inbox')->toContain('Sent')->not->toContain('Travel')
->and($rail)->toContain('data-navigation-rail-heading>Labels</p>')
->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('<x-app-shell :destinations="$destinations" />', ['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('<x-app-shell :destinations="$destinations" />', ['destinations' => shellDestinations()]))
->toContain('max-sm:[--material-bottom-bar:calc(4rem+env(safe-area-inset-bottom))]')
->and((string) $this->blade('<x-app-shell :destinations="$destinations" />', ['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'
<x-app-shell :destinations="$destinations">
<x-slot:brand><span>BRAND</span></x-slot:brand>
<x-slot:rail-header><span>FAB</span></x-slot:rail-header>
<x-slot:rail-footer><span>FOOTER</span></x-slot:rail-footer>
<x-slot:actions><span>ACTIONS</span></x-slot:actions>
<x-slot:top><header>APP BAR</header></x-slot:top>
PAGE
</x-app-shell>
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.*<main.*PAGE/s');
});
it('serves the showcase\'s app shell pages', function () {
$html = $this->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();
});
@@ -0,0 +1,48 @@
<?php
it('draws a navigation landmark around its items', function () {
expect((string) $this->blade('<x-navigation-bar><x-navigation-bar-item label="Inbox" icon="inbox" link="/inbox" /></x-navigation-bar>'))
->toContain('<nav aria-label="Main" data-navigation-bar')
->toContain('data-navigation-bar-items')
->and((string) $this->blade('<x-navigation-bar label="Sections" />'))->toContain('aria-label="Sections"');
});
it('links a destination through wire:navigate unless told not to', function () {
expect((string) $this->blade('<x-navigation-bar-item label="Inbox" icon="inbox" link="/inbox" />'))
->toContain('<a data-navigation-bar-item')
->toContain('href="/inbox"')
->toContain('wire:navigate')
->and((string) $this->blade('<x-navigation-bar-item label="Inbox" icon="inbox" link="/inbox" no-wire-navigate />'))->not->toContain('wire:navigate')
->and((string) $this->blade('<x-navigation-bar-item label="Help" icon="help" link="https://example.com" external />'))
->toContain('target="_blank"')
->not->toContain('wire:navigate');
});
it('is a button without a link', function () {
expect((string) $this->blade('<x-navigation-bar-item label="Inbox" icon="inbox" wire:click="show(\'inbox\')" />'))
->toContain('<button data-navigation-bar-item')
->toContain('type="button"')
->not->toContain('href=');
});
it('marks the current destination with aria-current and the filled icon', function () {
$active = (string) $this->blade('<x-navigation-bar-item label="Starred" icon="star" link="/starred" active />');
$inactive = (string) $this->blade('<x-navigation-bar-item label="Starred" icon="star" link="/starred" />');
$symbol = fn (string $html): string => preg_match('/<svg.*?<\/svg>/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('<x-icon name="star" filled class="size-6" />')))
->and($symbol($inactive))->toBe(trim((string) $this->blade('<x-icon name="star" class="size-6" />')))
->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('<x-navigation-bar-item label="Inbox" icon="inbox" link="/inbox" badge="1200" />');
$dot = (string) $this->blade('<x-navigation-bar-item label="Inbox" icon="inbox" link="/inbox" :badge="true" badge-label="New mail" />');
expect($count)->toContain('999+')->toContain('<span class="sr-only">, 1200</span>')
->and($dot)->toContain('size-1.5')->toContain('<span class="sr-only">, New mail</span>')
->and((string) $this->blade('<x-navigation-bar-item label="Inbox" icon="inbox" link="/inbox" />'))->not->toContain('sr-only');
});
@@ -0,0 +1,77 @@
<?php
it('draws each mode, falling back to collapsible', function (string $mode, string $expected) {
expect((string) $this->blade('<x-navigation-rail :mode="$mode"><x-navigation-rail-item label="Inbox" icon="inbox" link="/inbox" /></x-navigation-rail>', ['mode' => $mode]))
->toContain("data-navigation-rail=\"{$expected}\"")
->toContain('<nav')
->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('<x-navigation-rail />');
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('<x-navigation-rail mode="modal" />'))
->toContain('aria-label="Expand navigation"')
->toContain('data-navigation-rail-scrim')
->toContain('x-trap.inert.noscroll="open"')
->and((string) $this->blade('<x-navigation-rail mode="expanded" />'))
->not->toContain('data-navigation-rail-menu')
->not->toContain('x-data')
->and((string) $this->blade('<x-navigation-rail mode="collapsible" :menu="false" />'))->not->toContain('data-navigation-rail-menu');
});
it('keeps the header and footer out of the scrolling destinations', function () {
$html = (string) $this->blade(<<<'BLADE'
<x-navigation-rail mode="expanded" width="20rem">
<x-slot:brand><span>Brand</span></x-slot:brand>
<x-slot:header><button>FAB</button></x-slot:header>
<x-navigation-rail-item label="Inbox" icon="inbox" link="/inbox" />
<x-slot:footer><button>Account</button></x-slot:footer>
</x-navigation-rail>
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('<x-navigation-rail-item label="Inbox" icon="inbox" link="/inbox" active badge="12" />');
expect($html)
->toContain('<a data-navigation-rail-item')
->toContain('wire:navigate')
->toContain('aria-current="page"')
->toContain('data-navigation-indicator')
->toContain('<span data-navigation-label>Inbox</span>')
->toContain('hidden rail-collapsed:contents')
->toContain('rail-collapsed:hidden')
->toContain('<span class="sr-only">, 12</span>')
->and(substr_count($html, '>12</span>'))->toBe(2)
->and((string) $this->blade('<x-navigation-rail-item label="Inbox" icon="inbox" link="/inbox" no-wire-navigate />'))->not->toContain('wire:navigate')
->and((string) $this->blade('<x-navigation-rail-item label="Inbox" icon="inbox" />'))->toContain('<button data-navigation-rail-item');
});
it('groups destinations under a heading that names the group', function () {
$html = (string) $this->blade('<x-navigation-rail-section label="Labels"><x-navigation-rail-item label="Travel" icon="flight" link="/travel" /></x-navigation-rail-section>');
preg_match('/aria-labelledby="([^"]+)"/', $html, $labelledBy);
expect($html)->toContain('role="group"')
->and($labelledBy[1] ?? null)->not->toBeNull()
->and($html)->toContain("<p id=\"{$labelledBy[1]}\" data-navigation-rail-heading>Labels</p>");
});
+22 -2
View File
@@ -2,7 +2,7 @@
it('follows the operating system until the visitor chooses, by default', function () {
$this->blade('<x-theme-script />')
->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('<x-theme-script />')
->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('<x-theme-script />')
->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('<x-theme-script />')
->assertSee('"rail":{"default":"collapsed","key":"sealshare-rail"}', false)
->assertSee("root.setAttribute('data-rail', rail);", false);
config(['livewire-material.rail.default' => 'sideways']);
$this->blade('<x-theme-script />')
->assertSee('"rail":{"default":"expanded","key":"sealshare-rail"}', false);
});
it('puts its attributes back on <html> when wire:navigate swaps the page', function () {
$this->blade('<x-theme-script />')
->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);
});