diff --git a/resources/css/components/drawer.css b/resources/css/components/drawer.css index 47539517..4795bf53 100644 --- a/resources/css/components/drawer.css +++ b/resources/css/components/drawer.css @@ -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%; diff --git a/resources/views/components/drawer.blade.php b/resources/views/components/drawer.blade.php index 271b91c3..04d44c34 100644 --- a/resources/views/components/drawer.blade.php +++ b/resources/views/components/drawer.blade.php @@ -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 `` 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) diff --git a/tests/Browser/ContainmentTest.php b/tests/Browser/ContainmentTest.php index fae25237..c9256538 100644 --- a/tests/Browser/ContainmentTest.php +++ b/tests/Browser/ContainmentTest.php @@ -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(<< { + 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(<< { + {$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(); diff --git a/tests/Feature/Components/OverlayTest.php b/tests/Feature/Components/OverlayTest.php index 2a45d6f0..3fc6a16a 100644 --- a/tests/Feature/Components/OverlayTest.php +++ b/tests/Feature/Components/OverlayTest.php @@ -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('Body')) ->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('Body')) ->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')