diff --git a/UPGRADE.md b/UPGRADE.md index 150c13bc..1225dfaf 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -41,6 +41,14 @@ customizable `` while its menu is open. An open select in error now draws `error` rather than `primary`. A test that read the hover colour off a focused field reads `primary` now. +- **Escape closes one layer.** A dialog, menu, customizable select or searchable choice opened + over or inside a modal ``, `` or modal navigation rail — or a sheet + opened from a sheet, or inside `` — closed the layer under it on the same Escape, since + each sheet closed on any Escape the window heard. Now only the topmost layer closes; a sheet + with `close-on-escape` off still keeps the Escape from closing what is under it. A dialog or + sheet opened from a modal sheet but rendered elsewhere on the page was also hidden from screen + readers by the sheet's `aria-hidden`, and Tab could not move inside the dialog; both work now. + An application's own guard around a sheet's Escape can go. ## From 2.0.0 to 2.1.0 diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md index ffea37a6..0b04a91b 100644 --- a/resources/boost/skills/livewire-material-development/SKILL.md +++ b/resources/boost/skills/livewire-material-development/SKILL.md @@ -583,6 +583,8 @@ Props: `title`, `subtitle`, `icon` (centred hero icon), `separator` (draw the di An M3 side sheet, bound like ``; `close()` in scope. Props: `title`, `subtitle`, `separator`, `side` (`end` default, `start`; mirrored in RTL), `width` (`400px`), `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 `` instead — `` no longer has a `pane` prop. Its body is a size container — lay out inside it with a container query in your own CSS (`@container (width >= 28rem)`), never a window size class. +Modal surfaces stack: a ``, menu, select or searchable choice opened inside or over a modal side sheet, bottom sheet or the modal rail — or one sheet opened from another, nested or rendered elsewhere on the page — takes its own Escape, so one press closes one layer, and the layer on top stays readable to a screen reader while the sheet under it keeps the rest of the page hidden. Render the dialog wherever suits the page; nothing needs to be moved inside the sheet. + `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` (``, or a caller's own row). ### `` diff --git a/resources/js/dialog.js b/resources/js/dialog.js index 51248f4b..ed325e52 100644 --- a/resources/js/dialog.js +++ b/resources/js/dialog.js @@ -14,6 +14,8 @@ * one it opens to), and the element the component wraps around the slot when a morph, an image or * a disclosure makes the content taller or shorter while the body stays at its cap. */ +import { expose } from './layers.js' + /** * `materialShowModal(dialog)`, how `` opens: `showModal()`, and then the first focus Chrome * gives a text-only dialog and Firefox and WebKit do not. With nothing focusable inside, @@ -23,8 +25,13 @@ * WebKit never lets Tab reach a scroll container — so when the dialog took the focus itself and its * body overflows, the body is made focusable and takes it, as in Chrome, where modal.css draws the * inset ring. The `tabindex` goes when the dialog closes, so each opening decides afresh. + * + * A dialog opened from a modal sheet but rendered outside it sits inside what the sheet's + * `x-trap.inert` hid from assistive technology, so it lifts that for as long as it is open + * (layers.js). */ window.materialShowModal = (dialog) => { + dialog.addEventListener('close', expose(dialog), { once: true }) dialog.showModal() const body = dialog.querySelector(':scope > [data-md-modal-box] > [data-md-modal-body]') diff --git a/resources/js/layers.js b/resources/js/layers.js new file mode 100644 index 00000000..b3c5a69f --- /dev/null +++ b/resources/js/layers.js @@ -0,0 +1,138 @@ +/** + * Layers: how the modal surfaces stack, so one Escape dismisses one layer and the layer on top is + * never hidden from assistive technology. The modal panels are ``'s sheet below + * `expanded`, ``'s and the navigation rail's while it is open over the page, each + * trapped with `x-trap.inert`; above or below them can be ``'s native ``, and + * inside any of them a menu, a customizable select or another list. + * + * `x-layer="expression"` goes on a modal panel, beside its `x-trap`, and does two things while the + * expression is true. + * + * **Escape.** Each panel used to close on any Escape the window heard, so a dialog opened from a + * sheet, a sheet opened from a sheet, a sheet inside a dialog or a list inside a sheet closed two + * layers at once. An Escape is the panel's only when nothing has handled it yet + * (`defaultPrevented`: a searchable choice's list, the date picker, the search view, a panel on top) + * and the nearest open layer around its target is the panel itself — not an open ``, a + * popover (a menu, which its own light dismiss closes) or a customizable select's list, and not + * another panel inside this one. A target in no layer at all (focus dropped to the body) belongs to + * the panel opened last. The panel then claims the Escape with `preventDefault()`, which also keeps + * a `` around the panel from cancelling, and dispatches `material-escape` on itself for the + * view to close on; a view that keeps its panel open on Escape still claims it, so nothing under + * the panel closes in its place. + * + * **The accessibility tree.** `x-trap.inert` hides every sibling of the panel and of each of its + * ancestors (`aria-hidden`). That is right for the page under the panel and wrong for a layer + * opened above it from elsewhere in the document — a dialog rendered outside the sheet, or a second + * sheet beside the first — which sits inside one of those siblings. So a layer that opens lifts + * `aria-hidden` from its own ancestors (`expose()`), and when it closes puts it back only where a + * panel that is still open hides that element. `` exposes its dialog the same way + * (materialShowModal(), dialog.js); a modal `` keeps the rest of the page from assistive + * technology itself, and its own `x-trap` pauses the focus trap of the panel under it, whose Tab + * would otherwise pull the focus back to an inert sheet. + */ + +/** Everything that can hold an Escape before the window hears it, nearest first. */ +const LAYERS = 'dialog, [popover], select, [aria-modal="true"], [data-md-navigation-rail-panel]' + +/** The panels that are open, in the order they opened. */ +const panels = [] + +const isOpen = (layer) => { + if (layer.matches('dialog')) { + return layer.open + } + + if (layer.matches('[popover]')) { + return layer.matches(':popover-open') + } + + if (layer.matches('select')) { + try { + return layer.matches(':open') + } catch { + // No customizable select, so no list of the page's own to hold the Escape. + return false + } + } + + if (layer.matches('[data-md-navigation-rail-panel]')) { + return panels.includes(layer) + } + + return true +} + +/** Whether an Escape keydown is this open panel's to act on (see the header). */ +const owns = (panel, event) => { + if (event.defaultPrevented) { + return false + } + + let layer = event.target instanceof Element ? event.target.closest(LAYERS) : null + + while (layer !== null && !isOpen(layer)) { + layer = layer.parentElement?.closest(LAYERS) ?? null + } + + return layer === null ? panels.at(-1) === panel : layer === panel +} + +/** + * Lifts `aria-hidden` from `layer`'s ancestors; the function it returns puts it back on those a + * panel still open hides — a sibling of that panel or of one of its ancestors, as x-trap.inert + * hides them. + */ +export const expose = (layer) => { + const lifted = [] + + for (let element = layer.parentElement; element !== null && element !== document.body; element = element.parentElement) { + if (element.getAttribute('aria-hidden') === 'true') { + element.removeAttribute('aria-hidden') + lifted.push(element) + } + } + + return () => lifted + .filter((element) => panels.some((panel) => panel !== layer && !element.contains(panel) && element.parentElement?.contains(panel))) + .forEach((element) => element.setAttribute('aria-hidden', 'true')) +} + +document.addEventListener('alpine:init', () => { + window.Alpine.directive('layer', window.Alpine.skipDuringClone((el, { expression }, { effect, evaluateLater, cleanup }) => { + const active = evaluateLater(expression) + let restore = null + + const release = () => { + if (restore === null) { + return + } + + panels.splice(panels.indexOf(el), 1) + restore() + restore = null + } + + const escape = (event) => { + if (restore !== null && event.key === 'Escape' && owns(el, event)) { + event.preventDefault() + el.dispatchEvent(new CustomEvent('material-escape')) + } + } + + effect(() => active((value) => { + if (value && restore === null) { + panels.push(el) + restore = expose(el) + } else if (!value) { + release() + } + })) + + window.addEventListener('keydown', escape) + + cleanup(() => { + window.removeEventListener('keydown', escape) + release() + }) + })) +}) diff --git a/resources/js/material.js b/resources/js/material.js index abe19df2..a0e79984 100644 --- a/resources/js/material.js +++ b/resources/js/material.js @@ -17,6 +17,7 @@ import './snackbar.js' import './rich-tooltip.js' import './progress.js' import './list-rows.js' +import './layers.js' import './bottom-sheet.js' import './dialog.js' import './collapse.js' diff --git a/resources/views/components/bottom-sheet.blade.php b/resources/views/components/bottom-sheet.blade.php index 23cffe5d..fd9c2262 100644 --- a/resources/views/components/bottom-sheet.blade.php +++ b/resources/views/components/bottom-sheet.blade.php @@ -98,7 +98,6 @@ ...materialBottomSheet({{ $standard ? 'true' : 'false' }}, {{ $presets }}), @if ($model !== null) open: @entangle($attributes->wire('model')).live, @endif }" - x-on:keydown.window.escape="if (open && ! standard) close()" x-bind:data-md-open="open ? '' : null" data-md-bottom-sheet @if ($standard) data-md-standard @endif @@ -121,7 +120,7 @@ x-transition:enter="md-transition" x-transition:leave="md-transition" x-ref="sheet" - @unless ($standard) x-trap.inert.noscroll="open" @endunless + @unless ($standard) x-trap.inert.noscroll="open" x-layer="open" x-on:material-escape="close()" @endunless x-bind:style="sheetStyle" x-on:pointerdown="dragStart($event)" id="{{ $id }}" diff --git a/resources/views/components/choices.blade.php b/resources/views/components/choices.blade.php index fb1e21bb..1a4b27d5 100644 --- a/resources/views/components/choices.blade.php +++ b/resources/views/components/choices.blade.php @@ -159,7 +159,7 @@ x-on:keydown.home="jump($event, false)" x-on:keydown.end="jump($event, true)" x-on:keydown.enter.prevent="choose(filtered[active])" - x-on:keydown.escape="close()" + x-on:keydown.escape="if (open) $event.preventDefault(); close()" x-on:keydown.tab="close()" x-on:blur="close()" /> diff --git a/resources/views/components/drawer.blade.php b/resources/views/components/drawer.blade.php index 043aea01..4f062730 100644 --- a/resources/views/components/drawer.blade.php +++ b/resources/views/components/drawer.blade.php @@ -6,7 +6,10 @@ 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 + As a sheet it is modal: the page inert and still (`x-trap.inert.noscroll`), and Escape closes + only the topmost layer — a dialog, menu or list open above or inside the sheet takes its own, + and a sheet or dialog opened from this one stays readable to a screen reader + (`x-layer`, resources/js/layers.js). 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 (docs/reference/m3/components-actions-communication- containment.md § Side sheets; Compose has no side-sheet token file): surface-container-low, a @@ -149,7 +152,6 @@ }, @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="collapsed ? '' : null" x-bind:data-md-drawer-settled="settled ? '' : null" @@ -171,6 +173,8 @@ x-transition:enter="md-transition" x-transition:leave="md-transition" x-trap.inert.noscroll="open && ! wide" + x-layer="open && ! wide" + @if ($closeOnEscape) x-on:material-escape="close()" @endif x-bind:role="wide ? 'region' : 'dialog'" x-bind:aria-modal="wide ? null : 'true'" role="dialog" diff --git a/resources/views/components/modal.blade.php b/resources/views/components/modal.blade.php index 7207458c..aca010cd 100644 --- a/resources/views/components/modal.blade.php +++ b/resources/views/components/modal.blade.php @@ -1,7 +1,9 @@ {{-- An M3 dialog, on the native `` opened with `showModal()`. Native because it gets the hard parts right on its own: the top layer above everything, the - rest of the page inert, focus moved in and handed back, Escape. The open state is the Livewire + rest of the page inert, focus moved in and handed back, Escape. Its `x-trap` moves no focus of + its own; it is there to pause the focus trap of a sheet the dialog opened over, which would + otherwise take every Tab back to the inert sheet (resources/js/layers.js). The open state is the Livewire property in `wire:model` (entangled live) — a flag (`$confirmingDelete`) or an id (`$deletingShareId`) — and closing writes back whichever "closed" means for it, `false` or `null`. Without `wire:model` it reads and writes `open` in the Alpine scope around it: @@ -97,6 +99,7 @@ x-data="{ close() { this.open = false } }" @endif x-effect="open ? ($el.open || materialShowModal($el)) : ($el.open && $el.close())" + x-trap.noautofocus.noreturn="open" x-on:cancel.prevent="{{ $persistent ? '' : 'close()' }}" x-on:close="if (open) close()" @if (! $persistent) x-on:click.self="close()" @endif diff --git a/resources/views/components/navigation-rail.blade.php b/resources/views/components/navigation-rail.blade.php index 9c5df837..abe604c7 100644 --- a/resources/views/components/navigation-rail.blade.php +++ b/resources/views/components/navigation-rail.blade.php @@ -145,7 +145,8 @@ aria-label="{{ $label ?? __('Main') }}" @if ($canOpen) x-trap.inert.noscroll="open" - x-on:keydown.escape.window="open && $store.rail.hide()" + x-layer="open" + x-on:material-escape="$store.rail.hide()" @endif > @if ($menu || isset($brand) || isset($header)) diff --git a/resources/views/components/search.blade.php b/resources/views/components/search.blade.php index 16c6cd9d..872723b0 100644 --- a/resources/views/components/search.blade.php +++ b/resources/views/components/search.blade.php @@ -57,7 +57,7 @@
@if ($sheet) diff --git a/tests/Browser/ContainmentTest.php b/tests/Browser/ContainmentTest.php index abc2f910..c49a77b2 100644 --- a/tests/Browser/ContainmentTest.php +++ b/tests/Browser/ContainmentTest.php @@ -565,6 +565,188 @@ it('gives a closing standard side sheet\'s room back to the content beside it as ->assertScript("{$root}.hasAttribute('data-md-open') && ! {$root}.hasAttribute('data-md-drawer-collapsed') && Math.abs({$root}.getBoundingClientRect().width - 400) < 1 && getComputedStyle({$sheet}).opacity === '1'"); }); +/** + * Layers stacked every way a page stacks them: a modal sheet holding a menu, a select, a searchable + * choice and a sheet of its own, which opens a dialog and a second sheet rendered elsewhere on the + * page; the dialog holding a menu, a select, a searchable choice and a sheet. + */ +function layersProbe(): mixed +{ + Route::middleware('web')->get('/layers-probe', fn () => Blade::render(<<<'BLADE' + + + + + @vite(config('livewire-material.showcase.vite')) + @livewireStyles + + +
+
+ + + + + + + + + + + +
+ + +
+
+
+ +
+ +
+
+ +
+ + + + + + + + + +
+ + +
+
+
+ @livewireScripts + + + BLADE)); + + return visit('/layers-probe')->resize(1000, 800)->waitForEvent('networkidle') + ->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined'"); +} + +function sheetOpen(string $id): string +{ + return "document.getElementById('{$id}').closest('[data-md-drawer]').hasAttribute('data-md-open')"; +} + +function hiddenFromAssistiveTech(string $id): string +{ + return "(document.getElementById('{$id}').closest('[aria-hidden=\"true\"]') !== null)"; +} + +it('closes only a dialog opened over a modal sheet on Escape, readable and tabbable while it is open', function () { + $page = layersProbe(); + + $page->click('#open-sheet')->assertScript(sheetOpen('sheet')); + $page->click('#open-dialog')->assertScript("document.getElementById('dialog').open"); + + // Rendered outside the sheet, the dialog is not inside what the sheet hides, and the sheet's + // focus trap does not take the Tab back to the sheet. + $page->assertScript('! '.hiddenFromAssistiveTech('dialog')); + $page->click('#dialog-first'); + $page->keys('#dialog-first', 'Tab')->assertScript("document.activeElement.id === 'dialog-second'"); + + $page->keys('#dialog-second', 'Escape') + ->assertScript("! document.getElementById('dialog').open") + ->assertScript(sheetOpen('sheet')) + // Back under the sheet, the dialog's part of the page is hidden again, and shown once the + // sheet closes too. + ->assertScript(hiddenFromAssistiveTech('dialog')); + + $page->script("document.getElementById('open-dialog').focus()"); + $page->keys('#open-dialog', 'Escape') + ->assertScript('! '.sheetOpen('sheet')) + ->assertScript('! '.hiddenFromAssistiveTech('dialog')) + ->assertNoJavaScriptErrors(); +}); + +it('closes only the menu, select list or searchable choice open inside a modal sheet on Escape', function () { + $page = layersProbe(); + + $page->click('#open-sheet')->assertScript(sheetOpen('sheet')); + + $page->click('#open-menu')->assertScript("document.querySelector('#sheet [data-md-menu-popover]').matches(':popover-open')"); + $page->keys(':focus', 'Escape') + ->assertScript("! document.querySelector('#sheet [data-md-menu-popover]').matches(':popover-open')") + ->assertScript(sheetOpen('sheet')); + + $page->click('#sheet-choices')->assertScript("document.querySelector('#sheet [data-md-field-menu]').matches(':popover-open')"); + $page->keys('#sheet-choices', 'Escape') + ->assertScript("! document.querySelector('#sheet [data-md-field-menu]').matches(':popover-open')") + ->assertScript(sheetOpen('sheet')); + + // A customizable select's list, where the browser has one: elsewhere there is no list of the + // page's own open over the sheet. + if ($page->script("CSS.supports('appearance', 'base-select')") === true) { + $page->click('#sheet-select')->assertScript("document.getElementById('sheet-select').matches(':open')"); + $page->keys(':focus', 'Escape') + ->assertScript("! document.getElementById('sheet-select').matches(':open')") + ->assertScript(sheetOpen('sheet')); + } + + $page->assertNoJavaScriptErrors(); +}); + +it('closes only the sheet on top on Escape, inside the first or beside it, and keeps the one beside it readable', function () { + $page = layersProbe(); + + $page->click('#open-sheet')->assertScript(sheetOpen('sheet')); + + $page->click('#open-inner')->assertScript(sheetOpen('inner-sheet')); + $page->click('#inner-field'); + $page->keys('#inner-field', 'Escape') + ->assertScript('! '.sheetOpen('inner-sheet')) + ->assertScript(sheetOpen('sheet')); + + $page->click('#open-beside')->assertScript(sheetOpen('beside-sheet')) + ->assertScript('! '.hiddenFromAssistiveTech('beside-sheet')); + $page->click('#beside-field'); + $page->keys('#beside-field', 'Escape') + ->assertScript('! '.sheetOpen('beside-sheet')) + ->assertScript(sheetOpen('sheet')) + ->assertScript(hiddenFromAssistiveTech('beside-sheet')) + ->assertNoJavaScriptErrors(); +}); + +it('closes only the sheet, searchable choice, menu or select list open inside a dialog on Escape', function () { + $page = layersProbe(); + + $page->script("window.dispatchEvent(new CustomEvent('open-probe-dialog'))"); + $page->assertScript("document.getElementById('dialog').open"); + + $page->click('#open-dialog-sheet')->assertScript(sheetOpen('dialog-sheet')); + $page->click('#dialog-sheet-field'); + $page->keys('#dialog-sheet-field', 'Escape') + ->assertScript('! '.sheetOpen('dialog-sheet')) + ->assertScript("document.getElementById('dialog').open"); + + $page->click('#dialog-choices')->assertScript("document.querySelector('#dialog [data-md-field-menu]').matches(':popover-open')"); + $page->keys('#dialog-choices', 'Escape') + ->assertScript("! document.querySelector('#dialog [data-md-field-menu]').matches(':popover-open')") + ->assertScript("document.getElementById('dialog').open"); + + $page->click('#open-dialog-menu')->assertScript("document.querySelector('#dialog [data-md-menu-popover]').matches(':popover-open')"); + $page->keys(':focus', 'Escape') + ->assertScript("! document.querySelector('#dialog [data-md-menu-popover]').matches(':popover-open')") + ->assertScript("document.getElementById('dialog').open"); + + if ($page->script("CSS.supports('appearance', 'base-select')") === true) { + $page->click('#dialog-select')->assertScript("document.getElementById('dialog-select').matches(':open')"); + $page->keys(':focus', 'Escape') + ->assertScript("! document.getElementById('dialog-select').matches(':open')") + ->assertScript("document.getElementById('dialog').open"); + } + + $page->assertNoJavaScriptErrors(); +}); + it('draws a standard side sheet that starts open standing open, without growing it in on load', function () { // Slow tokens, so a load-time entry would still be running when the page is first read. Route::middleware('web')->get('/standard-sheet-load-probe', fn () => Blade::render(<<<'BLADE' diff --git a/tests/Feature/Components/OverlayTest.php b/tests/Feature/Components/OverlayTest.php index 3fc6a16a..a15dcc33 100644 --- a/tests/Feature/Components/OverlayTest.php +++ b/tests/Feature/Components/OverlayTest.php @@ -194,6 +194,10 @@ 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"') + // One layer among others: an Escape above or inside it is not its own (resources/js/layers.js). + ->toContain('x-layer="open && ! wide"') + ->toContain('x-on:material-escape="close()"') + ->not->toContain('keydown.window.escape') ->toContain('x-bind:data-md-drawer-collapsed="collapsed ? \'\' : null"') ->toContain('x-effect="settle(open)"') ->toContain('data-md-side="end"') @@ -208,6 +212,7 @@ it('draws a modal bottom sheet with a drag handle, or a standard one without a s ->toContain('...materialBottomSheet(false, JSON.parse(') ->toContain('data-md-bottom-sheet-scrim') ->toContain('x-trap.inert.noscroll="open"') + ->toContain('x-layer="open" x-on:material-escape="close()"') ->toContain('data-md-bottom-sheet-panel') ->toContain('data-md-bottom-sheet-handle') ->toContain('data-md-bottom-sheet-grip') diff --git a/tests/Feature/Components/SupportingPaneTest.php b/tests/Feature/Components/SupportingPaneTest.php index 06b60b0a..d9d00c94 100644 --- a/tests/Feature/Components/SupportingPaneTest.php +++ b/tests/Feature/Components/SupportingPaneTest.php @@ -41,7 +41,7 @@ it('docks the supporting pane as a bottom sheet below expanded, opened by its ha expect(layoutRoot($html)['data-md-compact'])->toBe('sheet') ->and($html)->toContain('x-data="{ sheetOpen: false }"') ->toContain("x-bind:data-md-open=\"sheetOpen ? '' : null\"") - ->toContain('x-on:keydown.escape="if (sheetOpen && $refs.handle.checkVisibility()) { sheetOpen = false; $refs.handle.focus() }"') + ->toContain('x-on:keydown.escape="if (sheetOpen && $refs.handle.checkVisibility()) { $event.preventDefault(); sheetOpen = false; $refs.handle.focus() }"') ->toMatch('/toContain('x-bind:aria-expanded="sheetOpen.toString()"') ->toMatch('/