Rewrite the drawer (side sheet) without Tailwind, remove pane

Plan step 36 (containment group): <x-drawer>'s class lists move into
resources/css/components/drawer.css, keyed on data-md-drawer (with
data-md-open, data-md-standard) and data-md-drawer-scrim/-sheet
(data-md-side)/-head/-head-row/-heading/-title/-subtitle/-close/-body/
-actions. The modal sheet slides in on the emphasized-decelerate
easing and out on emphasized-accelerate (an asymmetric CSS transition
expressed by giving the open-state selector its own
transition-duration/-timing-function, which the "after-change style"
rule picks up); the sheet stays x-show-driven, with @starting-style +
transition-behavior: allow-discrete taking the place of x-transition's
enter/leave classes, matching resources/css/components/search.css's
already-rewritten scrim/view. A standard sheet drops the scrim and
trap from 840px and only fades in place (an in-flow box does not
travel); data-md-drawer-collapsed (from the view's `wide` Alpine
state) takes it out of the layout while closed there.

<x-drawer pane> is removed: step 35's <x-list-detail> is M3's form of
that job (the plan's decision, docs/plans/material-3-alignment.md
"Decisions"). Removed the prop, its CSS classes and its tests
(OverlayTest's two pane-only tests deleted, the others' pane cases
dropped); pointed each mention at <x-list-detail> in the material-3
guideline, the material-3-design and livewire-material-development
skills, and added a line to UPGRADE.md's 2.0.0 notes. No other living
file named `<x-drawer pane>` (grepped views, tests, README, boost,
workbench excluding build output); docs/plans and docs/audits keep
their history unchanged.

Imported from the Containment block of components.css, importing
button.css and divider.css for what the view renders.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
Andreas Reinhold / reini
2026-09-14 21:07:04 +02:00
co-authored by Claude Sonnet 5
parent 3c235ecc1e
commit 9dd527f303
9 changed files with 319 additions and 144 deletions
+54 -108
View File
@@ -1,58 +1,49 @@
{{-- 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).
{{-- An M3 side sheet: detail or controls that slide in from the edge over a scrim; on a phone it
is the whole screen.
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.
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).
open a gap. M3's modal side sheet (NavigationDrawerTokens.kt-adjacent side-sheet sources;
docs/reference/m3/components-actions-communication-containment.md § Side sheets): surface-
container-low, a large corner on its inner edge, elevation 1; `side` `end` (the default) or
`start`; `width` from `medium` (a caller's `class` 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).
content, spans the window's height and scrolls on its own; below `expanded` (840px) it is the
modal sheet M3 calls the standard sheet "supplementary surfaces mainly for medium to
expanded breakpoints" and the modal one "preferred at compact breakpoints", and the switch
sits at `expanded` rather than `medium` because M3 also caps a side sheet at 400dp and a
600px window has too little left beside one.
`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.
For the second pane of a list-detail layout use `<x-list-detail>` instead (step 35's canonical
layout); the two are not the same thing — a pane shows what the list beside it selected, a
standard sheet is supplementary content (filters, details, a list of actions) beside the
primary content, divided from it by a rule rather than being part of its selection.
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`.
Accessibility, C-04) 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 `standard`, which has 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. --}}
`without-backdrop-close`, `right` (ignored; use `side`), and an `actions` slot.
The root renders `data-md-drawer`, `data-md-open` and `data-md-standard`; the sheet is
`data-md-drawer-sheet` with `data-md-side`, its parts `data-md-drawer-head`/-head-row/
-heading/-title/-subtitle/-close/-body/-actions — drawn by resources/css/components/
drawer.css, which imports button.css and divider.css for what the view renders. --}}
@props([
'title' => null,
@@ -64,23 +55,14 @@
'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;
$side = $side === 'start' ? 'start' : 'end';
$standard = (bool) $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.
@@ -89,7 +71,7 @@
// 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);
$closeButton = $withCloseButton || ! $closeOnEscape || $withoutBackdropClose || $standard;
@endphp
<div
@@ -97,7 +79,7 @@
@if ($model !== null) open: @entangle($attributes->wire('model')).live, @endif
wide: false,
close() { this.open = typeof this.open === 'boolean' ? false : null; },
@if ($wide)
@if ($standard)
init() {
const query = window.matchMedia('(width >= 52.5rem)');
this.wide = query.matches;
@@ -105,98 +87,62 @@
},
@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
@if ($closeOnEscape) x-on:keydown.window.escape="if (open && ! wide) close()" @endif
x-bind:data-md-open="open ? '' : null"
x-bind:data-md-drawer-collapsed="(! open && wide) ? '' : null"
data-md-drawer
@if ($standard) data-md-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>
<div data-md-drawer-scrim x-cloak x-show="open" @if (! $withoutBackdropClose) x-on:click="close()" @endif 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'),
]) }}
id="{{ $id }}"
data-md-drawer-sheet
data-md-side="{{ $side }}"
style="--sheet-width: {{ $sheetWidth }}"
{{ $attributes->whereDoesntStartWith('wire:model')->except(['id']) }}
>
@if (filled($title) || $closeButton)
<div class="mb-4">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div data-md-drawer-head>
<div data-md-drawer-head-row>
<div data-md-drawer-heading>
@if (filled($title))
<h2 id="{{ $id }}-title" class="type-title-lg">{{ $title }}</h2>
<h2 id="{{ $id }}-title" data-md-drawer-title>{{ $title }}</h2>
@endif
@if (filled($subtitle))
<p class="mt-1 type-body-md text-on-surface-variant">{{ $subtitle }}</p>
<p data-md-drawer-subtitle>{{ $subtitle }}</p>
@endif
</div>
@if ($closeButton)
<span class="-me-3 -mt-2 inline-flex shrink-0">
<span data-md-drawer-close>
<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" />
<x-livewire-material::divider />
@endif
</div>
@endif
<div class="@container flex-1">
{{ $slot }}
</div>
<div data-md-drawer-body>{{ $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>
M3 specifies the two differently (C-10). --}}
<div data-md-drawer-actions>{{ $actions }}</div>
@endisset
</aside>
</div>