diff --git a/UPGRADE.md b/UPGRADE.md index d9c7e321..8466a52e 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -91,6 +91,15 @@ around the field, which fills its container, so from `medium`, where the field stops at 40rem, the list ran on past the field's end across the whole container. The field itself is the anchor now, and the wrapper `
` is gone; the list keeps hanging under the field's supporting text. +- **A sheet or dialog that opens shows no tooltip on the control it focuses.** A modal + ``, ``, the modal rail and `` move the focus to their first + control as they open, and on a page loaded with the sheet open (`wire:model` already set, say + from `?workout=` in the URL) or a layer opened from the keyboard, browsers count that focus as + keyboard focus: the close button's plain tooltip stood over the sheet the moment it appeared, in + Chrome, Firefox and Safari. Plain and rich tooltips now leave out focus a modal layer moves into + itself from outside; a Tab onto the control, including the one that wraps round to it, and focus + moved within the layer still show them. An `autofocus` an application put on another control to + keep the focus off the close button 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 445e7e9c..ef26d350 100644 --- a/resources/boost/skills/livewire-material-development/SKILL.md +++ b/resources/boost/skills/livewire-material-development/SKILL.md @@ -273,7 +273,7 @@ Label button, icon button, toggle and responsive FAB in one component. ### `` -M3's plain tooltip, standalone around any trigger: ``. `side`: `top` (default), `bottom`, `left`, `right`. Shows on hover (fine pointers) and keyboard focus, and goes 1.5s after the pointer or the focus leaves it (M3's transient tooltip); only one is on screen at a time. It is `aria-hidden`, so the trigger has to carry the same words itself — as an icon button's `aria-label` does. Where the tip says something the trigger does not, use ``, which points the trigger at its text. Buttons and FABs take a `tooltip` prop instead. +M3's plain tooltip, standalone around any trigger: ``. `side`: `top` (default), `bottom`, `left`, `right`. Shows on hover (fine pointers) and keyboard focus, and goes 1.5s after the pointer or the focus leaves it (M3's transient tooltip); the focus a sheet, a dialog or the modal rail moves to its first control as it opens does not show it, so a close button's tooltip waits for a Tab; only one is on screen at a time. It is `aria-hidden`, so the trigger has to carry the same words itself — as an icon button's `aria-label` does. Where the tip says something the trigger does not, use ``, which points the trigger at its text. Buttons and FABs take a `tooltip` prop instead. ### ``, ``, ``, `` @@ -491,7 +491,7 @@ A few lines of context around a trigger, with an optional `title` and `actions` ``` -Shows on hover and keyboard focus and goes 1.5s after the pointer or the focus leaves, as M3 times a plain tooltip too; `persistent` opens it on press and keeps it until a press elsewhere or Escape (use it when there are actions). The trigger is pointed at the bubble with `aria-describedby`, so its words are read out with the control. An open bubble stays open while the Livewire component around it renders, its actions' `wire:click` included. `side`: `bottom` (default), `top`, `left`, `right`. +Shows on hover and keyboard focus (not the focus a sheet or dialog moves to its trigger as it opens) and goes 1.5s after the pointer or the focus leaves, as M3 times a plain tooltip too; `persistent` opens it on press and keeps it until a press elsewhere or Escape (use it when there are actions). The trigger is pointed at the bubble with `aria-describedby`, so its words are read out with the control. An open bubble stays open while the Livewire component around it renders, its actions' `wire:click` included. `side`: `bottom` (default), `top`, `left`, `right`. ### `` diff --git a/resources/js/layers.js b/resources/js/layers.js index b3c5a69f..abccb092 100644 --- a/resources/js/layers.js +++ b/resources/js/layers.js @@ -29,6 +29,12 @@ * (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. + * + * **The focus a layer moves.** A modal panel, `` and the modal rail move the focus to + * their first control as they open (x-trap, `showModal()`), and on a page loaded with the layer + * open, or opened from the keyboard, the browser counts that as `:focus-visible`. `openingFocus()` + * tells that focus apart from a person's, so a tooltip does not stand over a sheet's close button + * the moment the sheet appears (tooltip.js, rich-tooltip.js). */ /** Everything that can hold an Escape before the window hears it, nearest first. */ @@ -62,6 +68,43 @@ const isOpen = (layer) => { return true } +/** The layers that move the focus into themselves as they open. */ +const MODAL_LAYERS = 'dialog, [aria-modal="true"], [data-md-navigation-rail-panel]' + +/** + * Whether a Tab is moving the focus: the browser moves it while the keydown is handled, and so does + * focus-trap when it takes a Tab round a panel, so a focus that lands before the next task is the + * Tab's. + */ +let tabbing = false + +document.addEventListener('keydown', (event) => { + if (event.key === 'Tab') { + tabbing = true + setTimeout(() => (tabbing = false)) + } +}, true) + +/** + * Whether a `focusin` is an open modal layer moving the focus into itself (see the header): the + * focus arrives from outside the nearest open layer around its target, or from nowhere, and no Tab + * is moving it. Focus moved within the layer, and a Tab that focus-trap brings back to its first + * control, are a person's. + */ +export const openingFocus = (event) => { + if (tabbing || !(event.target instanceof Element)) { + return false + } + + let layer = event.target.closest(MODAL_LAYERS) + + while (layer !== null && !isOpen(layer)) { + layer = layer.parentElement?.closest(MODAL_LAYERS) ?? null + } + + return layer !== null && !(event.relatedTarget instanceof Node && layer.contains(event.relatedTarget)) +} + /** Whether an Escape keydown is this open panel's to act on (see the header). */ const owns = (panel, event) => { if (event.defaultPrevented) { diff --git a/resources/js/rich-tooltip.js b/resources/js/rich-tooltip.js index 2f777a42..30bc1fde 100644 --- a/resources/js/rich-tooltip.js +++ b/resources/js/rich-tooltip.js @@ -2,7 +2,8 @@ * `materialRichTooltip`: shows an ``. * * Transient (the default): like a plain tooltip (tooltip.js) — after a short hover on a pointer - * that can hover, at once on keyboard focus, and standing for 1.5s once the pointer or the focus + * that can hover, at once on keyboard focus (not the focus a sheet or dialog moves to it as it + * opens, layers.js), and standing for 1.5s once the pointer or the focus * leaves, which M3 gives plain and rich tooltips alike (docs/reference/m3/ * components-actions-communication-containment.md § Tooltips, ACT-25). The bubble is inside the * wrapper, so moving onto it to reach its actions, by pointer or by Tab, is no leave at all; Escape @@ -15,6 +16,8 @@ * A Livewire morph strips attributes the server did not render and gives the bubble a new id, so * they are written again whenever the trigger is reached. */ +import { openingFocus } from './layers.js' + const HOVER_DELAY_MS = 500 const LEAVE_GRACE_MS = 1500 @@ -95,7 +98,7 @@ document.addEventListener('alpine:init', () => { wrapper.addEventListener('pointerenter', (event) => event.pointerType === 'mouse' && show(HOVER_DELAY_MS)) wrapper.addEventListener('pointerleave', () => hide(LEAVE_GRACE_MS)) - wrapper.addEventListener('focusin', (event) => event.target.matches(':focus-visible') && show(0)) + wrapper.addEventListener('focusin', (event) => !openingFocus(event) && event.target.matches(':focus-visible') && show(0)) wrapper.addEventListener('focusout', (event) => !wrapper.contains(event.relatedTarget) && hide(LEAVE_GRACE_MS)) document.addEventListener('keydown', (event) => event.key === 'Escape' && hide()) }, diff --git a/resources/js/tooltip.js b/resources/js/tooltip.js index 3c65e2e5..8a1fb2a9 100644 --- a/resources/js/tooltip.js +++ b/resources/js/tooltip.js @@ -3,11 +3,15 @@ * * The trigger is the popover's parent element (the button, or the standalone wrapper). Hover * counts only for a pointer that can hover, after a short delay so a pointer passing over a - * toolbar does not flash every label; keyboard focus shows it at once. A press and Escape hide it + * toolbar does not flash every label; keyboard focus shows it at once — but not the focus a sheet, + * a dialog or the modal rail moves to its first control as it opens (layers.js), which would put + * a tooltip over a sheet's close button the moment the sheet appears. A press and Escape hide it * at once; leaving and blur let it stand for M3's 1.5s, so the words can still be read while the * pointer moves on. Only one tooltip is up at a time: showing one takes the last one down, which * the browser will not do for us because these are `popover="manual"`. */ +import { openingFocus } from './layers.js' + const HOVER_DELAY_MS = 500 const LEAVE_DELAY_MS = 1500 @@ -56,7 +60,7 @@ document.addEventListener('alpine:init', () => { this.listen(trigger, 'pointerenter', (event) => event.pointerType === 'mouse' && show(HOVER_DELAY_MS)) this.listen(trigger, 'pointerleave', () => hide(LEAVE_DELAY_MS)) this.listen(trigger, 'pointerdown', () => hide()) - this.listen(trigger, 'focusin', () => trigger.matches(':focus-within:has(:focus-visible), :focus-visible') && show(0)) + this.listen(trigger, 'focusin', (event) => !openingFocus(event) && trigger.matches(':focus-within:has(:focus-visible), :focus-visible') && show(0)) this.listen(trigger, 'focusout', () => hide(LEAVE_DELAY_MS)) this.listen(document, 'keydown', (event) => event.key === 'Escape' && hide()) }, diff --git a/resources/views/components/rich-tooltip.blade.php b/resources/views/components/rich-tooltip.blade.php index 0c9d905e..7d184706 100644 --- a/resources/views/components/rich-tooltip.blade.php +++ b/resources/views/components/rich-tooltip.blade.php @@ -5,7 +5,8 @@ - It wraps its trigger. By default it shows on hover and keyboard focus like a plain tooltip; + It wraps its trigger. By default it shows on hover and keyboard focus like a plain tooltip (not + on the focus a sheet or dialog moves to its trigger as it opens); `persistent` makes it open on press instead and stay until a press elsewhere or Escape — the form M3 asks for when it has actions. The bubble is a popover in surface-container with a medium corner and elevation 2, 312px at most, placed by anchor positioning on `side`. diff --git a/resources/views/components/tooltip.blade.php b/resources/views/components/tooltip.blade.php index b79661ab..03079ff6 100644 --- a/resources/views/components/tooltip.blade.php +++ b/resources/views/components/tooltip.blade.php @@ -6,7 +6,10 @@ It shows after a short hover on a pointer that can hover, at once on keyboard focus, and hides - at once on a press or Escape; leaving or blurring lets it stand for M3's 1.5s. Only one + at once on a press or Escape; leaving or blurring lets it stand for M3's 1.5s. The focus a + sheet, a dialog or the modal rail moves to its first control as it opens is not keyboard focus, + so a sheet's close button shows no tooltip the moment the sheet appears; a Tab onto it does. + Only one tooltip is on screen at a time, as M3 asks. A phone has no hover, so a control there carries its words. The bubble is a `popover="manual"` in the top layer — never clipped by an `overflow: hidden` parent, never widening a scroll container — placed by CSS anchor diff --git a/tests/Browser/ContainmentTest.php b/tests/Browser/ContainmentTest.php index 903bd08d..2e6aad3f 100644 --- a/tests/Browser/ContainmentTest.php +++ b/tests/Browser/ContainmentTest.php @@ -1592,3 +1592,102 @@ it('opens and closes a collapse at once under reduced motion, leaving nothing be expect($result)->toBe(['opened' => ['open' => true, 'animations' => 0], 'open' => false, 'animations' => 0, 'closing' => false, 'style' => null]); }); + +class OpenedLayersProbe extends Component +{ + public ?int $workout = null; + + public bool $setback = false; + + public bool $about = false; + + public function mount(): void + { + $this->workout = request()->has('workout') ? (int) request('workout') : null; + } + + public function render(): string + { + return <<<'BLADE' +
+

The week's sessions

+ + + + + + + + + + + + + +
+ BLADE; + } +} + +/** Whether the plain tooltip of the ✕ in the sheet or the full-screen dialog's bar is up. */ +function closeTooltipOpen(string $layer): string +{ + return "document.querySelector('{$layer} button[aria-label=\"Close\"] [data-md-tooltip]').matches(':popover-open')"; +} + +it('shows no tooltip on the control a sheet or a dialog focuses as it opens, but does once Tab reaches it', function () { + Livewire::component('opened-layers-probe', OpenedLayersProbe::class); + + Route::middleware('web')->get('/opened-layers-probe', fn () => Blade::render(<<<'BLADE' + + + + + @vite(config('livewire-material.showcase.vite')) + @livewireStyles + + + + @livewireScripts + + + BLADE)); + + $sheet = '[data-md-drawer-sheet]'; + $dialog = 'dialog[data-md-modal]'; + + // A phone, where the sheet is modal and traps the focus, which it moves to its ✕ on open: here + // on a page loaded with the sheet open, as a link to a session opens it. + $page = visit('/opened-layers-probe?workout=7', ['viewport' => ['width' => 393, 'height' => 800]])->waitForEvent('networkidle') + ->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined'") + ->assertScript("document.activeElement.matches('{$sheet} button[aria-label=\"Close\"]')") + ->wait(0.3) + ->assertScript('! '.closeTooltipOpen($sheet)); + + // Tab from the sheet's last field wraps round to its ✕, and a keyboard's focus shows its label. + $page->keys('#sheet-note', 'Tab') + ->assertScript("document.activeElement.matches('{$sheet} button[aria-label=\"Close\"]')") + ->assertScript(closeTooltipOpen($sheet)); + + // A full-screen dialog opened from the keyboard moves the focus to its bar's ✕ in showModal(). + $page = visit('/opened-layers-probe', ['viewport' => ['width' => 393, 'height' => 800]])->waitForEvent('networkidle') + ->assertScript("document.readyState === 'complete' && typeof window.Livewire !== 'undefined'"); + + $page->keys('#open-setback', 'Enter') + ->assertScript("document.querySelector('{$dialog}').open && document.activeElement.matches('{$dialog} button[aria-label=\"Close\"]')") + ->wait(0.3) + ->assertScript('! '.closeTooltipOpen($dialog)); + + $page->keys('#dialog-weeks', 'Tab') + ->assertScript("document.activeElement.matches('{$dialog} button[aria-label=\"Close\"]')") + ->assertScript(closeTooltipOpen($dialog)); + + // A rich tooltip's trigger as the first control of a dialog opened from the keyboard. + $page = visit('/opened-layers-probe', ['viewport' => ['width' => 393, 'height' => 800]])->waitForEvent('networkidle') + ->assertScript("document.readyState === 'complete' && typeof window.Livewire !== 'undefined'"); + + $page->keys('#open-about', 'Enter') + ->assertScript("document.activeElement.id === 'about-help'") + ->wait(0.3) + ->assertScript("! document.querySelector('[data-md-rich-tooltip-bubble]').matches(':popover-open')"); +});