Show no tooltip on the control a sheet or dialog focuses as it opens
A modal side sheet, bottom sheet and the modal rail move the focus to their first control as they open (x-trap), and `<x-modal>` does in `showModal()`. On a page loaded with the sheet already open (a `wire:model` set from `?workout=` in the URL) the browsers count that focus as `:focus-visible`, and so they do for a sheet or dialog opened from the keyboard, so tooltip.js showed the close button's "Close" tooltip the moment the layer appeared, over its title, with no keyboard use. Confirmed in Chrome, Firefox and Safari for the deep-linked sheet and a full-screen dialog opened with Enter; a deep-linked `<x-modal>`'s own focus was not counted as keyboard focus. M3 lets a tooltip appear on hover or focus and says nothing that asks for one on a focus the component moves itself. layers.js gains `openingFocus(event)`: a focus is a modal layer's own when the nearest open dialog, `aria-modal` panel or modal rail around its target is entered from outside it, or from nowhere, and no Tab is moving it (a Tab moves the focus while its keydown is handled, by the browser or by focus-trap wrapping round the panel). tooltip.js and rich-tooltip.js leave such a focus out; focus moved within the layer, a Tab onto the control and a focus returned to it from a menu inside still show the tooltip. The docblocks, the skill and UPGRADE.md say so. A browser test loads a page with a modal sheet open at 393px, opens a full-screen dialog and a dialog whose first control is a rich tooltip's trigger from the keyboard, and checks that no tooltip is up on the focused control, then that Tab wrapping round to the close button shows its tooltip; it fails without the change in Chrome, Firefox and Safari, each part on its own. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
16c52d46e9
commit
b62c83f244
@@ -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 `<div>` 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
|
||||
`<x-drawer>`, `<x-bottom-sheet>`, the modal rail and `<x-modal>` 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
|
||||
|
||||
|
||||
@@ -273,7 +273,7 @@ Label button, icon button, toggle and responsive FAB in one component.
|
||||
|
||||
### `<x-tooltip>`
|
||||
|
||||
M3's plain tooltip, standalone around any trigger: `<x-tooltip text="Copy link" side="bottom"><button>…</button></x-tooltip>`. `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 `<x-rich-tooltip>`, which points the trigger at its text. Buttons and FABs take a `tooltip` prop instead.
|
||||
M3's plain tooltip, standalone around any trigger: `<x-tooltip text="Copy link" side="bottom"><button>…</button></x-tooltip>`. `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 `<x-rich-tooltip>`, which points the trigger at its text. Buttons and FABs take a `tooltip` prop instead.
|
||||
|
||||
### `<x-menu>`, `<x-menu-item>`, `<x-menu-group>`, `<x-menu-separator>`
|
||||
|
||||
@@ -491,7 +491,7 @@ A few lines of context around a trigger, with an optional `title` and `actions`
|
||||
</x-rich-tooltip>
|
||||
```
|
||||
|
||||
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`.
|
||||
|
||||
### `<x-stat>`
|
||||
|
||||
|
||||
@@ -29,6 +29,12 @@
|
||||
* (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.
|
||||
*
|
||||
* **The focus a layer moves.** A modal panel, `<x-modal>` 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) {
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
* `materialRichTooltip`: shows an `<x-rich-tooltip>`.
|
||||
*
|
||||
* 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())
|
||||
},
|
||||
|
||||
@@ -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())
|
||||
},
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
<x-slot:actions><x-button label="Learn more" link="/help/expiry" /></x-slot:actions>
|
||||
</x-rich-tooltip>
|
||||
|
||||
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`.
|
||||
|
||||
@@ -6,7 +6,10 @@
|
||||
<x-tooltip text="Copy link"><button …>…</button></x-tooltip>
|
||||
|
||||
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
|
||||
|
||||
@@ -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'
|
||||
<div>
|
||||
<p>The week's sessions</p>
|
||||
<button type="button" id="open-setback" wire:click="$set('setback', true)">Set back the plan</button>
|
||||
<button type="button" id="open-about" wire:click="$set('about', true)">About expiry</button>
|
||||
<x-drawer wire:model="workout" title="Long run">
|
||||
<input id="sheet-note" aria-label="Note" />
|
||||
</x-drawer>
|
||||
<x-modal wire:model="setback" title="Set back the plan" fullscreen>
|
||||
<input id="dialog-weeks" aria-label="Weeks" />
|
||||
</x-modal>
|
||||
<x-modal wire:model="about" title="Expiry">
|
||||
<x-rich-tooltip text="Recipients lose access after this time.">
|
||||
<x-button id="about-help" icon="help" aria-label="What expiry means" />
|
||||
</x-rich-tooltip>
|
||||
</x-modal>
|
||||
</div>
|
||||
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'
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<x-theme-script />
|
||||
@vite(config('livewire-material.showcase.vite'))
|
||||
@livewireStyles
|
||||
</head>
|
||||
<body style="background-color: var(--md-sys-color-surface);">
|
||||
<livewire:opened-layers-probe />
|
||||
@livewireScripts
|
||||
</body>
|
||||
</html>
|
||||
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')");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user