refactor(ui): share surface keyboard lifecycle

This commit is contained in:
Sameh Khamis committed 2026-09-12 18:45:30 -07:00
1 parent d5b09efa74
commit e1ffb9c572
11 files changed
+367 -128

No files matched your search

+85
View File
@@ -0,0 +1,85 @@
/**
* Own capture-phase Escape and Tab handling while a surface is active.
* The caller owns visibility, content, initial focus and dismissal policy.
* This does not make the surface modal or make the rest of the page inert.
*
* @param {object} options
* @param {HTMLElement} options.root Surface containing the keyboard controls.
* @param {Document} [options.documentRef] Document that dispatches keyboard input.
* @param {() => boolean} options.isActive Whether this surface may handle input now.
* @param {() => void} options.onEscape Caller-owned dismissal action.
* @param {() => HTMLElement} [options.fallbackFocus] Return target if the opener is gone.
* @returns {{activate: Function, deactivate: Function, destroy: Function}}
*/
export function createSurfaceKeyboard({
root,
documentRef = root.ownerDocument,
isActive,
onEscape,
fallbackFocus,
}) {
let active = false;
let destroyed = false;
let previouslyFocused = null;
const onKeyDown = (event) => {
// Visibility/arbitration belongs to the screen, including overlays that
// cover a measurable box. Respect another handler's claim on the same key.
if (!active || destroyed || !isActive() || event.defaultPrevented) return;
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
onEscape();
return;
}
if (event.key !== 'Tab') return;
const order = [
...root.querySelectorAll('button, input, [href], [tabindex]:not([tabindex="-1"])'),
].filter((node) => !node.hasAttribute('disabled') && node.getClientRects().length > 0);
if (!order.length) return;
const first = order[0];
const last = order[order.length - 1];
const focused = documentRef.activeElement;
// Allow native scrolling when cycling to an off-screen control in a short
// surface. Initial focus and return focus use the caller's separate policy.
if (!root.contains(focused)) {
event.preventDefault();
(event.shiftKey ? last : first).focus();
} else if (event.shiftKey && focused === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && focused === last) {
event.preventDefault();
first.focus();
}
};
const activate = () => {
if (destroyed || active) return;
active = true;
previouslyFocused = documentRef.activeElement;
documentRef.addEventListener('keydown', onKeyDown, true);
};
const deactivate = ({ restoreFocus = false } = {}) => {
if (!active) return;
active = false;
documentRef.removeEventListener('keydown', onKeyDown, true);
const target = previouslyFocused;
previouslyFocused = null;
if (!restoreFocus) return;
if (typeof target?.focus === 'function' && target.isConnected) {
target.focus({ preventScroll: true });
} else {
fallbackFocus?.()?.focus?.({ preventScroll: true });
}
};
const destroy = () => {
if (destroyed) return;
destroyed = true;
deactivate();
};
return { activate, deactivate, destroy };
}
+187
View File
@@ -0,0 +1,187 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createSurfaceKeyboard } from './surfaceKeyboard.js';
function fixture() {
const listeners = new Set();
const documentRef = {
activeElement: null,
addEventListener(type, handler, capture) {
assert.equal(type, 'keydown');
assert.equal(capture, true);
listeners.add(handler);
},
removeEventListener(type, handler, capture) {
assert.equal(type, 'keydown');
assert.equal(capture, true);
listeners.delete(handler);
},
};
const node = () => ({
isConnected: true,
hidden: false,
disabled: false,
focusCalls: [],
hasAttribute(name) { return name === 'disabled' && this.disabled; },
getClientRects() { return this.hidden ? [] : [{}]; },
focus(options) { documentRef.activeElement = this; this.focusCalls.push(options); },
});
const opener = node();
const fallback = node();
const first = node();
const middle = node();
const last = node();
let controls = [first, middle, last];
const root = {
ownerDocument: documentRef,
querySelectorAll: () => controls,
contains: (target) => controls.includes(target),
};
let visible = true;
let escapes = 0;
let onEscape = () => { escapes += 1; };
const controller = createSurfaceKeyboard({
root,
isActive: () => visible,
onEscape: () => onEscape(),
fallbackFocus: () => fallback,
});
opener.focus();
const event = (key, extra = {}) => ({
key,
defaultPrevented: false,
stopped: false,
preventDefault() { this.defaultPrevented = true; },
stopPropagation() { this.stopped = true; },
...extra,
});
const send = (key, extra) => {
const e = event(key, extra);
for (const listener of [...listeners]) listener(e);
return e;
};
return { controller, listeners, documentRef, opener, fallback, first, middle, last, node, event, send,
setVisible(value) { visible = value; },
setControls(value) { controls = value; },
setEscape(value) { onEscape = value; },
escapes: () => escapes,
};
}
test('construction is inert; activation is idempotent and captures the opener once', () => {
const f = fixture();
assert.equal(f.listeners.size, 0);
f.controller.activate();
f.first.focus();
f.controller.activate();
assert.equal(f.listeners.size, 1);
f.controller.deactivate({ restoreFocus: true });
assert.equal(f.listeners.size, 0);
assert.equal(f.documentRef.activeElement, f.opener);
assert.deepEqual(f.opener.focusCalls.at(-1), { preventScroll: true });
});
test('Tab enters from outside in either direction and wraps only at an edge', () => {
const f = fixture();
f.controller.activate();
assert.equal(f.send('Tab').defaultPrevented, true);
assert.equal(f.documentRef.activeElement, f.first);
assert.equal(f.send('Tab').defaultPrevented, false);
f.middle.focus();
assert.equal(f.send('Tab', { shiftKey: true }).defaultPrevented, false);
f.last.focus();
f.send('Tab');
assert.equal(f.documentRef.activeElement, f.first);
f.send('Tab', { shiftKey: true });
assert.equal(f.documentRef.activeElement, f.last);
f.opener.focus();
f.send('Tab', { shiftKey: true });
assert.equal(f.documentRef.activeElement, f.last);
assert.equal(f.last.focusCalls.at(-1), undefined, 'wrapping permits native scroll into view');
});
test('Tab uses current controls and skips hidden/disabled nodes without changing native middle movement', () => {
const f = fixture();
f.controller.activate();
f.first.hidden = true;
f.last.disabled = true;
f.send('Tab');
assert.equal(f.documentRef.activeElement, f.middle);
const replacement = f.node();
f.setControls([replacement]);
f.send('Tab');
assert.equal(f.documentRef.activeElement, replacement);
f.setControls([]);
assert.equal(f.send('Tab').defaultPrevented, false);
});
test('hidden or covered surfaces and already-claimed keys never dismiss or redirect focus', () => {
const f = fixture();
f.controller.activate();
f.setVisible(false);
assert.equal(f.send('Escape').defaultPrevented, false);
assert.equal(f.send('Tab').defaultPrevented, false);
f.setVisible(true);
f.send('Escape', { defaultPrevented: true });
f.send('Tab', { defaultPrevented: true });
assert.equal(f.escapes(), 0);
assert.equal(f.documentRef.activeElement, f.opener);
assert.equal(f.send('x').defaultPrevented, false);
});
test('Escape claims input before calling dismissal; reentrant destruction is safe', () => {
const f = fixture();
f.controller.activate();
const handler = [...f.listeners][0];
const e = f.event('Escape');
f.setEscape(() => {
assert.equal(e.defaultPrevented, true);
assert.equal(e.stopped, true);
f.controller.destroy();
});
handler(e);
assert.equal(f.listeners.size, 0);
const late = f.event('Escape');
handler(late);
assert.equal(late.defaultPrevented, false);
});
test('deactivation can yield without stealing focus, then reopen with a new return target', () => {
const f = fixture();
f.controller.activate();
f.first.focus();
f.controller.deactivate();
assert.equal(f.documentRef.activeElement, f.first);
f.last.focus();
f.controller.activate();
f.middle.focus();
f.controller.deactivate({ restoreFocus: true });
assert.equal(f.documentRef.activeElement, f.last);
const calls = f.last.focusCalls.length;
f.controller.deactivate({ restoreFocus: true });
assert.equal(f.last.focusCalls.length, calls);
});
test('a disconnected opener uses the caller fallback', () => {
const f = fixture();
f.controller.activate();
f.opener.isConnected = false;
f.controller.deactivate({ restoreFocus: true });
assert.equal(f.documentRef.activeElement, f.fallback);
});
test('destroy is permanent, releases its listener and never restores focus', () => {
const f = fixture();
f.controller.activate();
const handler = [...f.listeners][0];
f.first.focus();
f.controller.destroy();
f.controller.destroy();
f.controller.activate();
f.controller.deactivate({ restoreFocus: true });
assert.equal(f.listeners.size, 0);
assert.equal(f.documentRef.activeElement, f.first);
const e = f.event('Tab');
handler(e);
assert.equal(e.defaultPrevented, false);
});