Close only the topmost layer on Escape, and keep a layer opened over a sheet readable
A modal side sheet, bottom sheet or the modal rail closed on any Escape the window heard, so a dialog opened from a sheet, a menu, select list or searchable choice inside one, a sheet opened from a sheet, and a sheet inside a dialog each closed two layers on one press. And a dialog or a second sheet rendered elsewhere on the page sat inside the `aria-hidden` the first sheet's `x-trap.inert` put on its siblings, so a screen reader could not read it, while the sheet's focus trap took every Tab inside the dialog back to the inert sheet. resources/js/layers.js adds `x-layer`, on each of those panels beside its `x-trap`. An Escape is the panel's only when nothing has handled it and the nearest open layer around its target is the panel itself - not an open dialog, popover or customizable select, nor a panel inside it; the panel claims it with preventDefault(), which also keeps a dialog around it from cancelling, and dispatches `material-escape`, which the views close on. A panel that opens lifts `aria-hidden` from its own ancestors and puts it back on close only where a panel still open hides them; materialShowModal() does the same for `<x-modal>`, whose new `x-trap.noautofocus.noreturn` pauses the sheet's focus trap while it is open and moves no focus of its own. The searchable choice, the search view and the supporting pane's sheet now preventDefault() the Escape they act on, so the dialog or sheet around them stays. Four browser tests stack the layers every way above and fail without the change in Chrome. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
029c78d301
commit
94a376a4fd
@@ -41,6 +41,14 @@
|
||||
customizable `<x-select>` 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 `<x-drawer>`, `<x-bottom-sheet>` or modal navigation rail — or a sheet
|
||||
opened from a sheet, or inside `<x-modal>` — 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
|
||||
|
||||
|
||||
@@ -583,6 +583,8 @@ Props: `title`, `subtitle`, `icon` (centred hero icon), `separator` (draw the di
|
||||
|
||||
An M3 side sheet, bound like `<x-modal>`; `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 `<x-list-detail>` instead — `<x-drawer>` 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 `<x-modal>`, 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` (`<x-supporting-pane>`, or a caller's own row).
|
||||
|
||||
### `<x-bottom-sheet>`
|
||||
|
||||
@@ -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 `<x-modal>` 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]')
|
||||
|
||||
@@ -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 `<x-drawer>`'s sheet below
|
||||
* `expanded`, `<x-bottom-sheet>`'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 `<x-modal>`'s native `<dialog>`, 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 `<dialog>`, 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 `<dialog>` 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. `<x-modal>` exposes its dialog the same way
|
||||
* (materialShowModal(), dialog.js); a modal `<dialog>` 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()
|
||||
})
|
||||
}))
|
||||
})
|
||||
@@ -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'
|
||||
|
||||
@@ -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 }}"
|
||||
|
||||
@@ -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()"
|
||||
/>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
{{-- An M3 dialog, on the native `<dialog>` 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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
|
||||
<div
|
||||
x-data="materialSearch({{ $docked ? 'true' : 'false' }}, @js($announce), @js($trigger))"
|
||||
x-on:keydown.escape="if (open) { $event.stopPropagation(); close(true); }"
|
||||
x-on:keydown.escape="if (open) { $event.preventDefault(); $event.stopPropagation(); close(true); }"
|
||||
x-on:focusout="leave($event)"
|
||||
x-on:pointerdown.outside="close()"
|
||||
x-trap.noscroll="open && fullScreen"
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
@if ($sheet)
|
||||
x-data="{ sheetOpen: false }"
|
||||
x-bind:data-md-open="sheetOpen ? '' : null"
|
||||
x-on:keydown.escape="if (sheetOpen && $refs.handle.checkVisibility()) { sheetOpen = false; $refs.handle.focus() }"
|
||||
x-on:keydown.escape="if (sheetOpen && $refs.handle.checkVisibility()) { $event.preventDefault(); sheetOpen = false; $refs.handle.focus() }"
|
||||
@endif
|
||||
>
|
||||
@if ($sheet)
|
||||
|
||||
@@ -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'
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<x-theme-script />
|
||||
@vite(config('livewire-material.showcase.vite'))
|
||||
@livewireStyles
|
||||
</head>
|
||||
<body style="background-color: var(--md-sys-color-surface);">
|
||||
<main>
|
||||
<div x-data="{ open: false }">
|
||||
<button type="button" id="open-sheet" x-on:click="open = true">Open the session</button>
|
||||
<x-drawer id="sheet" title="Session">
|
||||
<button type="button" id="open-dialog" x-on:click="$dispatch('open-probe-dialog')">Set back</button>
|
||||
<button type="button" id="open-beside" x-on:click="$dispatch('open-probe-beside')">Swap</button>
|
||||
<x-menu label="More">
|
||||
<x-slot:trigger><button type="button" id="open-menu">More</button></x-slot:trigger>
|
||||
<x-menu-item label="Copy" />
|
||||
<x-menu-item label="Move" />
|
||||
</x-menu>
|
||||
<x-select id="sheet-select" label="Expires" :options="[['id' => '1', 'name' => '1 hour'], ['id' => '24', 'name' => '1 day']]" />
|
||||
<x-choices id="sheet-choices" label="Sport" searchable :options="[['id' => 'run', 'name' => 'Run'], ['id' => 'ride', 'name' => 'Ride']]" />
|
||||
<div x-data="{ open: false }">
|
||||
<button type="button" id="open-inner" x-on:click="open = true">Notes</button>
|
||||
<x-drawer id="inner-sheet" title="Notes"><input id="inner-field" aria-label="Note" /></x-drawer>
|
||||
</div>
|
||||
</x-drawer>
|
||||
</div>
|
||||
|
||||
<div x-data="{ open: false }" x-on:open-probe-beside.window="open = true">
|
||||
<x-drawer id="beside-sheet" title="Swap"><input id="beside-field" aria-label="Swap with" /></x-drawer>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div x-data="{ open: false }" x-on:open-probe-dialog.window="open = true">
|
||||
<x-modal id="dialog" title="Set back the plan">
|
||||
<input id="dialog-first" aria-label="Weeks" />
|
||||
<input id="dialog-second" aria-label="Reason" />
|
||||
<x-menu label="Dialog menu">
|
||||
<x-slot:trigger><button type="button" id="open-dialog-menu">Options</button></x-slot:trigger>
|
||||
<x-menu-item label="Keep" />
|
||||
</x-menu>
|
||||
<x-select id="dialog-select" label="Expires" :options="[['id' => '1', 'name' => '1 hour'], ['id' => '24', 'name' => '1 day']]" />
|
||||
<x-choices id="dialog-choices" label="Sport" searchable :options="[['id' => 'run', 'name' => 'Run'], ['id' => 'ride', 'name' => 'Ride']]" />
|
||||
<div x-data="{ open: false }">
|
||||
<button type="button" id="open-dialog-sheet" x-on:click="open = true">Details</button>
|
||||
<x-drawer id="dialog-sheet" title="Details"><input id="dialog-sheet-field" aria-label="Detail" /></x-drawer>
|
||||
</div>
|
||||
</x-modal>
|
||||
</div>
|
||||
@livewireScripts
|
||||
</body>
|
||||
</html>
|
||||
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'
|
||||
|
||||
@@ -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('<x-drawer title="Details" with-close-button>Body</x-drawer>'))
|
||||
->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')
|
||||
|
||||
@@ -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('/<button\s+type="button"\s+class="md-state-layer md-focus-ring"\s+data-md-supporting-pane-handle\s+aria-expanded="false"/')
|
||||
->toContain('x-bind:aria-expanded="sheetOpen.toString()"')
|
||||
->toMatch('/<span data-md-supporting-pane-grip aria-hidden="true"><\/span>\s*<span class="md-type-title-sm" data-md-supporting-pane-label>Comments<\/span>/')
|
||||
|
||||
Reference in New Issue
Block a user