Plan step 39 (part 0): the step 38 review found <x-scaffold> and <x-tooltip> never referencing $attributes, so a caller's class and style were silently dropped. The scaffold's root is its single data-md-scaffold div; the tooltip's root, documented in its header, is the standalone wrapper it draws around a trigger (data-md-tooltip-anchor, merged with its own anchor-name) — passed an anchor instead, it renders only a popover fragment beside another component's root and takes nothing of the caller's. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
210 lines
12 KiB
PHP
210 lines
12 KiB
PHP
{{-- The scaffold: M3's structure of bars, rails and panes around the page, and the FAB, with
|
||
navigation that changes shape with the window. M3 and Compose call it the scaffold
|
||
(docs/reference/m3/foundations.md § Layout → Scaffold); before 2.0.0 it was `<x-app-shell>`.
|
||
|
||
<x-scaffold :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="md-type-title-lg">SealShare</a></x-slot:brand>
|
||
<x-slot:top>…the page's app bar…</x-slot:top>
|
||
<x-slot:fab><x-fab icon="add" tooltip="New share" /></x-slot:fab>
|
||
|
||
…the page…
|
||
</x-scaffold>
|
||
|
||
The navigation is M3's per breakpoint (docs/reference/m3/foundations.md § Layout and
|
||
foundations-supplement.md § Breakpoints), and only those four numbers:
|
||
|
||
- **Compact**, below `medium` (600px): 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 `medium`:
|
||
`<x-stack as="span" hide-from="medium"><x-button icon="menu" tooltip="Open navigation" x-on:click="$store.rail.show()" /></x-stack>`.
|
||
- **Medium** (600–839): the collapsed rail in the layout, 96px, and no bar; its menu button
|
||
opens it expanded over a scrim, since 256px beside the page would leave the page too little.
|
||
- **Expanded** (840–1199): a standard rail — in the layout, nothing covered — collapsed until
|
||
the menu button expands it in place.
|
||
- **Large and extra-large** (from 1200): the same standard rail, expanded to begin with
|
||
(`rail.default`), which is what M3 prefers once there is room.
|
||
|
||
From `expanded` the choice the menu button makes is remembered and applied before the first
|
||
paint (`$store.rail`, <x-theme-script>), so the rail never paints one width and snaps to the
|
||
other. A visitor who has chosen keeps that choice in both bands; one who never has gets the
|
||
breakpoint's own default.
|
||
|
||
`--material-margin` is M3's window margin — 16px on a compact window, 24px from `medium` — and
|
||
the content region is padded with it, so a page inside the scaffold writes no gutters of its own.
|
||
Something meant to reach the window's edges opts out with `margin-inline: calc(var(--material-margin) * -1)`.
|
||
|
||
Panes are `<x-pane>`, and two side by side are M3's canonical layouts from `expanded`,
|
||
`<x-list-detail>` and `<x-supporting-pane>`; inside the content region they draw no margin of
|
||
their own (resources/css/layout/scaffold.css sets `--md-layout-margin` there).
|
||
|
||
`destinations` is a list of arrays: `title`, `icon` (a Material Symbol), `url`, and optionally
|
||
`active` (by default: the URL is the page's; during a Livewire update request, the page the
|
||
component was rendered on rather than the update endpoint), `badge` (`true` for a dot, or a count), `badgeLabel` (what a screen reader hears for
|
||
the badge instead: "3 unread"),
|
||
`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: `banner` (a bar across the whole window, above the rail and the
|
||
page — M3's scaffold is bars, then rails, then panes), `brand` (beside the rail's menu button
|
||
while it is expanded), `rail-header` (under it: one `<x-fab label="…" icon="…">`, which the
|
||
rail morphs between a FAB and an extended FAB as it opens), `rail-footer` (at the foot of the
|
||
rail: footer destinations, an account), `actions`
|
||
(a row of icon buttons at the very foot, stacked in a column once the rail collapses to its
|
||
narrow width — the same selectors `navigation-rail.css` matches "collapsed" with, since
|
||
`resources/css/layout/scaffold.css` keys off the rail's own hooks rather than a breakpoint of
|
||
its own: a theme toggle, sign out), `top` (the page's own bar, above the page and *beside* the
|
||
rail), `fab` (the
|
||
page's floating action button, see below) 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; `tall-bar` picks M3's 80px navigation bar over the 64px one, and the bottom
|
||
offset every pinned thing reads follows it; `hide-bar-on-scroll` lets the bar leave the window
|
||
while the page scrolls down, and `--material-bottom-bar` goes down and comes back with it;
|
||
`hide-rail-when-collapsed` is M3's immersive configuration — from `expanded` the rail leaves
|
||
the layout when the menu button collapses it, rather than narrowing to 96px, so the page has
|
||
the whole window. The only way back is `$store.rail.show()`, so put a menu button in the app
|
||
bar at every width, not just below `medium`.
|
||
|
||
`banner` or `top` is a decision about what the bar belongs to: an application-wide bar — one
|
||
search, one account menu, the same on every page — spans the window and the rail starts under
|
||
it; a bar that titles the page belongs to the page, beside the rail. Put an app bar in one or
|
||
the other, never both. A banner that pins itself to the top of the window says how tall it is
|
||
— `style="--material-banner: 4rem"` on `<x-scaffold>` — so the rail sticks under it instead
|
||
of behind it.
|
||
|
||
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 scaffold;
|
||
on a compact window it, and a `fab` button, sit above the bottom bar through `--material-bottom-bar`:
|
||
the bar's 64px, the bottom safe area (`--material-safe-bottom`, else the device's inset) and
|
||
`--material-bottom-extra` (0px unless the application docks something, an offline banner, on
|
||
top of the bar).
|
||
|
||
`fab` places an `<x-fab>` as Compose's Scaffold places its floating action button: fixed at the
|
||
bottom-end corner, 16px from the window's edges on a compact window and 24px from `medium`
|
||
(M3's margins, foundations-supplement.md § Breakpoints; the FAB's own adaptive placement is
|
||
the lower trailing corner, components-actions-communication-containment.md § FAB), above the
|
||
navigation bar and the bottom safe area, and lifted over a snackbar while one shows —
|
||
M3: a snackbar appears above a FAB, never in front of or behind one. Compose raises the
|
||
snackbar over the FAB instead; here the snackbar host is one fixed element every page shares,
|
||
so the FAB is what moves (resources/css/layout/scaffold.css). Use it or `rail-header`'s FAB,
|
||
not both: M3 says not to show more than one FAB on a screen. It comes after the page's bar and
|
||
before the page in focus order, where M3 puts a FAB (§ FAB → Accessibility: "the FAB should be
|
||
prioritized in the page's focus order").
|
||
|
||
`overflow-x: clip` on the content region, below `expanded` only, is the backstop under every
|
||
page: `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). 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. The caller's `class` and `style` land on the root, `data-md-scaffold`
|
||
itself — the element `style="--material-banner: 4rem"` above sets a custom property on. --}}
|
||
|
||
@props([
|
||
'destinations' => [],
|
||
'label' => null,
|
||
'railWidth' => '16rem',
|
||
'tallBar' => false,
|
||
'hideBarOnScroll' => false,
|
||
'hideRailWhenCollapsed' => false,
|
||
])
|
||
|
||
@php
|
||
$label ??= __('Main');
|
||
$current = \Livewire\Livewire::isLivewireRequest() ? \Livewire\Livewire::originalUrl() : 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,
|
||
'badgeLabel' => filled($item['badgeLabel'] ?? null) ? (string) $item['badgeLabel'] : 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-md-scaffold {{ $attributes->only(['class', 'style']) }}>
|
||
<a href="#content" data-md-skip-link>{{ __('Skip to content') }}</a>
|
||
|
||
@isset($banner)
|
||
<div data-md-scaffold-banner>{{ $banner }}</div>
|
||
@endisset
|
||
|
||
<div data-md-scaffold-row>
|
||
<x-livewire-material::navigation-rail mode="adaptive" :label="$label" :width="$railWidth" :hide-when-collapsed="$hideRailWhenCollapsed">
|
||
@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-livewire-material::navigation-rail-section :label="$group->first()['section']">
|
||
@foreach ($group as $item)
|
||
<x-livewire-material::navigation-rail-item :label="$item['title']" :icon="$item['icon']" :link="$item['url']" :active="$item['active']" :badge="$item['badge']" :badge-label="$item['badgeLabel']" :no-wire-navigate="! $item['navigate']" />
|
||
@endforeach
|
||
</x-livewire-material::navigation-rail-section>
|
||
@else
|
||
@foreach ($group as $item)
|
||
<x-livewire-material::navigation-rail-item :label="$item['title']" :icon="$item['icon']" :link="$item['url']" :active="$item['active']" :badge="$item['badge']" :badge-label="$item['badgeLabel']" :no-wire-navigate="! $item['navigate']" />
|
||
@endforeach
|
||
@endif
|
||
@endforeach
|
||
|
||
@if (isset($railFooter) || isset($actions))
|
||
<x-slot:footer>
|
||
{{ $railFooter ?? '' }}
|
||
|
||
@isset($actions)
|
||
<div data-md-scaffold-actions>
|
||
{{ $actions }}
|
||
</div>
|
||
@endisset
|
||
</x-slot:footer>
|
||
@endif
|
||
</x-livewire-material::navigation-rail>
|
||
|
||
<div data-md-scaffold-content>
|
||
{{ $top ?? '' }}
|
||
|
||
@isset($fab)
|
||
<div data-md-scaffold-fab>{{ $fab }}</div>
|
||
@endisset
|
||
|
||
<main id="content" tabindex="-1" wire:transition.navigate>
|
||
{{ $slot }}
|
||
</main>
|
||
</div>
|
||
</div>
|
||
|
||
@if ($barItems->isNotEmpty())
|
||
<div data-md-scaffold-bar>
|
||
<x-livewire-material::navigation-bar :label="$label" :tall="$tallBar" :hide-on-scroll="$hideBarOnScroll">
|
||
@foreach ($barItems as $item)
|
||
<x-livewire-material::navigation-bar-item :label="$item['title']" :icon="$item['icon']" :link="$item['url']" :active="$item['active']" :badge="$item['badge']" :badge-label="$item['badgeLabel']" :no-wire-navigate="! $item['navigate']" />
|
||
@endforeach
|
||
</x-livewire-material::navigation-bar>
|
||
</div>
|
||
@endif
|
||
|
||
<x-livewire-material::toast />
|
||
</div>
|