Rename the app shell to the scaffold and give it a FAB slot
Plan step 35: <x-app-shell> becomes <x-scaffold>, M3's and Compose's name for the structure of bars, rails and panes, with no alias; every package view, the showcase, the tests, the skills, README and UPGRADE follow. Its markup and Tailwind classes stay for the navigation rewrite. The new fab slot places the FAB as Compose's Scaffold does - bottom-end, 16px from the edges below medium and 24px from it, clear of the navigation bar and of a snackbar on screen (resources/css/layout/scaffold.css) - and the content region tells a pane inside that the margin is already drawn. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
co-authored by
Claude Opus 5
parent
acd6ba21ae
commit
0013e0154d
@@ -0,0 +1,217 @@
|
||||
{{-- 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="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`:
|
||||
`<span class="medium:hidden"><x-button icon="menu" tooltip="Open navigation" x-on:click="$store.rail.show()" /></span>`.
|
||||
- **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 `-mx-(--material-margin)`.
|
||||
|
||||
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 when the rail is collapsed: 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").
|
||||
|
||||
`max-expanded: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
|
||||
`expanded` 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',
|
||||
'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-app-shell
|
||||
data-md-scaffold
|
||||
@class([
|
||||
'flex min-h-dvh flex-col bg-surface text-on-surface [--material-margin:1rem] medium:[--material-margin:1.5rem]',
|
||||
'max-medium:[--material-bottom-bar:calc(4rem+var(--material-safe-bottom,env(safe-area-inset-bottom))+var(--material-bottom-extra,0px))]' => $barItems->isNotEmpty() && ! $tallBar,
|
||||
'max-medium:[--material-bottom-bar:calc(5rem+var(--material-safe-bottom,env(safe-area-inset-bottom))+var(--material-bottom-extra,0px))]' => $barItems->isNotEmpty() && $tallBar,
|
||||
])
|
||||
>
|
||||
<a
|
||||
href="#content"
|
||||
data-skip-link
|
||||
class="sr-only focus:not-sr-only focus:fixed focus:start-4 focus:top-[calc(var(--material-safe-top,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>
|
||||
|
||||
@isset($banner)
|
||||
<div data-app-shell-banner class="shrink-0">{{ $banner }}</div>
|
||||
@endisset
|
||||
|
||||
<div class="flex-1 medium:flex">
|
||||
<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-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-livewire-material::navigation-rail>
|
||||
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
{{ $top ?? '' }}
|
||||
|
||||
@isset($fab)
|
||||
<div data-md-scaffold-fab>{{ $fab }}</div>
|
||||
@endisset
|
||||
|
||||
<main id="content" tabindex="-1" wire:transition.navigate class="min-w-0 flex-1 px-(--material-margin) outline-none max-expanded:overflow-x-clip max-medium:pb-(--material-bottom-bar)">
|
||||
{{ $slot }}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($barItems->isNotEmpty())
|
||||
<div data-app-shell-bar class="fixed inset-x-0 bottom-0 z-30 medium:hidden">
|
||||
<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>
|
||||
Reference in New Issue
Block a user