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:
Andreas Reinhold / reini
2026-09-14 14:51:48 +02:00
co-authored by Claude Opus 5
parent acd6ba21ae
commit 0013e0154d
24 changed files with 301 additions and 120 deletions
@@ -24,12 +24,12 @@
down and springs back on a scroll up, never before the first screenful has gone by, and never
while a snackbar, a bottom sheet or a drawer is on screen those are anchored to the bar's
edge and would slide with it. Focus reaching the bar brings it back, which is as close as the
web gets to M3's "never hide it while a screen reader is active". Inside `<x-app-shell>`
web gets to M3's "never hide it while a screen reader is active". Inside `<x-scaffold>`
`--material-bottom-bar` follows the bar down and up, so a `fab` button, the snackbar and the
page's bottom padding keep their distance from it rather than from where it was.
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
and hides it where a rail takes over. `<x-scaffold>` does both, and lifts the snackbar and a
`fab` button above it through `--material-bottom-bar`.
`label` names the landmark ("Main" by default); `tall` picks the 80px container;
@@ -29,7 +29,7 @@
- `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, one rail per M3 window size class: on a compact
- `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` (600839) collapsed in the layout, opening as a modal; at `expanded` (8401199)
a standard rail, collapsed until its menu button expands it in place; from `large` (1200)
@@ -1,17 +1,20 @@
{{-- The adaptive app shell: navigation that changes shape with the window, around the page.
{{-- 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-app-shell :destinations="[
<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-app-shell>
</x-scaffold>
The navigation is M3's per window size class (docs/reference/m3/foundations.md § Layout and
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`,
@@ -28,14 +31,15 @@
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
class's own default.
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 shell writes no gutters of its own.
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)`.
Two panes side by side are M3's from `expanded`: `<x-drawer pane>` is the second one, 360dp
wide, in an `expanded:flex expanded:items-start expanded:gap-6` row inside the page.
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
@@ -51,8 +55,8 @@
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) and the page
itself. The rail is one element at every width, so what is in it is also in the modal rail a
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
@@ -66,16 +70,28 @@
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-app-shell>` so the rail sticks under it instead
`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 shell;
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
@@ -120,6 +136,7 @@
<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,
@@ -176,6 +193,10 @@
<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>
@@ -31,7 +31,7 @@
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. <html data-rail-auto> rides with it and says nothing was stored — the value is only
`rail.default`, not a choice — so `<x-app-shell>`'s adaptive rail can start collapsed in the
`rail.default`, not a choice — so `<x-scaffold>`'s adaptive rail can start collapsed in the
expanded class (8401199) and expanded from large, as M3 asks, while still obeying a visitor
who has chosen. `$store.rail` drops it the first time they do.
+1 -1
View File
@@ -25,7 +25,7 @@
A docked toolbar and a navigation bar occupy the same region of the screen and M3 says never to
show both at once: the bar belongs on a primary page, the toolbar on a secondary or contextual
one. Either way a toolbar placed at `bottom` clears `--material-bottom-bar`, so it is never
buried under `<x-app-shell>`'s bar.
buried under `<x-scaffold>`'s bar.
Put `<x-button icon="…" tooltip="…" />` controls in the slot (`:selected` for toggles). It is a
`role="toolbar"`: the arrow keys move between its controls (resources/js/toolbar.js,
+3 -3
View File
@@ -1,4 +1,4 @@
{{-- The showcase's frame, and the package's own app shell at work: the rail groups every section,
{{-- The showcase's frame, and the package's own scaffold at work: the rail groups every section,
collapses and expands from `lg`, and opens as a modal from the app bar's menu button on a phone.
Pages move with wire:navigate, so the rail keeps its place and the theme stays. --}}
@@ -42,7 +42,7 @@
@livewireStyles
</head>
<body class="bg-surface font-sans text-on-surface antialiased">
<x-livewire-material::app-shell :destinations="$destinations" label="Showcase" rail-width="17rem">
<x-livewire-material::scaffold :destinations="$destinations" label="Showcase" rail-width="17rem">
<x-slot:brand>
<a href="{{ route('livewire-material.showcase') }}" wire:navigate class="block truncate rounded-corner-xs type-title-lg focus-ring">Livewire Material</a>
</x-slot:brand>
@@ -152,7 +152,7 @@
</x-slot:top>
@yield('content')
</x-livewire-material::app-shell>
</x-livewire-material::scaffold>
@livewireScripts
@@ -98,7 +98,7 @@
</div>
<p class="max-w-3xl type-body-md text-on-surface-variant">
<code>&lt;x-app-shell&gt;</code> does all of this: resize this showcase and watch the bar, the rail and the
<code>&lt;x-scaffold&gt;</code> does all of this: resize this showcase and watch the bar, the rail and the
content margin (<code>--material-margin</code>) change at 600, 840 and 1200px. A visitor who has chosen a rail
width keeps it; until then the rail follows the class.
</p>
@@ -113,6 +113,7 @@
</p>
<x-livewire-material::stack as="ul" gap="space100" class="md-type-body-md">
<li><code>&lt;x-scaffold&gt;</code> — the bars, the rail and the FAB around the page (this showcase is one).</li>
<li><code>&lt;x-pane&gt;</code> — a content region with M3's margins and its own app bar.</li>
<li><code>&lt;x-list-detail&gt;</code> a list and the detail of its selection: one pane below 840px, two from it.</li>
<li><code>&lt;x-supporting-pane&gt;</code> a focus pane and a pane beside it from 840px, below it or a bottom sheet before.</li>
@@ -134,7 +134,7 @@
</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, and the FAB morphs into an extended FAB with it. 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>expanded</code> (840px) too.
The menu button collapses and expands the rail, and the FAB morphs into an extended FAB with it. The choice is remembered and applied before the next page paints every collapsible rail follows it, <code>&lt;x-scaffold&gt;</code>'s from <code>expanded</code> (840px) too.
</div>
</div>
BLADE,
@@ -164,8 +164,8 @@
<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 on M3's window size classes: a bar below <code>medium</code> (600px), a collapsed rail that opens as a modal through <code>medium</code>, a standard rail from <code>expanded</code> (840px) and an expanded one from <code>large</code> (1200px).
<a href="{{ route('livewire-material.shell') }}" class="link text-primary">Open the app shell</a> and change the window's width.
and <code>&lt;x-scaffold&gt;</code>, which puts them together on M3's window size classes: a bar below <code>medium</code> (600px), a collapsed rail that opens as a modal through <code>medium</code>, a standard rail from <code>expanded</code> (840px) and an expanded one from <code>large</code> (1200px).
<a href="{{ route('livewire-material.shell') }}" class="link text-primary">Open the scaffold</a> and change the window's width.
</p>
@foreach ($examples as $title => $code)
+4 -4
View File
@@ -1,4 +1,4 @@
{{-- The app shell, as a whole page: /material/shell and its sibling pages, so the rail, the bar, the
{{-- The scaffold, 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
@@ -31,7 +31,7 @@
<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>
<title>{{ $current['title'] }} · Scaffold · Livewire Material</title>
<x-livewire-material::theme-script />
@@ -39,7 +39,7 @@
@livewireStyles
</head>
<body class="bg-surface font-sans text-on-surface antialiased">
<x-livewire-material::app-shell :destinations="$destinations">
<x-livewire-material::scaffold :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>
@@ -79,7 +79,7 @@
<x-livewire-material::button label="Back to the showcase" link="{{ route('livewire-material.section', 'navigation') }}" no-wire-navigate />
</div>
</div>
</x-livewire-material::app-shell>
</x-livewire-material::scaffold>
@livewireScripts
</body>