A modal side sheet, bottom sheet or the modal rail closed on any Escape the window heard, so a dialog opened from a sheet, a menu, select list or searchable choice inside one, a sheet opened from a sheet, and a sheet inside a dialog each closed two layers on one press. And a dialog or a second sheet rendered elsewhere on the page sat inside the `aria-hidden` the first sheet's `x-trap.inert` put on its siblings, so a screen reader could not read it, while the sheet's focus trap took every Tab inside the dialog back to the inert sheet. resources/js/layers.js adds `x-layer`, on each of those panels beside its `x-trap`. An Escape is the panel's only when nothing has handled it and the nearest open layer around its target is the panel itself - not an open dialog, popover or customizable select, nor a panel inside it; the panel claims it with preventDefault(), which also keeps a dialog around it from cancelling, and dispatches `material-escape`, which the views close on. A panel that opens lifts `aria-hidden` from its own ancestors and puts it back on close only where a panel still open hides them; materialShowModal() does the same for `<x-modal>`, whose new `x-trap.noautofocus.noreturn` pauses the sheet's focus trap while it is open and moves no focus of its own. The searchable choice, the search view and the supporting pane's sheet now preventDefault() the Escape they act on, so the dialog or sheet around them stays. Four browser tests stack the layers every way above and fail without the change in Chrome. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
193 lines
11 KiB
PHP
193 lines
11 KiB
PHP
{{-- 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).
|
||
|
||
<x-row align="stretch" style="min-height: 100dvh">
|
||
<x-navigation-rail mode="collapsible">
|
||
<x-slot:brand><span class="md-type-title-lg">Mail</span></x-slot:brand>
|
||
<x-slot:header>
|
||
<x-fab label="Compose" icon="edit" />
|
||
</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 style="flex: 1; min-width: 0">…</main>
|
||
</x-row>
|
||
|
||
`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-scaffold>` uses, one rail per M3 window size class: on a compact
|
||
window (below `medium`, 600px) nothing until `$store.rail.show()` slides it in as a modal;
|
||
at `medium` (600–839) collapsed in the layout, opening as a modal; at `expanded` (840–1199)
|
||
a standard rail, collapsed until its menu button expands it in place; from `large` (1200)
|
||
the same standard rail, expanded to begin with. A visitor who has used the menu button keeps
|
||
that choice in both standard bands.
|
||
|
||
Slots: `brand` beside the menu button, only while expanded; `header` under it — one
|
||
`<x-fab label="…" icon="…">`, which the rail morphs: the label's width springs open and shut
|
||
with the rail, so the FAB becomes an extended FAB and back rather than one being swapped for
|
||
the other, and its label names it at both widths. It also rests flat, because M3 puts a FAB
|
||
nested in another component at elevation 0, not the 3 a standalone one has. Then the
|
||
destinations in the default slot, which alone scroll when the window is too short, and
|
||
`footer`, pinned to the foot. Header and footer never scroll, so nothing in them is cut off by
|
||
the scroller's edge.
|
||
|
||
Anything else inside takes both shapes from the rail's value: the rail publishes M3's two
|
||
(Compose's WideNavigationRailValue) as `--md-navigation-rail-value`, `collapsed` or
|
||
`expanded`, from the first paint and in step with the rail's own items, and every descendant
|
||
inherits it, so an application's CSS asks a style query instead of repeating the conditions:
|
||
|
||
@container style(--md-navigation-rail-value: collapsed) {
|
||
.account-summary { display: none; }
|
||
}
|
||
|
||
A rail open over a scrim reads `expanded`; outside a rail the property is unset and neither
|
||
value matches. Style queries on a custom property need Chrome 111, Safari 18 or Firefox 151.
|
||
The package's own rules write the conditions out (resources/css/components/navigation-rail.css
|
||
lists them), and tests/Feature/Components/NavigationRailTest.php keeps each copy to the same
|
||
conditions. Nothing that shows while collapsed may be wider than 96px.
|
||
|
||
Props: `label` names the landmark ("Main"); `width` is the expanded width (`256px`, held
|
||
between M3's 220 and 360dp) — or the word `narrow`, M3's other *collapsed* width
|
||
(NavigationRailCollapsedTokens.NarrowContainerWidth, 80px against the default 96), where the
|
||
items are their icons alone because no label fits under a 56px indicator at that width; the
|
||
labels stay in the accessibility tree, since they are what name the destinations, and a
|
||
narrow rail expands to the default 256px; `align` is `top` (the default) or `center`, which
|
||
puts the destinations at the rail's vertical centre — M3 prefers that on a tablet, for reach —
|
||
while the menu button, the brand and the FAB stay at the top and the footer at the foot, as M3
|
||
asks; more destinations than fit go back to the top rather than out of reach above the
|
||
scroller; `hide-when-collapsed` is M3's other expanded behaviour, for a `collapsible` or
|
||
`adaptive` rail: collapsing it takes it out of the layout altogether instead of narrowing it
|
||
to 96px, and it comes back expanded over a scrim when something calls `$store.rail.show()` —
|
||
a menu button in the app bar, which is the only way back, so put one there. Its own menu
|
||
button then docks it into the layout again. Not below `medium` for a collapsible rail, nor at
|
||
`medium` for an adaptive one: there it is the window and not the visitor that collapses a
|
||
rail, and M3's collapsed rail may never hide; `menu` shows the menu button (by default for `collapsible`,
|
||
`modal` and `adaptive`); `divider` draws M3's optional vertical divider on the edge the page
|
||
is on — which is also what M3 asks for when a page scrolls underneath a fixed rail; `fill`
|
||
(`false`) drops the container colour for a transparent rail over the page's own background,
|
||
which M3 allows as long as the items keep a 3:1 contrast against what is behind them. A rail
|
||
open over a scrim keeps its fill and drops the divider whatever those say: it is a surface
|
||
over the page then. As it closes it keeps `data-md-closing` (`sheet` while the panel slides off
|
||
the window, `scrim` while only the scrim fades) until the exit has run, which is what holds it
|
||
on screen in Firefox, where `display` cannot transition (resources/js/navigation.js). 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-rail.css; the behaviour resources/js/navigation.js. --}}
|
||
|
||
@props([
|
||
'mode' => 'collapsible',
|
||
'label' => null,
|
||
'width' => '256px',
|
||
'align' => 'top',
|
||
'hideWhenCollapsed' => false,
|
||
'menu' => null,
|
||
'divider' => false,
|
||
'fill' => true,
|
||
])
|
||
|
||
@php
|
||
$mode = in_array($mode, ['collapsed', 'expanded', 'collapsible', 'modal', 'adaptive'], true) ? $mode : 'collapsible';
|
||
$interactive = in_array($mode, ['collapsible', 'modal', 'adaptive'], true);
|
||
// Only a rail that has a collapsed *and* an expanded state of its own can hide instead of
|
||
// narrowing; a `modal` one is already over the page, and the two fixed modes mean what they say.
|
||
$hideWhenCollapsed = $hideWhenCollapsed && in_array($mode, ['collapsible', 'adaptive'], true);
|
||
$canOpen = in_array($mode, ['modal', 'adaptive'], true) || $hideWhenCollapsed;
|
||
$menu ??= $interactive;
|
||
$collapsedAtFirst = in_array($mode, ['collapsed', 'modal'], true);
|
||
$narrow = $width === 'narrow';
|
||
$width = $narrow ? '256px' : $width;
|
||
$centred = $align === 'center';
|
||
@endphp
|
||
|
||
<div
|
||
data-md-navigation-rail="{{ $mode }}"
|
||
@if ($narrow) data-md-width="narrow" @endif
|
||
@if ($centred) data-md-align="center" @endif
|
||
@if ($hideWhenCollapsed) data-md-hide-when-collapsed @endif
|
||
@if ($divider) data-md-divider @endif
|
||
@unless ($fill) data-md-fill="false" @endunless
|
||
@if ($interactive)
|
||
x-data="materialNavigationRail('{{ $mode }}', {{ $hideWhenCollapsed ? 'true' : 'false' }})"
|
||
x-bind:data-md-open="open"
|
||
x-bind:data-md-closing="closing"
|
||
x-effect="settle(open)"
|
||
@endif
|
||
{{ $attributes->merge(['style' => "--navigation-rail-width: {$width}"]) }}
|
||
>
|
||
@if ($canOpen)
|
||
<div data-md-navigation-rail-scrim aria-hidden="true" x-on:click="$store.rail.hide()"></div>
|
||
@endif
|
||
|
||
<nav
|
||
data-md-navigation-rail-panel
|
||
aria-label="{{ $label ?? __('Main') }}"
|
||
@if ($canOpen)
|
||
x-trap.inert.noscroll="open"
|
||
x-layer="open"
|
||
x-on:material-escape="$store.rail.hide()"
|
||
@endif
|
||
>
|
||
@if ($menu || isset($brand) || isset($header))
|
||
<div data-md-navigation-rail-header>
|
||
@if ($menu || isset($brand))
|
||
<div data-md-navigation-rail-menu-row>
|
||
@if ($menu)
|
||
<button
|
||
type="button"
|
||
data-md-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="md-state-layer md-focus-ring md-touch-target"
|
||
>
|
||
<span data-md-navigation-rail-menu-collapse-icon><x-livewire-material::icon name="menu_open" /></span>
|
||
<span data-md-navigation-rail-menu-expand-icon><x-livewire-material::icon name="menu" /></span>
|
||
</button>
|
||
@endif
|
||
|
||
@isset($brand)
|
||
<div data-md-navigation-rail-brand>{{ $brand }}</div>
|
||
@endisset
|
||
</div>
|
||
@endif
|
||
|
||
@isset($header)
|
||
<div data-md-navigation-rail-fab-row {{ $header->attributes }}>{{ $header }}</div>
|
||
@endisset
|
||
</div>
|
||
@endif
|
||
|
||
<div data-md-navigation-rail-destinations>
|
||
{{ $slot }}
|
||
</div>
|
||
|
||
@isset($footer)
|
||
<div data-md-navigation-rail-footer {{ $footer->attributes }}>{{ $footer }}</div>
|
||
@endisset
|
||
</nav>
|
||
</div>
|