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
+53 -9
View File
@@ -15,6 +15,25 @@
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).
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
list in an `expanded:flex expanded:items-start expanded:gap-6` row, the drawer sticks under the
top of the viewport, the
@@ -29,8 +48,8 @@
sheet's open/close flow or tell whether it is transient or permanent
(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
ignored where nothing else closes the sheet: Escape off, the scrim off, or a pane, which has
neither.
ignored where nothing else closes the sheet: Escape off, the scrim off, or a pane or a
standard sheet, which have neither from `expanded`.
maryUI's API, kept: `title`, `subtitle`, `separator`, `with-close-button`, `close-on-escape`,
`without-backdrop-close`, `right` (ignored; use `side`), and an `actions` slot. --}}
@@ -48,6 +67,7 @@
'pane' => false,
'paneWidth' => '22.5rem',
'paneCloseOnEscape' => false,
'standard' => false,
])
@php
@@ -55,9 +75,21 @@
$id = $attributes->get('id') ?? 'material-sheet-'.substr(md5($model.'|'.$title), 0, 10);
$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
// way out of the sheet.
$closeButton = $withCloseButton || ! $closeOnEscape || $withoutBackdropClose || ($pane && ! $paneCloseOnEscape);
// way out of the sheet. A standard sheet keeps no scrim and no trap from `expanded`, and
// Escape leaves it open there, so it always draws one.
$closeButton = $withCloseButton || ! $closeOnEscape || $withoutBackdropClose || $standard || ($pane && ! $paneCloseOnEscape);
@endphp
<div
@@ -65,7 +97,7 @@
@if ($model !== null) open: @entangle($attributes->wire('model')).live, @endif
wide: false,
close() { this.open = typeof this.open === 'boolean' ? false : null; },
@if ($pane)
@if ($wide)
init() {
const query = window.matchMedia('(width >= 52.5rem)');
this.wide = query.matches;
@@ -79,6 +111,12 @@
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"
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
>
<div
@@ -86,7 +124,7 @@
x-show="open"
x-transition.opacity.duration.200ms
@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"
></div>
@@ -95,24 +133,30 @@
x-show="open"
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-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: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-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 }}"
x-bind:role="wide ? 'region' : 'dialog'"
x-bind:aria-modal="wide ? null : 'true'"
role="dialog"
aria-modal="true"
@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([
'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,
'start-0 medium:rounded-e-corner-lg' => $start,
'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,
// 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'),
]) }}
>