Files
livewire-material/resources/views/components/scaffold.blade.php
T
Andreas Reinhold / reiniandClaude Sonnet 5 ed93222d22 Draw the scaffold without Tailwind
Plan step 36 (navigation group, third batch): <x-scaffold>'s own
styling moves into resources/css/layout/scaffold.css (the content
region, the bar and rail row, the banner, the actions row and its
rail-collapsed column layout, --material-bottom-bar and
--material-margin publishing, the skip link) alongside step 35's FAB
and content-margin rules already there. Every data-app-shell* hook
becomes data-md-scaffold-* (data-app-shell-bar, -actions, -banner);
the skip link is data-md-skip-link; data-app-shell itself is dropped,
data-md-scaffold already named the root.

The actions row's rail-collapsed:flex-col is written out branch for
branch as the navigation rail's own rewrite did for its internal
parts: the three width-independent conditions in one :where() group,
the four width-gated ones each in their own @media block. With that
gone, resources/css/tailwind.css's rail-collapsed custom-variant
shim (its last use) is removed; tailwind.css now carries only
tokens/theme.css and tokens/utilities.css, which the showcase still
needs until step 38.

navigation-bar.css's hide-on-scroll rule reading --material-bottom-bar
stayed unlayered only because <x-scaffold> published that variable
through a Tailwind utility, which no layered rule could outrank; now
scaffold.css sets it itself in material.layout, a layer
navigation-bar.css's own material.components always beats, so the
rule moves into the layer and the file fits one
@layer material.components block like every other navigation
stylesheet. navigation-bar rejoins NavigationStylesheetsTest.php's
dataset and NavigationBarTest.php's own duplicate shape test is
retired in favour of it.

Browser tests added (docs/plans/material-3-browser-tests.md): the
scaffold's FAB dropping the bar's own height once hide-bar-on-scroll
slides it away, at the trailing edge in a right-to-left document, and
clearing a safe area an application sets on its inline-end and bottom
edges.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 00:37:22 +02:00

209 lines
12 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{{-- 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** (600839): 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** (8401199): 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. --}}
@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>
<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>