diff --git a/resources/css/components/collapse.css b/resources/css/components/collapse.css index 114ede6d..19fa88da 100644 --- a/resources/css/components/collapse.css +++ b/resources/css/components/collapse.css @@ -4,12 +4,13 @@ * state through a Livewire morph, and is announced as a disclosure by the browser. Judged against * foundations rather than any M3 component spec (docs/audits/m3-alignment/containment.md, C-24). * - * `interpolate-size: allow-keywords` makes `
`'s `0 -> auto` block-size an animatable pair, - * so `::details-content` (the pseudo-element that holds everything after the summary) can transition - * `block-size` on the fast spatial spring, the same one that turns the chevron; `content-visibility` - * goes with it, `allow-discrete`, so the content stays rendered while it closes rather than - * vanishing on the first frame. Under reduced motion the duration token is zero - * (tokens/motion.css), so the section snaps open with nothing else to do. + * The height eases open and shut on the fast spatial spring, the same one that turns the chevron, + * from resources/js/collapse.js: it animates the `
`' own `block-size`. This file cannot — + * `interpolate-size` (for `0 -> auto`) is Chrome's alone, and Firefox does not hold the content's + * `content-visibility` through a close — so Firefox and Safari used to snap open and shut. While a + * close runs, `open` is still set and `data-md-collapse-closing` turns the chevron back at its + * start. Under reduced motion the duration token is zero (tokens/motion.css), and the section + * snaps, as the browser draws it. * * `data-md-variant="filled"` is a surface-container tile with a large corner; the summary inherits * it (`border-radius: inherit`) so the shared `md-state-layer`'s own `::before`, which also @@ -27,27 +28,11 @@ @import './icon.css'; @layer material.components { - [data-md-collapse] { - interpolate-size: allow-keywords; - } - [data-md-collapse][data-md-variant='filled'] { border-radius: var(--md-sys-shape-corner-lg); background-color: var(--md-sys-color-surface-container); } - [data-md-collapse]::details-content { - block-size: 0; - overflow: hidden; - transition: - block-size var(--md-sys-motion-spatial-fast-duration) var(--md-sys-motion-spatial-fast), - content-visibility var(--md-sys-motion-spatial-fast-duration) allow-discrete; - } - - [data-md-collapse][open]::details-content { - block-size: auto; - } - [data-md-collapse-summary] { display: flex; min-block-size: var(--md-sys-measurement-space600); @@ -82,7 +67,7 @@ transition: rotate var(--md-sys-motion-spatial-fast-duration) var(--md-sys-motion-spatial-fast); } - [data-md-collapse][open] > [data-md-collapse-summary] > [data-md-collapse-chevron] { + [data-md-collapse][open]:not([data-md-collapse-closing]) > [data-md-collapse-summary] > [data-md-collapse-chevron] { rotate: 180deg; } diff --git a/resources/js/collapse.js b/resources/js/collapse.js new file mode 100644 index 00000000..36b406ea --- /dev/null +++ b/resources/js/collapse.js @@ -0,0 +1,212 @@ +/** + * ``'s height, eased open and shut on the fast spatial spring in every engine. + * + * collapse.css used to animate `
`' `::details-content` from `block-size: 0` to `auto`: + * that takes `interpolate-size: allow-keywords` (Chrome only) and a `content-visibility` that holds + * through the close (not Firefox), so Firefox and Safari snapped open and shut. A script can do what + * the stylesheet cannot, the same way everywhere: the `
` itself runs a Web Animation of its + * `block-size`, from the height it is drawn at to the height it is going to, with `overflow: clip` + * for as long as it runs. Content below it moves with it; nothing is written into its `style`. + * + * - Open: `open` is set at once — the content is there, `toggle` fires, the chevron turns — and the + * height grows from where it was to the section's natural height. + * - Close: `open` stays set while the height shrinks to the summary's, so the content is still there + * to be clipped; `data-md-collapse-closing` turns the chevron back at the start, and `open` goes + * (and `toggle` fires) when the height has. `min-block-size` holds the summary whole while the + * spring overshoots. + * - A press mid-way turns it round from the height it has reached. + * - A `name` group closes its open member with the same animation: that member's `name` is lifted + * for the close, so the browser's own exclusivity does not shut it on the first frame, and put + * back once it has closed. + * + * Routes: a press on the summary (a pointer, Enter or Space, all a `click`) is taken over here; the + * Alpine and Livewire bindings call `materialCollapse(details, open)` from the view's `x-effect`. + * Anything else that sets `open` — find-in-page revealing a match, an application's own script — + * opens or closes it at once, as the browser does, and this follows along. Under reduced motion the + * duration token is zero and every route is the browser's own, instant. + */ +const COLLAPSE = 'details[data-md-collapse]' +const CLOSING = 'data-md-collapse-closing' + +/** Where each collapse is headed (`true` open), its running animation, and a `name` lifted for its close. */ +const targets = new WeakMap() +const animations = new WeakMap() +const lifted = new WeakMap() + +const milliseconds = (value) => parseFloat(value) * (value.trim().endsWith('ms') ? 1 : 1000) || 0 + +/** The height the section is drawn at, closed: its summary, and its own padding and border. */ +const closedHeight = (details) => { + const style = getComputedStyle(details) + const summary = details.querySelector(':scope > summary') + + return ( + (summary?.getBoundingClientRect().height ?? 0) + + parseFloat(style.paddingBlockStart) + + parseFloat(style.paddingBlockEnd) + + parseFloat(style.borderBlockStartWidth) + + parseFloat(style.borderBlockEndWidth) + ) +} + +const restoreName = (details) => { + if (lifted.has(details)) { + details.setAttribute('name', lifted.get(details)) + lifted.delete(details) + } +} + +/** Moves the height from where it is drawn now to `to`, then `done`. Returns false under reduced motion. */ +const animate = (details, from, to, done) => { + const style = getComputedStyle(details) + const duration = milliseconds(style.getPropertyValue('--md-sys-motion-spatial-fast-duration')) + + if (duration === 0) { + return false + } + + const floor = `${closedHeight(details)}px` + const animation = details.animate( + [ + { blockSize: `${from}px`, minBlockSize: floor, overflow: 'clip' }, + { blockSize: `${to}px`, minBlockSize: floor, overflow: 'clip' }, + ], + { duration, easing: style.getPropertyValue('--md-sys-motion-spatial-fast').trim() || 'ease', fill: 'forwards' }, + ) + + animations.set(details, animation) + animation.onfinish = () => { + if (animations.get(details) !== animation) { + return + } + + animations.delete(details) + done() + animation.cancel() + } + + return true +} + +/** The height the section is drawn at this moment, then without whatever animation was running. */ +const settle = (details) => { + const height = details.getBoundingClientRect().height + + animations.get(details)?.cancel() + animations.delete(details) + + return height +} + +const open = (details) => { + targets.set(details, true) + + const from = settle(details) + + details.removeAttribute(CLOSING) + + // The group's open member closes on the same spring, with its `name` lifted so the browser's + // exclusivity does not shut it the moment this one opens; this one's own name comes back + // after, when no other member of the group is open to be closed by it. + const name = details.getAttribute('name') ?? lifted.get(details) + + if (name) { + details + .getRootNode() + .querySelectorAll(`${COLLAPSE}[open]`) + .forEach((other) => { + if (other !== details && (other.getAttribute('name') ?? lifted.get(other)) === name && targets.get(other) !== false) { + close(other) + } + }) + } + + restoreName(details) + details.open = true + + animate(details, from, details.getBoundingClientRect().height, () => {}) +} + +const close = (details) => { + targets.set(details, false) + + const from = settle(details) + const to = closedHeight(details) + + const shut = () => { + details.removeAttribute(CLOSING) + details.open = false + restoreName(details) + } + + if (details.hasAttribute('name')) { + lifted.set(details, details.getAttribute('name')) + details.removeAttribute('name') + } + + details.setAttribute(CLOSING, '') + + if (!animate(details, from, to, shut)) { + shut() + } +} + +/** + * Opens (`true`) or closes a collapse on its spring; what the view's bindings call. The first call + * for a collapse, when Alpine starts and applies the bound value, sets it at once: nothing should + * move on page load. + */ +window.materialCollapse = (details, shouldOpen) => { + shouldOpen = Boolean(shouldOpen) + + if (!targets.has(details)) { + targets.set(details, details.open) + + if (details.open !== shouldOpen) { + details.open = shouldOpen + targets.set(details, shouldOpen) + } + + return + } + + if (targets.get(details) === shouldOpen) { + return + } + + shouldOpen ? open(details) : close(details) +} + +document.addEventListener('click', (event) => { + const summary = event.target instanceof Element ? event.target.closest('summary') : null + const details = summary?.parentElement + + if (event.defaultPrevented || !details?.matches(COLLAPSE) || summary !== details.querySelector(':scope > summary')) { + return + } + + if (milliseconds(getComputedStyle(details).getPropertyValue('--md-sys-motion-spatial-fast-duration')) === 0) { + return + } + + event.preventDefault() + + const headedOpen = targets.has(details) ? targets.get(details) : details.open + + headedOpen ? close(details) : open(details) +}) + +// Whatever else sets `open` (find-in-page, a `name` group this script did not close, an +// application's own script) is followed, unless this script is the one mid-way through a change. +document.addEventListener( + 'toggle', + (event) => { + const details = event.target + + if (details instanceof HTMLDetailsElement && details.matches(COLLAPSE) && !animations.has(details)) { + targets.set(details, details.open) + details.removeAttribute(CLOSING) + } + }, + true, +) diff --git a/resources/js/material.js b/resources/js/material.js index 48e45b8e..abe19df2 100644 --- a/resources/js/material.js +++ b/resources/js/material.js @@ -19,6 +19,7 @@ import './progress.js' import './list-rows.js' import './bottom-sheet.js' import './dialog.js' +import './collapse.js' import './carousel.js' import './chips.js' import './field.js' diff --git a/resources/views/components/collapse.blade.php b/resources/views/components/collapse.blade.php index fe11a05e..85cf26e3 100644 --- a/resources/views/components/collapse.blade.php +++ b/resources/views/components/collapse.blade.php @@ -4,8 +4,10 @@ state through a Livewire morph (`wire:ignore.self` stops the server's HTML from closing it), and is announced as a disclosure. Drawn in M3's terms: a title-medium `title` (or a `heading` slot), an optional leading `icon`, a chevron that turns over on the spatial spring, - and a height that eases open on the same spring - (`data-md-collapse`, resources/css/components/collapse.css). `open` starts it open. + and a height that eases open and shut on the same spring in every engine + (`data-md-collapse`, resources/css/components/collapse.css, and resources/js/collapse.js, + which animates the `
` itself because only Chrome can animate its content's height + from CSS). `open` starts it open. `variant`: `plain` (the default, on the surface around it) or `filled` (a surface-container tile with a large corner). @@ -48,7 +50,7 @@ @if ($bound) x-data="{ collapseOpen: @if ($model !== null) @entangle($attributes->wire('model')) @else @js($expanded) @endif }" @if ($model === null) x-modelable="collapseOpen" @endif - x-effect="$el.open = collapseOpen" + x-effect="materialCollapse($el, collapseOpen)" x-on:toggle="collapseOpen = $el.open" @endif @if ($expanded) open @endif diff --git a/tests/Browser/ContainmentTest.php b/tests/Browser/ContainmentTest.php index a904d0f5..fae25237 100644 --- a/tests/Browser/ContainmentTest.php +++ b/tests/Browser/ContainmentTest.php @@ -873,3 +873,261 @@ it('keeps a selected segmented row\'s fill under the hover and focus tint', func expect($selectedFocusBackground)->not->toBe($restBackground) ->and($selectedFocusBackground)->not->toBe($selectedHoverBackground); }); + +/** + * Collapses to watch move: one on its own with a paragraph under it, one bound to Alpine, and a + * `name` group of two with the first open. Each body is tall enough for a height caught part-way + * to be told from both ends. + */ +function collapseMotionProbe(string $durations = ''): mixed +{ + Route::middleware('web')->get('/collapse-motion-probe', fn () => Blade::render(<<<'BLADE' + + + + + @vite(config('livewire-material.showcase.vite')) + @livewireStyles + + + +

Until the expiry the sender chose, at most thirty days.

+
+

Under the collapse.

+ +
+ +

Everything else.

+
+ + + +
+ + +

The first answer.

+
+ +

The second answer.

+
+ @livewireScripts + + + BLADE)); + + $page = visit('/collapse-motion-probe')->waitForEvent('networkidle') + ->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined'"); + + if ($durations !== '') { + $page->script("document.head.insertAdjacentHTML('beforeend', '')"); + } + + return $page; +} + +/** + * Runs `$act` in the page and samples `$details` every frame until its animation has run: whether + * its height was ever caught strictly between where it started and where it ended, what `open` and + * the closing mark said as it began, whether the chevron was caught turning, and how it ended. In one round trip, because a separate read + * costs about as long as the 350ms spring. + */ +function collapseMotion(mixed $page, string $act, string $details): array +{ + return $page->script(<< { + const details = document.querySelector('{$details}') + const chevron = details.querySelector('[data-md-collapse-chevron]') + const below = details.nextElementSibling + const start = details.getBoundingClientRect().height + const belowStart = below?.getBoundingClientRect().top ?? 0 + const heights = [] + const belows = [] + + {$act} + + // A binding reaches the collapse from Alpine's own flush, a microtask on; a task later + // both routes have begun, and the close's attribute and `open` hold for its whole run. + await new Promise((resolve) => setTimeout(resolve, 0)) + + const first = { open: details.open, closing: details.hasAttribute('data-md-collapse-closing') } + let chevronTurning = false + + for (let i = 0; i < 120; i++) { + await new Promise((resolve) => requestAnimationFrame(resolve)) + heights.push(details.getBoundingClientRect().height) + belows.push(below?.getBoundingClientRect().top ?? 0) + const rotate = getComputedStyle(chevron).rotate + chevronTurning ||= rotate !== 'none' && rotate !== '180deg' && rotate !== '0deg' + if (i > 5 && details.getAnimations().length === 0) break + } + + const end = details.getBoundingClientRect().height + const low = Math.min(start, end) + const high = Math.max(start, end) + + return { + between: heights.some((height) => height > low + 2 && height < high - 2), + belowMoved: belows.some((top) => top > Math.min(belowStart, belows.at(-1)) + 2 && top < Math.max(belowStart, belows.at(-1)) - 2), + first, + chevronTurning, + start, + end, + open: details.open, + closing: details.hasAttribute('data-md-collapse-closing'), + style: details.getAttribute('style'), + animations: details.getAnimations().length, + } + })() + JS); +} + +it('eases a collapse open and shut in every engine, moving what is under it', function () { + $page = collapseMotionProbe(); + + $opened = collapseMotion($page, "details.querySelector('summary').click()", '#plain'); + + expect($opened['first']['open'])->toBeTrue() + ->and($opened['between'])->toBeTrue() + ->and($opened['belowMoved'])->toBeTrue() + ->and($opened['open'])->toBeTrue() + ->and($opened['end'])->toBeGreaterThan($opened['start'] + 200) + ->and($opened['style'])->toBeNull() + ->and($opened['animations'])->toBe(0); + + $closed = collapseMotion($page, "details.querySelector('summary').click()", '#plain'); + + // Still open while the height goes, so there is content to clip; the chevron turns back at once. + expect($closed['first'])->toBe(['open' => true, 'closing' => true]) + ->and($closed['chevronTurning'])->toBeTrue() + ->and($closed['between'])->toBeTrue() + ->and($closed['belowMoved'])->toBeTrue() + ->and($closed['open'])->toBeFalse() + ->and($closed['closing'])->toBeFalse() + ->and($closed['end'])->toBe($opened['start']) + ->and($closed['style'])->toBeNull() + ->and($closed['animations'])->toBe(0); + + // Clipped while it moves, so the content never shows past the edge that is moving. + $clipped = $page->script(<<<'JS' + (async () => { + const details = document.querySelector('#plain') + details.querySelector('summary').click() + await new Promise((resolve) => requestAnimationFrame(resolve)) + const overflow = getComputedStyle(details).overflowY + await Promise.all(details.getAnimations().map((animation) => animation.finished)) + return overflow + })() + JS); + + expect($clipped)->toBe('clip'); + + // From the keyboard: Enter on the summary is the same press, taken over the same way. + $page->script("document.querySelector('#plain summary').focus()"); + $page->keys('#plain summary', 'Enter') + ->assertScript("document.querySelector('#plain').open === false && document.querySelector('#plain').getAnimations().length === 0"); +}); + +it('eases a collapse bound to Alpine open and shut, keeping the binding in step', function () { + $page = collapseMotionProbe(); + + $opened = collapseMotion($page, "document.querySelector('#open-bound').click()", '#bound'); + + expect($opened['between'])->toBeTrue() + ->and($opened['open'])->toBeTrue(); + + $page->assertSeeIn('#advanced', 'true'); + + $closed = collapseMotion($page, "document.querySelector('#close-bound').click()", '#bound'); + + expect($closed['first']['closing'])->toBeTrue() + ->and($closed['between'])->toBeTrue() + ->and($closed['open'])->toBeFalse() + ->and($closed['end'])->toBe($opened['start']); + + $page->assertSeeIn('#advanced', 'false'); + + // A press on the summary tells the binding when the section has closed, and never loops. + $page->click('#bound summary') + ->assertSeeIn('#advanced', 'true') + ->assertScript("document.querySelector('#bound').open === true"); +}); + +it('turns a collapse round from the height it has reached, ending as the last press asked', function () { + $page = collapseMotionProbe('1500ms'); + + $result = $page->script(<<<'JS' + (async () => { + const details = document.querySelector('#plain') + const summary = details.querySelector('summary') + const closed = details.getBoundingClientRect().height + const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) + + summary.click() + await wait(300) + const midway = details.getBoundingClientRect().height + summary.click() + await new Promise((resolve) => requestAnimationFrame(resolve)) + const turned = details.getBoundingClientRect().height + + await wait(1700) + + return { closed, midway, turned, end: details.getBoundingClientRect().height, open: details.open, style: details.getAttribute('style') } + })() + JS); + + expect($result['midway'])->toBeGreaterThan($result['closed'] + 2) + // Turned round from where it was, not from either end. + ->and(abs($result['turned'] - $result['midway']))->toBeLessThan(40) + ->and($result['open'])->toBeFalse() + ->and($result['end'])->toBe($result['closed']) + ->and($result['style'])->toBeNull(); +}); + +it('closes the open member of a name group on the same spring as the one opening', function () { + $page = collapseMotionProbe(); + + $first = "document.querySelector('#first')"; + + $result = $page->script(<<<'JS' + (async () => { + const first = document.querySelector('#first') + const second = document.querySelector('#second') + const start = first.getBoundingClientRect().height + let caught = false + + second.querySelector('summary').click() + const stillOpen = first.open + + for (let i = 0; i < 120; i++) { + await new Promise((resolve) => requestAnimationFrame(resolve)) + const height = first.getBoundingClientRect().height + caught ||= first.open && height > 60 && height < start - 2 + if (i > 5 && first.getAnimations().length === 0 && second.getAnimations().length === 0) break + } + + return { stillOpen, caught, first: first.open, second: second.open, name: first.getAttribute('name') } + })() + JS); + + expect($result)->toBe(['stillOpen' => true, 'caught' => true, 'first' => false, 'second' => true, 'name' => 'faq']); + + // The group still keeps one open: the browser's own exclusivity, with the name back. + $page->script("document.querySelector('#first').open = true"); + $page->assertScript("{$first}.open === true && document.querySelector('#second').open === false"); +}); + +it('opens and closes a collapse at once under reduced motion, leaving nothing behind', function () { + $page = collapseMotionProbe('0ms'); + + $result = $page->script(<<<'JS' + (() => { + const details = document.querySelector('#plain') + details.querySelector('summary').click() + const opened = { open: details.open, animations: details.getAnimations().length } + details.querySelector('summary').click() + return { opened, open: details.open, animations: details.getAnimations().length, closing: details.hasAttribute('data-md-collapse-closing'), style: details.getAttribute('style') } + })() + JS); + + expect($result)->toBe(['opened' => ['open' => true, 'animations' => 0], 'open' => false, 'animations' => 0, 'closing' => false, 'style' => null]); +}); diff --git a/tests/Feature/Components/CollapseTest.php b/tests/Feature/Components/CollapseTest.php index 2ca7d47d..b2508f9f 100644 --- a/tests/Feature/Components/CollapseTest.php +++ b/tests/Feature/Components/CollapseTest.php @@ -39,7 +39,7 @@ it('binds its open state through x-model, drawing the open prop until Alpine sta ->toMatch('/x-data="{ collapseOpen:\s*true\s*}"/') ->toContain('x-modelable="collapseOpen"') ->toContain('x-model="advanced"') - ->toContain('x-effect="$el.open = collapseOpen"') + ->toContain('x-effect="materialCollapse($el, collapseOpen)"') ->toContain('x-on:toggle="collapseOpen = $el.open"') ->toMatch('/\sopen\s/'); }); @@ -72,6 +72,6 @@ it('draws a filled collapse\'s padding and an open one\'s chevron on its own par expect($css->declarations("[data-md-collapse][data-md-variant='filled'] > [data-md-collapse-summary]"))->toBe(['padding-inline' => 'var(--md-sys-measurement-space200)']) ->and($css->declarations("[data-md-collapse][data-md-variant='filled'] > [data-md-collapse-body]"))->toBe(['padding-inline' => 'var(--md-sys-measurement-space200)']) - ->and($css->declarations('[data-md-collapse][open] > [data-md-collapse-summary] > [data-md-collapse-chevron]'))->toBe(['rotate' => '180deg']) + ->and($css->declarations('[data-md-collapse][open]:not([data-md-collapse-closing]) > [data-md-collapse-summary] > [data-md-collapse-chevron]'))->toBe(['rotate' => '180deg']) ->and($css->declarations('[data-md-collapse-summary]'))->toHaveKey('gap', '12px'); });