Offer M3's standard side sheet beside the content

Plan step 23, containment.md § Missing ("Side sheets: a genuine standard
variant"). `<x-drawer standard>` is M3's other side-sheet variant: from
`expanded` it is co-planar with the content — no scrim, no focus trap,
nothing inert, 0dp elevation, `surface` rather than surface-container-low,
no corner, and an outline-variant rule down its inner edge in place of the
scrim — sitting in the page flow at the window's full height. Below
`expanded` it is the modal sheet, since M3 caps a side sheet at 400dp and
a medium window has too little left beside one. Capped at that 400dp
whatever `width` says, and it always draws the close affordance M3
requires, because nothing else closes it there.

`pane` is untouched and the header and SKILL.md say how the two differ:
pane is the list-detail companion, standard is supplementary content.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
Andreas Reinhold / reini
2026-09-14 06:38:16 +02:00
co-authored by Claude Fable 5.1
parent b1f64771f3
commit 07759cd664
4 changed files with 100 additions and 9 deletions
@@ -490,6 +490,8 @@ Props: `title`, `subtitle`, `icon` (centred hero icon), `separator` (a divider u
An M3 side sheet, bound like `<x-modal>`; `close()` in scope. Props: `title`, `subtitle`, `separator`, `side` (`end` default, `start`), `width` (`25rem`), `with-close-button` (**default true** — M3 requires a close affordance; `:with-close-button="false"` is ignored when Escape or the scrim is off, and on a pane), `close-on-escape` (default true), `without-backdrop-close`, `actions` slot (**left**-aligned in a 72dp row, which is what the side-sheet spec says; a dialog's are trailing-aligned). `pane` (with `pane-width`, `22.5rem` — M3's 360dp fixed pane) turns it into a second pane from `expanded`, where M3 shows two panes: render it after the list inside `<div class="expanded:flex expanded:items-start expanded:gap-6">`. Escape leaves a pane open unless `pane-close-on-escape`. Its body is a size container — lay out inside with `@md:` (a *container* query), never a window class. An M3 side sheet, bound like `<x-modal>`; `close()` in scope. Props: `title`, `subtitle`, `separator`, `side` (`end` default, `start`), `width` (`25rem`), `with-close-button` (**default true** — M3 requires a close affordance; `:with-close-button="false"` is ignored when Escape or the scrim is off, and on a pane), `close-on-escape` (default true), `without-backdrop-close`, `actions` slot (**left**-aligned in a 72dp row, which is what the side-sheet spec says; a dialog's are trailing-aligned). `pane` (with `pane-width`, `22.5rem` — M3's 360dp fixed pane) turns it into a second pane from `expanded`, where M3 shows two panes: render it after the list inside `<div class="expanded:flex expanded:items-start expanded:gap-6">`. Escape leaves a pane open unless `pane-close-on-escape`. Its body is a size container — lay out inside with `@md:` (a *container* query), never a window class.
`standard` is M3's other side-sheet variant, and it is not the same thing as `pane`. A **pane** is the list-detail companion: it shows what the list beside it has selected, 360dp wide on `surface-container` with a large corner. A **standard** sheet is supplementary content beside the primary content — filters, details, a list of actions: co-planar from `expanded`, flat on `surface` with 0dp elevation and no corner, the window's full height, an outline-variant rule down its inner edge instead of a scrim, nothing inert and no focus trap. Below `expanded` it is the modal sheet. Capped at M3's 400dp whatever `width` says, and it always draws the close button. Render it in the same `expanded:flex` row as a pane.
### `<x-bottom-sheet>` ### `<x-bottom-sheet>`
An M3 bottom sheet, bound like `<x-modal>`: modal by default (scrim, inert page, drag the handle down or press Escape to close), `standard` for one that is part of the page. Props: `title`, `height` (`50dvh` — M3 caps a modal sheet's initial position at half the screen; whatever you pass is held under a ceiling of the screen less M3's 72dp top margin), `actions` slot. An M3 bottom sheet, bound like `<x-modal>`: modal by default (scrim, inert page, drag the handle down or press Escape to close), `standard` for one that is part of the page. Props: `title`, `height` (`50dvh` — M3 caps a modal sheet's initial position at half the screen; whatever you pass is held under a ceiling of the screen less M3's 72dp top margin), `actions` slot.
+53 -9
View File
@@ -15,6 +15,25 @@
elevation 1; `side` `end` (the default) or `start`; `width` from `medium` (a caller's `w-*` elevation 1; `side` `end` (the default) or `start`; `width` from `medium` (a caller's `w-*`
would race the sheet's own — a compact window gets the full-bleed sheet). would race the sheet's own — a compact window gets the full-bleed sheet).
As a **standard** side sheet (`standard`, from `expanded`) it is M3's other variant: co-planar
with the content rather than over it no scrim, no focus trap, nothing inert, 0dp elevation
(material-components-android's `SideSheet.md`: "standard side sheet elevation = 0dp,
coplanar"), `surface` rather than surface-container-low, no corner, and an outline-variant
divider down its inner edge in place of the scrim. It sits in the page flow beside the
content, spans the window's height and scrolls on its own. M3 calls the standard sheet
"supplementary surfaces mainly for medium to expanded breakpoints" and the modal one
"preferred at compact breakpoints"; the library switches at `expanded` (840px) rather than at
medium, because M3 also caps a side sheet at 400dp and a 600px window has too little left
beside one below `expanded` a `standard` sheet is the modal sheet
(docs/reference/m3/components-actions-communication-containment.md § Side sheets).
`standard` and `pane` are not the same thing and neither replaces the other: a **pane** is the
second pane of a list-detail layout it shows what the list beside it has selected, is
`22.5rem` wide (M3's 360dp fixed pane), sticks under the top of the viewport and sits on
`surface-container` with a large corner. A **standard** sheet is supplementary content beside
the primary content — filters, details, a list of actions — full height, flat on `surface`,
divided from the content by a rule. Pass one or the other; `pane` wins if both are given.
As a pane (`pane`, from `expanded`) nothing is covered: the page renders the drawer after its As a pane (`pane`, from `expanded`) nothing is covered: the page renders the drawer after its
list in an `expanded:flex expanded:items-start expanded:gap-6` row, the drawer sticks under the list in an `expanded:flex expanded:items-start expanded:gap-6` row, the drawer sticks under the
top of the viewport, the top of the viewport, the
@@ -29,8 +48,8 @@
sheet's open/close flow or tell whether it is transient or permanent sheet's open/close flow or tell whether it is transient or permanent
(docs/reference/m3/components-actions-communication-containment.md § Side sheets → (docs/reference/m3/components-actions-communication-containment.md § Side sheets →
Accessibility) — so `with-close-button` is on by default, and `:with-close-button="false"` is Accessibility) — so `with-close-button` is on by default, and `:with-close-button="false"` is
ignored where nothing else closes the sheet: Escape off, the scrim off, or a pane, which has ignored where nothing else closes the sheet: Escape off, the scrim off, or a pane or a
neither. standard sheet, which have neither from `expanded`.
maryUI's API, kept: `title`, `subtitle`, `separator`, `with-close-button`, `close-on-escape`, maryUI's API, kept: `title`, `subtitle`, `separator`, `with-close-button`, `close-on-escape`,
`without-backdrop-close`, `right` (ignored; use `side`), and an `actions` slot. --}} `without-backdrop-close`, `right` (ignored; use `side`), and an `actions` slot. --}}
@@ -48,6 +67,7 @@
'pane' => false, 'pane' => false,
'paneWidth' => '22.5rem', 'paneWidth' => '22.5rem',
'paneCloseOnEscape' => false, 'paneCloseOnEscape' => false,
'standard' => false,
]) ])
@php @php
@@ -55,9 +75,21 @@
$id = $attributes->get('id') ?? 'material-sheet-'.substr(md5($model.'|'.$title), 0, 10); $id = $attributes->get('id') ?? 'material-sheet-'.substr(md5($model.'|'.$title), 0, 10);
$start = $side === 'start'; $start = $side === 'start';
// One or the other: a pane is the list-detail companion, a standard sheet is supplementary
// content beside the primary content.
$standard = $standard && ! $pane;
// Both stop being modal from `expanded`, so both watch the window for that width.
$wide = $pane || $standard;
// The side-sheet specs table caps the sheet at 400dp, which is where `width` already starts;
// a wider one is the modal sheet's to take, not the co-planar standard sheet's.
$sheetWidth = $standard ? "min({$width}, 25rem)" : $width;
// M3 requires a close affordance; the prop can only ever add one, never take away the last // M3 requires a close affordance; the prop can only ever add one, never take away the last
// way out of the sheet. // way out of the sheet. A standard sheet keeps no scrim and no trap from `expanded`, and
$closeButton = $withCloseButton || ! $closeOnEscape || $withoutBackdropClose || ($pane && ! $paneCloseOnEscape); // Escape leaves it open there, so it always draws one.
$closeButton = $withCloseButton || ! $closeOnEscape || $withoutBackdropClose || $standard || ($pane && ! $paneCloseOnEscape);
@endphp @endphp
<div <div
@@ -65,7 +97,7 @@
@if ($model !== null) open: @entangle($attributes->wire('model')).live, @endif @if ($model !== null) open: @entangle($attributes->wire('model')).live, @endif
wide: false, wide: false,
close() { this.open = typeof this.open === 'boolean' ? false : null; }, close() { this.open = typeof this.open === 'boolean' ? false : null; },
@if ($pane) @if ($wide)
init() { init() {
const query = window.matchMedia('(width >= 52.5rem)'); const query = window.matchMedia('(width >= 52.5rem)');
this.wide = query.matches; this.wide = query.matches;
@@ -79,6 +111,12 @@
x-bind:class="! open && 'expanded:hidden'" x-bind:class="! open && 'expanded:hidden'"
class="expanded:sticky expanded:top-[calc(var(--material-safe-top,env(safe-area-inset-top))+1.25rem)] expanded:shrink-0 expanded:self-start" class="expanded:sticky expanded:top-[calc(var(--material-safe-top,env(safe-area-inset-top))+1.25rem)] expanded:shrink-0 expanded:self-start"
data-pane data-pane
@elseif ($standard)
{{-- A standard sheet spans the window's height beside the content and takes no room while
closed; sticky, so it stays put as the page scrolls past it. --}}
x-bind:class="! open && 'expanded:hidden'"
class="expanded:sticky expanded:top-0 expanded:h-dvh expanded:shrink-0 expanded:self-start"
data-standard
@endif @endif
> >
<div <div
@@ -86,7 +124,7 @@
x-show="open" x-show="open"
x-transition.opacity.duration.200ms x-transition.opacity.duration.200ms
@if (! $withoutBackdropClose) x-on:click="close()" @endif @if (! $withoutBackdropClose) x-on:click="close()" @endif
@class(['fixed inset-0 z-40 bg-scrim/32', 'expanded:hidden' => $pane]) @class(['fixed inset-0 z-40 bg-scrim/32', 'expanded:hidden' => $wide])
aria-hidden="true" aria-hidden="true"
></div> ></div>
@@ -95,24 +133,30 @@
x-show="open" x-show="open"
x-trap.inert.noscroll="open && ! wide" x-trap.inert.noscroll="open && ! wide"
x-transition:enter="transition-[translate,opacity] duration-(--md-sys-motion-spatial-default-duration) ease-emphasized-decelerate" x-transition:enter="transition-[translate,opacity] duration-(--md-sys-motion-spatial-default-duration) ease-emphasized-decelerate"
x-transition:enter-start="{{ $pane ? ($start ? 'max-expanded:-translate-x-full expanded:opacity-0' : 'max-expanded:translate-x-full expanded:opacity-0') : ($start ? '-translate-x-full' : 'translate-x-full') }}" x-transition:enter-start="{{ $wide ? ($start ? 'max-expanded:-translate-x-full expanded:opacity-0' : 'max-expanded:translate-x-full expanded:opacity-0') : ($start ? '-translate-x-full' : 'translate-x-full') }}"
x-transition:enter-end="translate-x-0 opacity-100" x-transition:enter-end="translate-x-0 opacity-100"
x-transition:leave="transition-[translate,opacity] duration-(--md-sys-motion-effects-default-duration) ease-emphasized-accelerate" x-transition:leave="transition-[translate,opacity] duration-(--md-sys-motion-effects-default-duration) ease-emphasized-accelerate"
x-transition:leave-start="translate-x-0 opacity-100" x-transition:leave-start="translate-x-0 opacity-100"
x-transition:leave-end="{{ $pane ? ($start ? 'max-expanded:-translate-x-full expanded:opacity-0' : 'max-expanded:translate-x-full expanded:opacity-0') : ($start ? '-translate-x-full' : 'translate-x-full') }}" x-transition:leave-end="{{ $wide ? ($start ? 'max-expanded:-translate-x-full expanded:opacity-0' : 'max-expanded:translate-x-full expanded:opacity-0') : ($start ? '-translate-x-full' : 'translate-x-full') }}"
id="{{ $id }}" id="{{ $id }}"
x-bind:role="wide ? 'region' : 'dialog'" x-bind:role="wide ? 'region' : 'dialog'"
x-bind:aria-modal="wide ? null : 'true'" x-bind:aria-modal="wide ? null : 'true'"
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
@if (filled($title)) aria-labelledby="{{ $id }}-title" @endif @if (filled($title)) aria-labelledby="{{ $id }}-title" @endif
style="--sheet-width: {{ $width }}; --pane-width: {{ $paneWidth }}" style="--sheet-width: {{ $sheetWidth }}; --pane-width: {{ $paneWidth }}"
{{ $attributes->whereDoesntStartWith('wire:model')->except(['id', 'class'])->class([ {{ $attributes->whereDoesntStartWith('wire:model')->except(['id', 'class'])->class([
'fixed top-[var(--material-safe-top,env(safe-area-inset-top))] bottom-0 z-50 flex w-full flex-col overflow-y-auto bg-surface-container-low p-6 text-on-surface shadow-elevation-1', 'fixed top-[var(--material-safe-top,env(safe-area-inset-top))] bottom-0 z-50 flex w-full flex-col overflow-y-auto bg-surface-container-low p-6 text-on-surface shadow-elevation-1',
'end-0 medium:rounded-s-corner-lg' => ! $start, 'end-0 medium:rounded-s-corner-lg' => ! $start,
'start-0 medium:rounded-e-corner-lg' => $start, 'start-0 medium:rounded-e-corner-lg' => $start,
'medium:w-(--sheet-width) medium:max-w-[calc(100vw-4rem)]', 'medium:w-(--sheet-width) medium:max-w-[calc(100vw-4rem)]',
'expanded:relative expanded:top-0 expanded:z-auto expanded:max-h-[calc(100dvh-2.5rem-var(--material-safe-top,env(safe-area-inset-top)))] expanded:w-(--pane-width) expanded:max-w-none expanded:rounded-corner-lg expanded:bg-surface-container expanded:shadow-none' => $pane, 'expanded:relative expanded:top-0 expanded:z-auto expanded:max-h-[calc(100dvh-2.5rem-var(--material-safe-top,env(safe-area-inset-top)))] expanded:w-(--pane-width) expanded:max-w-none expanded:rounded-corner-lg expanded:bg-surface-container expanded:shadow-none' => $pane,
// M3's standard side sheet from `expanded`: co-planar on `surface`, square, 0dp
// elevation, the window's full height, with an outline-variant rule down the edge it
// meets the content on — the divider its anatomy lists, in place of the scrim.
'expanded:relative expanded:top-0 expanded:z-auto expanded:h-full expanded:rounded-corner-none expanded:bg-surface expanded:shadow-none' => $standard,
'expanded:border-s expanded:border-outline-variant' => $standard && ! $start,
'expanded:border-e expanded:border-outline-variant' => $standard && $start,
$attributes->get('class'), $attributes->get('class'),
]) }} ]) }}
> >
@@ -140,6 +140,27 @@
</x-modal> </x-modal>
</div> </div>
BLADE, BLADE,
'A standard side sheet: co-planar from expanded, modal below' => <<<'BLADE'
<div x-data="{ open: true }" class="w-full">
<div class="expanded:flex expanded:items-start expanded:gap-6">
<div class="min-w-0 flex-1 space-y-4">
<x-button label="Show or hide the filters" variant="tonal" x-on:click="open = ! open" />
<p class="type-body-md text-on-surface-variant">
From <code>expanded</code> (840px) the sheet beside this text is co-planar: no scrim, no focus trap, flat on the surface, divided from this column by a rule. Narrow the window and the same sheet becomes the modal one.
</p>
</div>
<x-drawer standard title="Filters" subtitle="Narrow the list down">
<x-list selectable label="Expiry">
<x-list-item title="One hour" :selected="false" />
<x-list-item title="Three days" :selected="true" />
<x-list-item title="Thirty days" :selected="false" />
</x-list>
<x-slot:actions><x-button label="Apply" variant="filled" /></x-slot:actions>
</x-drawer>
</div>
</div>
BLADE,
'Sheets' => <<<'BLADE' 'Sheets' => <<<'BLADE'
<div x-data="{ open: false }"> <div x-data="{ open: false }">
<x-button label="Side sheet" variant="tonal" x-on:click="open = true" /> <x-button label="Side sheet" variant="tonal" x-on:click="open = true" />
+24
View File
@@ -143,3 +143,27 @@ it('draws a modal bottom sheet with a drag handle, or a standard one without a s
->and((string) $this->blade('<x-bottom-sheet height="90dvh">Body</x-bottom-sheet>')) ->and((string) $this->blade('<x-bottom-sheet height="90dvh">Body</x-bottom-sheet>'))
->toContain('--sheet-max-height: min(90dvh, calc(100dvh - 72px))'); ->toContain('--sheet-max-height: min(90dvh, calc(100dvh - 72px))');
}); });
it('is M3\'s standard side sheet from expanded, and the modal one below', function () {
$html = (string) $this->blade('<x-drawer standard title="Filters">Body</x-drawer>');
expect($html)
->toContain('data-standard')
->toContain('expanded:sticky expanded:top-0 expanded:h-dvh expanded:shrink-0 expanded:self-start')
->toContain('expanded:relative expanded:top-0 expanded:z-auto expanded:h-full expanded:rounded-corner-none expanded:bg-surface expanded:shadow-none')
->toContain('expanded:border-s expanded:border-outline-variant')
->toContain('--sheet-width: min(25rem, 25rem)')
->toContain("matchMedia('(width >= 52.5rem)')")
->toContain('x-trap.inert.noscroll="open && ! wide"')
->toContain('bg-scrim/32 expanded:hidden')
->toContain('aria-label="Close"')
->and((string) $this->blade('<x-drawer standard side="start" width="30rem">Body</x-drawer>'))
->toContain('expanded:border-e expanded:border-outline-variant')
->toContain('--sheet-width: min(30rem, 25rem)')
->and((string) $this->blade('<x-drawer standard pane>Body</x-drawer>'))
->toContain('data-pane')
->not->toContain('data-standard')
->and((string) $this->blade('<x-drawer title="Filters">Body</x-drawer>'))
->not->toContain('data-standard')
->not->toContain('expanded:hidden');
});