fix(client): give tooltips and focus rings the modality rule each needs
A tooltip asks what the last input was: after any key the user is working from the keyboard, so a programmatic focus may raise a bubble. A focus ring asks something narrower — Chromium reveals an already-focused control as soon as any key arrives, including a modifier or Escape that never navigated anywhere, which is the ring users report as unexplained. Record both answers and publish the ring's narrower one, so tooltips keep their existing behaviour and rings stay silent for a pointer's focus.
This commit is contained in:
1 parent
db92970146
commit
9a74ba5d08
2 files changed
+41
-23
No files matched your search
@@ -1,22 +1,26 @@
|
||||
/**
|
||||
* Last-input modality for the whole document. Focus alone cannot reveal how it
|
||||
* arrived: a closing menu hands focus back to its trigger, and after a mouse
|
||||
* selection that programmatic return must stay silent, while keyboard focus
|
||||
* must not. Capture-phase window listeners record the modality, and only focus
|
||||
* navigation returns the document to the keyboard — a stray modifier or Escape
|
||||
* is not a reason to reveal the pointer's focus.
|
||||
* Input modality for the whole document, under the two rules its consumers need.
|
||||
*
|
||||
* The guard keeps the module loadable where no window exists (node-side imports
|
||||
* of the package's pure helpers).
|
||||
* A tooltip asks what the last input was: after any key the user is working from
|
||||
* the keyboard, so a programmatic focus may raise a bubble. A focus ring asks
|
||||
* something narrower — a pointer's focus must stay invisible — because Chromium
|
||||
* reveals an already-focused control as soon as any key arrives, including a
|
||||
* modifier or Escape that never navigated anywhere. Only focus navigation
|
||||
* returns that second answer to the keyboard.
|
||||
*
|
||||
* Both are recorded from capture-phase window listeners. The guard keeps the
|
||||
* module loadable where no window exists (node-side imports of the package's
|
||||
* pure helpers).
|
||||
*/
|
||||
|
||||
/** Modality values published on the document element. */
|
||||
export const INPUT_MODALITY = { pointer: 'pointer', keyboard: 'keyboard' } as const
|
||||
|
||||
/** Attribute carrying the current modality, read by the global focus sheet. */
|
||||
/** Attribute carrying whether focus navigation last owned focus. */
|
||||
export const INPUT_MODALITY_ATTRIBUTE = 'data-input-modality'
|
||||
|
||||
let pointer = false
|
||||
let pointerOwnsFocus = false
|
||||
|
||||
/** Navigation keys move focus; every other key must not reveal existing focus. */
|
||||
function isFocusNavigation(event: KeyboardEvent): boolean {
|
||||
@@ -24,7 +28,7 @@ function isFocusNavigation(event: KeyboardEvent): boolean {
|
||||
}
|
||||
|
||||
function publish(): void {
|
||||
document.documentElement.setAttribute(INPUT_MODALITY_ATTRIBUTE, pointer ? INPUT_MODALITY.pointer : INPUT_MODALITY.keyboard)
|
||||
document.documentElement.setAttribute(INPUT_MODALITY_ATTRIBUTE, pointerOwnsFocus ? INPUT_MODALITY.pointer : INPUT_MODALITY.keyboard)
|
||||
}
|
||||
|
||||
/** Whether the last input able to own focus came from a pointer. */
|
||||
@@ -33,10 +37,16 @@ export function pointerModality(): boolean {
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('pointerdown', () => { pointer = true; publish() }, true)
|
||||
window.addEventListener('pointerdown', () => {
|
||||
pointer = true
|
||||
pointerOwnsFocus = true
|
||||
publish()
|
||||
}, true)
|
||||
window.addEventListener('keydown', (event) => {
|
||||
if (!isFocusNavigation(event)) return
|
||||
// Any key returns tooltips to the keyboard; only navigation reveals a ring.
|
||||
pointer = false
|
||||
if (!isFocusNavigation(event)) return
|
||||
pointerOwnsFocus = false
|
||||
publish()
|
||||
}, true)
|
||||
}
|
||||
@@ -1,30 +1,38 @@
|
||||
// @vitest-environment jsdom
|
||||
/** The document modality that decides whether focus may reveal itself. */
|
||||
/** The two modality answers: what a tooltip reads, and what a focus ring reads. */
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { INPUT_MODALITY, INPUT_MODALITY_ATTRIBUTE, pointerModality } from '../src/input-modality.ts'
|
||||
|
||||
const press = (key: string): void => { window.dispatchEvent(new KeyboardEvent('keydown', { key })) }
|
||||
const click = (): void => { window.dispatchEvent(new Event('pointerdown')) }
|
||||
const published = (): string | null => document.documentElement.getAttribute(INPUT_MODALITY_ATTRIBUTE)
|
||||
|
||||
describe('input modality', () => {
|
||||
it('keeps a pointer focused control silent through keys that do not navigate', () => {
|
||||
it('returns tooltips to the keyboard on any key', () => {
|
||||
click()
|
||||
expect(pointerModality()).toBe(true)
|
||||
expect(document.documentElement.getAttribute(INPUT_MODALITY_ATTRIBUTE)).toBe(INPUT_MODALITY.pointer)
|
||||
|
||||
for (const key of ['Shift', 'Escape', 'a', 'Control', 'Meta', 'Enter', ' ']) press(key)
|
||||
expect(pointerModality()).toBe(true)
|
||||
expect(document.documentElement.getAttribute(INPUT_MODALITY_ATTRIBUTE)).toBe(INPUT_MODALITY.pointer)
|
||||
for (const key of ['Shift', 'Escape', 'a', 'Enter']) {
|
||||
click()
|
||||
press(key)
|
||||
expect(pointerModality()).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('returns to the keyboard modality on focus navigation', () => {
|
||||
it('keeps a pointer focused control silent through keys that do not navigate', () => {
|
||||
click()
|
||||
expect(published()).toBe(INPUT_MODALITY.pointer)
|
||||
|
||||
for (const key of ['Shift', 'Escape', 'a', 'Control', 'Meta', 'Enter', ' ']) press(key)
|
||||
expect(published()).toBe(INPUT_MODALITY.pointer)
|
||||
})
|
||||
|
||||
it('returns the ring to the keyboard on focus navigation', () => {
|
||||
click()
|
||||
press('Tab')
|
||||
expect(pointerModality()).toBe(false)
|
||||
expect(document.documentElement.getAttribute(INPUT_MODALITY_ATTRIBUTE)).toBe(INPUT_MODALITY.keyboard)
|
||||
expect(published()).toBe(INPUT_MODALITY.keyboard)
|
||||
|
||||
click()
|
||||
press('ArrowDown')
|
||||
expect(pointerModality()).toBe(false)
|
||||
expect(published()).toBe(INPUT_MODALITY.keyboard)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user