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
203 lines
11 KiB
PHP
203 lines
11 KiB
PHP
{{-- An M3 side sheet: detail or controls that slide in from the edge over a scrim; on a phone it is
|
|
the whole screen. With `pane`, from `expanded` (840px) it is a second pane beside the list
|
|
instead — M3 shows two panes for list-detail from expanded upwards, and puts a supporting pane
|
|
on the side of the focus pane at expanded, 360dp wide
|
|
(docs/reference/m3/foundations-supplement.md § Canonical layout examples).
|
|
|
|
The open state is the Livewire property in `wire:model` (entangled live, so a detail held in the
|
|
URL follows a close) — a flag or an id — and closing writes back `false` or `null`. Without
|
|
`wire:model` it reads and writes `open` in the Alpine scope around it. `close()` is in scope for
|
|
anything inside the sheet, so it can draw its own close button.
|
|
|
|
As a sheet it is modal: the page inert and still (`x-trap.inert.noscroll`), and it enters on
|
|
emphasized decelerate rather than a spring — a sheet anchored to the edge that overshot would
|
|
open a gap. M3's modal side sheet: surface-container-low, a large corner on its inner edge,
|
|
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
|
|
list stays usable and another row swaps what it shows — no scrim, no trap, no inert page. While
|
|
closed it takes no room. `pane-width` sizes the pane (`22.5rem`, M3's 360dp fixed pane). Escape closes
|
|
the sheet but leaves a pane open, since the page beside it is still in use; `pane-close-on-escape`
|
|
closes the pane on Escape too, for a pane that is a transient detail. The body is
|
|
a size container, so its contents lay out by the room the sheet or pane actually has (`@md:`),
|
|
never by the viewport.
|
|
|
|
M3 **requires** a close affordance on a side sheet — without one nobody can predict the
|
|
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 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. --}}
|
|
|
|
@props([
|
|
'title' => null,
|
|
'subtitle' => null,
|
|
'separator' => false,
|
|
'side' => 'end',
|
|
'right' => true,
|
|
'withCloseButton' => true,
|
|
'closeOnEscape' => true,
|
|
'withoutBackdropClose' => false,
|
|
'width' => '25rem',
|
|
'pane' => false,
|
|
'paneWidth' => '22.5rem',
|
|
'paneCloseOnEscape' => false,
|
|
'standard' => false,
|
|
])
|
|
|
|
@php
|
|
$model = $attributes->wire('model')->value() ?: null;
|
|
$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. 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
|
|
x-data="{
|
|
@if ($model !== null) open: @entangle($attributes->wire('model')).live, @endif
|
|
wide: false,
|
|
close() { this.open = typeof this.open === 'boolean' ? false : null; },
|
|
@if ($wide)
|
|
init() {
|
|
const query = window.matchMedia('(width >= 52.5rem)');
|
|
this.wide = query.matches;
|
|
query.addEventListener('change', (event) => this.wide = event.matches);
|
|
},
|
|
@endif
|
|
}"
|
|
@if ($closeOnEscape) x-on:keydown.window.escape="{{ $paneCloseOnEscape ? 'if (open) close()' : 'if (open && ! wide) close()' }}" @endif
|
|
data-sheet="{{ $id }}"
|
|
@if ($pane)
|
|
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
|
|
x-cloak
|
|
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' => $wide])
|
|
aria-hidden="true"
|
|
></div>
|
|
|
|
<aside
|
|
x-cloak
|
|
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="{{ $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="{{ $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: {{ $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'),
|
|
]) }}
|
|
>
|
|
@if (filled($title) || $closeButton)
|
|
<div class="mb-4">
|
|
<div class="flex items-start justify-between gap-3">
|
|
<div class="min-w-0">
|
|
@if (filled($title))
|
|
<h2 id="{{ $id }}-title" class="type-title-lg">{{ $title }}</h2>
|
|
@endif
|
|
|
|
@if (filled($subtitle))
|
|
<p class="mt-1 type-body-md text-on-surface-variant">{{ $subtitle }}</p>
|
|
@endif
|
|
</div>
|
|
|
|
@if ($closeButton)
|
|
<span class="-me-3 -mt-2 inline-flex shrink-0">
|
|
<x-livewire-material::button icon="close" :tooltip-left="__('Close')" x-on:click="close()" />
|
|
</span>
|
|
@endif
|
|
</div>
|
|
|
|
@if ($separator)
|
|
<x-livewire-material::divider class="mt-4" />
|
|
@endif
|
|
</div>
|
|
@endif
|
|
|
|
<div class="@container flex-1">
|
|
{{ $slot }}
|
|
</div>
|
|
|
|
@isset($actions)
|
|
{{-- The side-sheet specs table: bottom actions 72dp tall, 16dp above them, 24dp below
|
|
(the sheet's own padding), aligned **left** — a dialog's are trailing-aligned, and
|
|
M3 specifies the two differently. --}}
|
|
<div class="flex min-h-18 shrink-0 flex-wrap items-center justify-start gap-2 pt-4">
|
|
{{ $actions }}
|
|
</div>
|
|
@endisset
|
|
</aside>
|
|
</div>
|