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:
mektpoy committed 2026-09-20 17:26:14 +08:00
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)
})
})