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
+3 -1
View File
@@ -75,7 +75,9 @@ over a colour, use the role directly.
- `<x-menu-item>` rows are 48px with 16px sides; a selected item draws a trailing check unless it
has `icon-right`; a long menu scrolls.
- `<x-drawer>` renders a close button by default (`:with-close-button="false"` to drop it, ignored
on a `pane` or when Escape and the scrim are off) and left-aligns its `actions` in a 72px row.
on a `standard` sheet or when Escape and the scrim are off) and left-aligns its `actions` in a
72px row. `<x-drawer pane>` is removed; use `<x-list-detail>` for the second pane of a
list-detail layout.
- `<x-bottom-sheet>` opens at `50dvh` (was `90dvh`); any `height` is capped at
`calc(100dvh - 72px)`.
- `<x-carousel>` `padding` defaults to `16`; `layout="full-screen"` scrolls vertically with
@@ -41,7 +41,7 @@ Every view in this application is Material 3 Expressive (m3.material.io), throug
### Layout and breakpoints
- Widths are M3's window size classes, the only variants that compile: compact below 600px (the default), `medium:` 600, `expanded:` 840, `large:` 1200, `extra-large:` 1600, and `max-medium:` … for "below". In scripts, `from()` and `upTo()` from `resources/js/breakpoints.js`.
- What changes per class: compact — navigation bar, one pane, full-screen dialogs, a bottom sheet for choices; medium — collapsed rail, one pane; expanded — rail (collapsible), two panes, menus and basic dialogs; large and extra-large — the rail expanded, two panes, a third only at extra-large as a side sheet. `<x-scaffold>` does this; content lives in panes (`<x-drawer pane>`), never beside the rail by hand.
- What changes per class: compact — navigation bar, one pane, full-screen dialogs, a bottom sheet for choices; medium — collapsed rail, one pane; expanded — rail (collapsible), two panes, menus and basic dialogs; large and extra-large — the rail expanded, two panes, a third only at extra-large as a side sheet. `<x-scaffold>` does this; content lives in panes (`<x-pane>`, `<x-list-detail>` for a list's second pane), never beside the rail by hand.
- Margins are 16px below `medium` and 24px from it; spacing sits on the 4px grid, as padding and gaps on the parent, with margins only between layout regions. A fixed pane is 360px (expanded) or 412px (large); a side sheet at most 400px.
- Write logical properties (`ps-*`, `me-*`, `start-*`, `text-start`); directional icons mirror in RTL; charts and media controls stay LTR. Keep controls inside the safe area (`--material-safe-*`).
@@ -539,9 +539,9 @@ Props: `title`, `subtitle`, `icon` (centred hero icon), `separator` (draw the di
### `<x-drawer>`
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, or on a `standard` sheet), `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). For the second pane of a list-detail layout use `<x-list-detail>` instead — `<x-drawer>` no longer has a `pane` prop. 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.
`standard` is M3's other side-sheet variant: 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 beside its content in a row that only lays out side by side from `expanded` (`<x-supporting-pane>`, or a caller's own row).
### `<x-bottom-sheet>`
@@ -215,7 +215,7 @@ Layout keys on the width of the window, in M3's five window size classes; they a
| Large | 12001599px | `large:` | rail expanded | 2; a fixed pane 412px | basic dialog; menu | 24px |
| Extra-large | 1600px and up | `extra-large:` | rail expanded | 2, or 3 with a standard side sheet (at most 400px) | basic dialog; menu | 24px |
- `<x-scaffold>` implements the navigation column; `<x-drawer pane>` is the second pane of a list-detail layout from `expanded:`, a supporting pane (360px, beside the focus pane) from `expanded:` and below it before that. Moving up a class, ask what to reveal, divide into panes, resize, reposition or swap — never swap a component for one that does not do the same job.
- `<x-scaffold>` implements the navigation column; `<x-list-detail>` is the second pane of a list-detail layout from `expanded:`, `<x-supporting-pane>` puts a supporting pane (360px, beside the focus pane) from `expanded:` and below it before that. Moving up a class, ask what to reveal, divide into panes, resize, reposition or swap — never swap a component for one that does not do the same job.
- Scaffold: bars (app bar at the top, navigation bar at the bottom: 35 destinations), rails (the navigation rail, toolbars, the FAB, on the leading edge), panes (all content), around a safety region that stays clear of the device's own chrome (`--material-safe-top|bottom|left|right`).
- Canonical layouts: feed (a grid of cards that gains columns per class), list-detail (one pane on compact, two from expanded; a back button only in single-pane mode, a selected row only in two-pane mode), supporting pane (two thirds focus, one third support).
- Bidirectionality: write logical properties (`ps-4`, `me-2`, `start-0`, `text-start`, `border-s`); leading and trailing icons swap, directional icons (back, send) mirror, the rail moves to the right; charts, media controls, clocks and Hebrew progress bars stay left-to-right.
+1
View File
@@ -62,5 +62,6 @@
@import './components/list-item.css';
@import './components/list.css';
@import './components/modal.css';
@import './components/drawer.css';
/* Navigation */
+226
View File
@@ -0,0 +1,226 @@
/*
* `<x-drawer>`: M3's side sheet, modal by default and co-planar as `standard` from 840px
* (docs/reference/m3/components-actions-communication-containment.md § Side sheets Specs; no
* androidx Compose token file exists for it Compose support is "Unavailable" per the site so
* the numbers come from the specs page and, for the standard variant's 0dp elevation,
* material-components-android's `SideSheet.md`).
*
* [data-md-drawer] the wrapper; data-md-open, data-md-standard
* [data-md-drawer-scrim] modal only (hidden by JS once the sheet is wide/standard)
* [data-md-drawer-sheet] surface-container-low, elevation 1; data-md-side
* [data-md-drawer-head] title/subtitle/close, and the separator's divider
* [data-md-drawer-head-row], [data-md-drawer-heading], [data-md-drawer-title],
* [data-md-drawer-subtitle], [data-md-drawer-close]
* [data-md-drawer-body] a size container, so its contents lay out by the sheet's own
* width (`@md:`), never the viewport's
* [data-md-drawer-actions] left-aligned, 72dp (C-10 a dialog's are trailing-aligned)
*
* 400px cap, 24dp start/end padding, a large corner on the inner edge only, 56dp side-sheet
* width start all from the specs table. It enters on the emphasized-decelerate easing rather
* than a spring, because a sheet anchored to an edge that overshot would open a gap; below
* `expanded` it slides, and once `standard` makes it co-planar it only fades (an in-flow box does
* not need to travel).
*
* A `standard` sheet drops the scrim, the trap and the elevation, and swaps surface-container-low
* for `surface` with an outline-variant rule down its inner edge, in place of the scrim
* (its anatomy's "Divider (optional)"). Below `expanded` (840px) it is the modal sheet M3 caps
* a side sheet at 400dp, and a 600px window has too little room left beside one, so the switch
* sits at `expanded` rather than `medium`. `data-md-drawer-collapsed` (the view, from the
* `wide` window 840px Alpine state) takes the standard sheet out of the layout entirely
* while it is closed there, so the content beside it fills the space.
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './button.css';
@import './divider.css';
@layer material.components {
[data-md-drawer-scrim] {
position: fixed;
inset: 0;
z-index: 40;
background-color: color-mix(in srgb, var(--md-sys-color-scrim) 32%, transparent);
transition-property: opacity, display;
transition-duration: var(--md-sys-motion-effects-default-duration);
transition-timing-function: var(--md-sys-motion-effects-default);
transition-behavior: allow-discrete;
}
@starting-style {
[data-md-drawer-scrim] {
opacity: 0;
}
}
[data-md-drawer-sheet] {
position: fixed;
top: var(--material-safe-top, env(safe-area-inset-top));
bottom: 0;
z-index: 50;
display: flex;
width: 100%;
flex-direction: column;
overflow-y: auto;
padding: var(--md-sys-measurement-space300);
background-color: var(--md-sys-color-surface-container-low);
color: var(--md-sys-color-on-surface);
box-shadow: var(--md-sys-elevation-1);
translate: 100% 0;
opacity: 0;
transition-property: translate, opacity, display;
transition-duration: var(--md-sys-motion-effects-default-duration);
transition-timing-function: var(--md-sys-motion-easing-emphasized-accelerate);
transition-behavior: allow-discrete;
}
[data-md-drawer-sheet][data-md-side='end'] {
inset-inline-end: 0;
}
[data-md-drawer-sheet][data-md-side='start'] {
inset-inline-start: 0;
translate: -100% 0;
}
[data-md-drawer][data-md-open] [data-md-drawer-sheet] {
translate: 0 0;
opacity: 1;
transition-duration: var(--md-sys-motion-spatial-default-duration);
transition-timing-function: var(--md-sys-motion-easing-emphasized-decelerate);
}
@starting-style {
[data-md-drawer][data-md-open] [data-md-drawer-sheet] {
translate: 100% 0;
opacity: 0;
}
[data-md-drawer][data-md-open] [data-md-drawer-sheet][data-md-side='start'] {
translate: -100% 0;
}
}
@media (width >= 600px) {
[data-md-drawer-sheet] {
width: var(--sheet-width);
max-width: calc(100vw - 64px);
}
[data-md-drawer-sheet][data-md-side='end'] {
border-start-start-radius: var(--md-sys-shape-corner-lg);
border-end-start-radius: var(--md-sys-shape-corner-lg);
}
[data-md-drawer-sheet][data-md-side='start'] {
border-start-end-radius: var(--md-sys-shape-corner-lg);
border-end-end-radius: var(--md-sys-shape-corner-lg);
}
}
/* Co-planar from expanded: sticky beside the content, no scrim, no elevation, flat on
`surface` with an outline-variant rule down the inner edge instead. It only fades here
an in-flow box does not travel like an overlay does. */
@media (width >= 840px) {
[data-md-drawer][data-md-standard] {
position: sticky;
top: 0;
height: 100dvh;
flex-shrink: 0;
align-self: flex-start;
}
[data-md-drawer][data-md-standard][data-md-drawer-collapsed] {
display: none;
}
[data-md-drawer][data-md-standard] [data-md-drawer-sheet] {
position: relative;
top: 0;
z-index: auto;
height: 100%;
translate: 0 0;
border-radius: var(--md-sys-shape-corner-none);
background-color: var(--md-sys-color-surface);
box-shadow: none;
opacity: 0;
transition-property: opacity, display;
transition-duration: var(--md-sys-motion-effects-default-duration);
transition-timing-function: var(--md-sys-motion-easing-emphasized-accelerate);
}
[data-md-drawer][data-md-standard][data-md-open] [data-md-drawer-sheet] {
opacity: 1;
transition-duration: var(--md-sys-motion-spatial-default-duration);
transition-timing-function: var(--md-sys-motion-easing-emphasized-decelerate);
}
@starting-style {
[data-md-drawer][data-md-standard][data-md-open] [data-md-drawer-sheet] {
opacity: 0;
}
}
[data-md-drawer][data-md-standard] [data-md-drawer-sheet][data-md-side='end'] {
border-inline-start: 1px solid var(--md-sys-color-outline-variant);
}
[data-md-drawer][data-md-standard] [data-md-drawer-sheet][data-md-side='start'] {
border-inline-end: 1px solid var(--md-sys-color-outline-variant);
}
}
[data-md-drawer-head] {
margin-bottom: var(--md-sys-measurement-space200);
}
[data-md-drawer-head-row] {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
[data-md-drawer-heading] {
min-width: 0;
}
[data-md-drawer-title] {
font: var(--md-sys-typescale-title-lg);
letter-spacing: var(--md-sys-typescale-title-lg-tracking);
}
[data-md-drawer-subtitle] {
margin-top: var(--md-sys-measurement-space50);
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-body-md);
letter-spacing: var(--md-sys-typescale-body-md-tracking);
}
[data-md-drawer-close] {
display: inline-flex;
flex-shrink: 0;
margin-top: calc(-1 * var(--md-sys-measurement-space100));
margin-inline-end: -12px;
}
[data-md-drawer-head] > [data-md-divider] {
margin-top: var(--md-sys-measurement-space200);
}
[data-md-drawer-body] {
container-type: inline-size;
flex: 1 1 0%;
}
[data-md-drawer-actions] {
display: flex;
min-height: var(--md-sys-measurement-space900);
flex-shrink: 0;
flex-wrap: wrap;
align-items: center;
justify-content: flex-start;
gap: var(--md-sys-measurement-space100);
padding-top: var(--md-sys-measurement-space200);
}
}
+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>
@@ -22,6 +22,7 @@ dataset('containment components', [
'list',
'list-item',
'modal',
'drawer',
]);
/**
+30 -31
View File
@@ -174,41 +174,27 @@ it('gives a full-screen dialog M3\'s 56px header and action bar', function () {
->toContain('min-height: var(--md-sys-measurement-space700);');
});
it('leaves a pane open on Escape unless it is asked to close then too', function () {
expect((string) $this->blade('<x-drawer pane>Body</x-drawer>'))
->toContain('x-on:keydown.window.escape="if (open &amp;&amp; ! wide) close()"')
->and((string) $this->blade('<x-drawer pane pane-close-on-escape>Body</x-drawer>'))
->toContain('x-on:keydown.window.escape="if (open) close()"')
->and((string) $this->blade('<x-drawer pane pane-close-on-escape :close-on-escape="false">Body</x-drawer>'))
->not->toContain('keydown.window.escape');
});
it('always offers a way out of a side sheet, as M3 requires', function () {
expect((string) $this->blade('<x-drawer title="Details">Body</x-drawer>'))->toContain('aria-label="Close"')
->and((string) $this->blade('<x-drawer title="Details" :with-close-button="false">Body</x-drawer>'))->not->toContain('aria-label="Close"')
->and((string) $this->blade('<x-drawer title="Details" :with-close-button="false" :close-on-escape="false">Body</x-drawer>'))->toContain('aria-label="Close"')
->and((string) $this->blade('<x-drawer title="Details" :with-close-button="false" without-backdrop-close>Body</x-drawer>'))->toContain('aria-label="Close"')
->and((string) $this->blade('<x-drawer pane :with-close-button="false">Body</x-drawer>'))->toContain('aria-label="Close"')
->and((string) $this->blade('<x-drawer pane pane-close-on-escape :with-close-button="false">Body</x-drawer>'))->not->toContain('aria-label="Close"');
->and((string) $this->blade('<x-drawer standard :with-close-button="false">Body</x-drawer>'))->toContain('aria-label="Close"');
});
it('puts a side sheet\'s actions on the left, in M3\'s 72dp row', function () {
expect((string) $this->blade('<x-drawer title="Details">Body<x-slot:actions><button>Delete</button></x-slot:actions></x-drawer>'))
->toContain('flex min-h-18 shrink-0 flex-wrap items-center justify-start gap-2 pt-4');
->toMatch('/<div\s+data-md-drawer-actions\s*>/');
});
it('slides a side sheet in from either edge, and is a pane from expanded when asked', function () {
it('slides a side sheet in from either edge', function () {
expect((string) $this->blade('<x-drawer title="Details" with-close-button>Body</x-drawer>'))
->toContain('x-trap.inert.noscroll="open && ! wide"')
->toContain('end-0 medium:rounded-s-corner-lg')
->toContain('bg-surface-container-low')
->toContain('data-md-side="end"')
->toContain('data-md-drawer-sheet')
->toContain('role="dialog"')
->toContain('aria-label="Close"')
->and((string) $this->blade('<x-drawer side="start">Body</x-drawer>'))->toContain('start-0 medium:rounded-e-corner-lg')
->and((string) $this->blade('<x-drawer pane pane-width="28rem">Body</x-drawer>'))
->toContain('data-pane')
->toContain('--pane-width: 28rem')
->toContain("matchMedia('(width >= 52.5rem)')");
->and((string) $this->blade('<x-drawer side="start">Body</x-drawer>'))->toContain('data-md-side="start"');
});
it('draws a modal bottom sheet with a drag handle, or a standard one without a scrim', function () {
@@ -234,24 +220,37 @@ it('is M3\'s standard side sheet from expanded, and the modal one below', functi
$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('data-md-standard')
->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('data-md-side="start"')
->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');
->not->toContain('data-md-standard');
expect(File::get(__DIR__.'/../../../resources/css/components/drawer.css'))
->toContain('[data-md-drawer][data-md-standard]')
->toContain('[data-md-drawer][data-md-standard][data-md-drawer-collapsed]')
->toContain('[data-md-drawer][data-md-standard] [data-md-drawer-sheet][data-md-side=\'end\']')
->toContain('[data-md-drawer][data-md-standard] [data-md-drawer-sheet][data-md-side=\'start\']')
->toContain('box-shadow: none;');
});
it('draws the drawer from a stylesheet imported from the containment block, with the pane prop gone', function () {
$view = File::get(__DIR__.'/../../../resources/views/components/drawer.blade.php');
expect($view)
->not->toContain("'pane'")
->not->toContain('paneWidth')
->not->toContain('paneCloseOnEscape')
->not->toContain('pane-width')
->not->toContain('pane-close-on-escape')
->not->toContain('data-pane')
->and(File::get(__DIR__.'/../../../resources/css/components.css'))
->toContain("@import './components/drawer.css';");
});
it('gives a bottom sheet M3\'s preset heights, cycled from the drag handle', function () {