Give a closing standard side sheet's room back as it leaves
From `expanded` a standard side sheet sits in the layout beside the content, and the view took it out of the layout the moment it closed: `data-md-drawer-collapsed` (`display: none` on the root) followed `! open` at once, so its fade was never seen and the content beside it took the sheet's width, and the gap, in one jump — in every engine. M3's side sheets: opening a standard sheet shrinks the body beside it, and closing gives the room back. drawer.css: the standard root is a clipping flex box whose `inline-size` springs between none and the sheet's width, with a negative margin as wide as its flex parent's gap (`--md-drawer-gap`), on the sheet's own exit and entry timings; the sheet keeps its width at the root's far edge, so it is uncovered from its inner edge while it fades. drawer.blade.php: `settle()`, from `x-effect`, measures the gap on each change and holds the collapse (`closing`) for the root's and the sheet's closing durations, read a frame on; reopening lets a pending end go by. The binding reads `collapsed`, which only `settle()` and the window's width write: a binding reading `open` ran before `settle()` in the same flush and, already queued, did not run again once `closing` changed. ContainmentTest closes the showcase's sheet at 1000px and samples, in the page, for the root part-way to none while the column beside it has grown part of the way, then checks the collapse, the column at the row's full width, and a reopen part-way through the exit ending fully open; it fails on the previous code in Chrome, Firefox and Safari. OverlayTest pins the root's declarations and the view's bindings. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
db6023bf80
commit
99c18e01d8
@@ -33,9 +33,15 @@
|
||||
* 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.
|
||||
* sits at `expanded` rather than `medium`. Opening it shrinks the body beside it and closing it
|
||||
* gives the room back (M3's side sheets, "Adaptive"): the root's `inline-size` springs from none to
|
||||
* the sheet's width — the sheet itself keeps its width, sits at the root's far edge and is clipped,
|
||||
* so it is uncovered from its inner edge — together with a negative margin as wide as the flex
|
||||
* parent's gap (`--md-drawer-gap`, which the view measures), so the content beside does not jump by
|
||||
* the gap when the root leaves the layout. `data-md-drawer-collapsed` (the view, while the window is
|
||||
* ≥ 840px and the sheet is closed) then takes the root out of the layout entirely, but only once the
|
||||
* exit has run: the view's `closing` holds it, where it once cut the fade and the resize off on
|
||||
* their first frame.
|
||||
*/
|
||||
|
||||
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
|
||||
@@ -146,9 +152,43 @@
|
||||
[data-md-drawer][data-md-standard] {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
height: 100dvh;
|
||||
flex-shrink: 0;
|
||||
align-self: flex-start;
|
||||
overflow: clip;
|
||||
inline-size: 0;
|
||||
margin-inline-start: calc(-1 * var(--md-drawer-gap, 0px));
|
||||
transition-property: inline-size, margin-inline;
|
||||
transition-duration: var(--md-sys-motion-effects-default-duration);
|
||||
transition-timing-function: var(--md-sys-motion-easing-emphasized-accelerate);
|
||||
}
|
||||
|
||||
/* A start sheet sits at the start edge, with the gap after it. */
|
||||
[data-md-drawer][data-md-standard]:where(:has(> [data-md-drawer-sheet][data-md-side='start'])) {
|
||||
justify-content: flex-start;
|
||||
margin-inline-start: 0;
|
||||
margin-inline-end: calc(-1 * var(--md-drawer-gap, 0px));
|
||||
}
|
||||
|
||||
[data-md-drawer][data-md-standard][data-md-open] {
|
||||
inline-size: min(var(--sheet-width), calc(100vw - 64px));
|
||||
margin-inline: 0;
|
||||
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] {
|
||||
inline-size: 0;
|
||||
margin-inline-start: calc(-1 * var(--md-drawer-gap, 0px));
|
||||
}
|
||||
|
||||
[data-md-drawer][data-md-standard][data-md-open]:where(:has(> [data-md-drawer-sheet][data-md-side='start'])) {
|
||||
margin-inline-start: 0;
|
||||
margin-inline-end: calc(-1 * var(--md-drawer-gap, 0px));
|
||||
}
|
||||
}
|
||||
|
||||
[data-md-drawer][data-md-standard][data-md-drawer-collapsed] {
|
||||
@@ -161,6 +201,7 @@
|
||||
|
||||
[data-md-drawer][data-md-standard] > [data-md-drawer-sheet] {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
top: 0;
|
||||
z-index: auto;
|
||||
height: 100%;
|
||||
|
||||
@@ -23,7 +23,14 @@
|
||||
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.
|
||||
600px window has too little left beside one. Opening one shrinks the body beside it and closing
|
||||
one gives the room back (M3's side sheets, "Adaptive"): the root's inline size, and a negative
|
||||
margin as wide as its flex parent's gap, spring between none and the sheet's width while the
|
||||
sheet fades, and `data-md-drawer-collapsed` — which takes the closed sheet out of the layout —
|
||||
waits for that exit (`closing`, `settle()`) instead of cutting it off on its first frame. It
|
||||
binds `collapsed`, which only `settle()` and the window's width write: a binding that read
|
||||
`open` itself would run before `settle()` in the same flush and, already queued, not run again
|
||||
once `closing` changed.
|
||||
|
||||
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
|
||||
@@ -78,20 +85,66 @@
|
||||
x-data="{
|
||||
@if ($model !== null) open: @entangle($attributes->wire('model')).live, @endif
|
||||
wide: false,
|
||||
collapsed: false,
|
||||
closing: false,
|
||||
closings: 0,
|
||||
wasOpen: null,
|
||||
close() { this.open = typeof this.open === 'boolean' ? false : null; },
|
||||
settle(open) {
|
||||
open = Boolean(open);
|
||||
|
||||
if (this.wasOpen === null || open === this.wasOpen) {
|
||||
this.wasOpen = open;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.wasOpen = open;
|
||||
|
||||
const closing = ++this.closings;
|
||||
const gap = parseFloat(getComputedStyle(this.$el.parentElement).columnGap);
|
||||
|
||||
this.$el.style.setProperty('--md-drawer-gap', Number.isNaN(gap) ? '0px' : gap + 'px');
|
||||
this.closing = ! open;
|
||||
|
||||
if (open) {
|
||||
this.collapsed = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const ms = (value) => parseFloat(value) * (value.trim().endsWith('ms') ? 1 : 1000);
|
||||
const longest = (element) => Math.max(0, ...getComputedStyle(element).transitionDuration.split(',').map(ms));
|
||||
const duration = Math.max(longest(this.$el), ...[...this.$el.children].map(longest));
|
||||
|
||||
setTimeout(() => {
|
||||
if (closing === this.closings) {
|
||||
this.closing = false;
|
||||
this.collapsed = this.wide;
|
||||
}
|
||||
}, duration);
|
||||
});
|
||||
},
|
||||
@if ($standard)
|
||||
init() {
|
||||
const query = window.matchMedia('(width >= 840px)');
|
||||
this.wide = query.matches;
|
||||
query.addEventListener('change', (event) => this.wide = event.matches);
|
||||
this.collapsed = ! this.open && this.wide;
|
||||
query.addEventListener('change', (event) => {
|
||||
this.wide = event.matches;
|
||||
this.collapsed = ! this.open && this.wide && ! this.closing;
|
||||
});
|
||||
},
|
||||
@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"
|
||||
x-bind:data-md-drawer-collapsed="collapsed ? '' : null"
|
||||
x-effect="settle(open)"
|
||||
data-md-drawer
|
||||
@if ($standard) data-md-standard @endif
|
||||
style="--sheet-width: {{ $sheetWidth }}"
|
||||
>
|
||||
{{-- `md-transition` is a class name only to turn on Alpine's CSS transition: `x-show` then keeps
|
||||
the scrim and the sheet displayed for their computed transition-duration (drawer.css's
|
||||
@@ -114,7 +167,6 @@
|
||||
id="{{ $id }}"
|
||||
data-md-drawer-sheet
|
||||
data-md-side="{{ $side }}"
|
||||
style="--sheet-width: {{ $sheetWidth }}"
|
||||
{{ $attributes->whereDoesntStartWith('wire:model')->except(['id']) }}
|
||||
>
|
||||
@if (filled($title) || $closeButton)
|
||||
|
||||
@@ -509,6 +509,62 @@ it('is a standard side sheet from 840px, without a scrim or a focus trap, and th
|
||||
->assertScript("document.querySelector('header').closest('[aria-hidden=\"true\"]') !== null");
|
||||
});
|
||||
|
||||
it('gives a closing standard side sheet\'s room back to the content beside it as it leaves, from 840px', function () {
|
||||
$sheet = "document.querySelector('#containment aside[data-md-side]')";
|
||||
$root = "{$sheet}.parentElement";
|
||||
$column = "{$root}.previousElementSibling";
|
||||
$toggle = "Array.from(document.querySelectorAll('#containment button')).find((button) => button.textContent.trim() === 'Show or hide the filters')";
|
||||
|
||||
// The example starts open, and grows in once Alpine opens it: its entry has run when the sheet
|
||||
// stands at its full 400px and fully shown, with nothing on it still animating.
|
||||
$page = containment()->resize(1000, 800)
|
||||
->assertScript("{$root}.hasAttribute('data-md-open') && Math.round({$root}.getBoundingClientRect().width) === 400 && getComputedStyle({$sheet}).opacity === '1' && {$root}.getAnimations({ subtree: true }).length === 0");
|
||||
|
||||
// Closed and sampled in one round trip: the sheet's root caught part-way between its width
|
||||
// and none, still in the layout, while the column beside it has grown part of the way — not
|
||||
// the whole sheet and its gap handed back in one jump at the end.
|
||||
$midExit = $page->script(<<<JS
|
||||
(async () => {
|
||||
const root = {$root}
|
||||
const column = {$column}
|
||||
const open = { root: root.getBoundingClientRect().width, column: column.getBoundingClientRect().width, row: root.parentElement.getBoundingClientRect().width }
|
||||
{$toggle}.click()
|
||||
|
||||
for (let i = 0; i < 80; i++) {
|
||||
const width = root.getBoundingClientRect().width
|
||||
const columnWidth = column.getBoundingClientRect().width
|
||||
|
||||
if (! root.hasAttribute('data-md-drawer-collapsed') && getComputedStyle(root).display !== 'none' && width > 1 && width < open.root - 1 && columnWidth > open.column + 1 && columnWidth < open.row - 1) return true
|
||||
await new Promise((resolve) => setTimeout(resolve, 5))
|
||||
}
|
||||
return false
|
||||
})()
|
||||
JS);
|
||||
|
||||
expect($midExit)->toBeTrue();
|
||||
|
||||
// Settled: out of the layout, and the column is the whole row, gap included.
|
||||
$page->assertScript("{$root}.hasAttribute('data-md-drawer-collapsed') && getComputedStyle({$root}).display === 'none'")
|
||||
->assertScript("Math.abs({$column}.getBoundingClientRect().width - {$root}.parentElement.getBoundingClientRect().width) < 1");
|
||||
|
||||
// Reopened part-way through its exit, it ends open, not collapsed. Each script stays well under
|
||||
// the browser plugin's one-second call timeout, past which it runs the script a second time.
|
||||
$page->script("{$toggle}.click()");
|
||||
$page->wait(0.6)
|
||||
->assertScript("{$root}.hasAttribute('data-md-open') && {$root}.getAnimations({ subtree: true }).length === 0");
|
||||
|
||||
$page->script(<<<JS
|
||||
(async () => {
|
||||
{$toggle}.click()
|
||||
await new Promise((resolve) => setTimeout(resolve, 60))
|
||||
{$toggle}.click()
|
||||
})()
|
||||
JS);
|
||||
|
||||
$page->wait(0.9)
|
||||
->assertScript("{$root}.hasAttribute('data-md-open') && ! {$root}.hasAttribute('data-md-drawer-collapsed') && Math.abs({$root}.getBoundingClientRect().width - 400) < 1 && getComputedStyle({$sheet}).opacity === '1'");
|
||||
});
|
||||
|
||||
it('opens a row\'s opener from a press anywhere on the row, but not from its own buttons', function () {
|
||||
$page = containment();
|
||||
|
||||
|
||||
@@ -194,6 +194,8 @@ it('puts a side sheet\'s actions on the left, in M3\'s 72dp row', 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('x-bind:data-md-drawer-collapsed="collapsed ? \'\' : null"')
|
||||
->toContain('x-effect="settle(open)"')
|
||||
->toContain('data-md-side="end"')
|
||||
->toContain('data-md-drawer-sheet')
|
||||
->toContain('role="dialog"')
|
||||
@@ -229,6 +231,8 @@ it('is M3\'s standard side sheet from expanded, and the modal one below', functi
|
||||
->toContain('--sheet-width: min(400px, 400px)')
|
||||
->toContain("matchMedia('(width >= 840px)')")
|
||||
->toContain('x-trap.inert.noscroll="open && ! wide"')
|
||||
->toContain('x-bind:data-md-drawer-collapsed="collapsed ? \'\' : null"')
|
||||
->toContain('x-effect="settle(open)"')
|
||||
->toContain('aria-label="Close"')
|
||||
->and((string) $this->blade('<x-drawer standard side="start" width="30rem">Body</x-drawer>'))
|
||||
->toContain('data-md-side="start"')
|
||||
@@ -241,6 +245,18 @@ it('is M3\'s standard side sheet from expanded, and the modal one below', functi
|
||||
|
||||
expect($css->has('[data-md-drawer][data-md-standard]', $expanded))->toBeTrue()
|
||||
->and($css->declarations('[data-md-drawer][data-md-standard][data-md-drawer-collapsed]', $expanded))->toBe(['display' => 'none'])
|
||||
// Closing gives the room back as the sheet leaves (M3's side sheets, "Adaptive"): the root's
|
||||
// inline size and the parent's gap spring to none, and the collapse waits for that exit.
|
||||
->and($css->declarations('[data-md-drawer][data-md-standard]', $expanded))->toMatchArray([
|
||||
'overflow' => 'clip',
|
||||
'inline-size' => '0',
|
||||
'margin-inline-start' => 'calc(-1 * var(--md-drawer-gap, 0px))',
|
||||
'transition-property' => 'inline-size, margin-inline',
|
||||
])
|
||||
->and($css->declarations('[data-md-drawer][data-md-standard][data-md-open]', $expanded))->toMatchArray([
|
||||
'inline-size' => 'min(var(--sheet-width), calc(100vw - 64px))',
|
||||
'margin-inline' => '0',
|
||||
])
|
||||
// The old view's `expanded:hidden` on a standard sheet's scrim.
|
||||
->and($css->declarations('[data-md-drawer][data-md-standard] > [data-md-drawer-scrim]', $expanded))->toBe(['display' => 'none'])
|
||||
->and($css->declarations('[data-md-drawer][data-md-standard] > [data-md-drawer-sheet]', $expanded))->toHaveKey('box-shadow', 'none')
|
||||
|
||||
Reference in New Issue
Block a user