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

+14 -62
View File
@@ -1,3 +1,5 @@
import { createSurfaceKeyboard } from './ui/surfaceKeyboard.js';
// First-run mission launcher.
//
// The map deliberately does not auto-enable live feeds on every visit: doing so
@@ -342,14 +344,9 @@ export function initFirstRunExperience({
const suppressBox = root.querySelector('[data-first-run-suppress]');
const buttons = [...root.querySelectorAll('[data-first-run-choice]')];
const defaultStatus = status?.textContent || '';
const previouslyFocused = documentRef.activeElement;
let busy = false;
let closing = false;
const focusables = () => [
...root.querySelectorAll('button, input, [href], [tabindex]:not([tabindex="-1"])'),
].filter((node) => !node.hasAttribute('disabled') && node.getClientRects().length > 0);
/**
* Is something painted OVER the card? A measurable box is not a visible card.
* The attribution lightbox is a full-screen overlay at `z-index: 200` against
@@ -401,7 +398,6 @@ export function initFirstRunExperience({
rememberFirstRunSessionDismissed(sessionStorageRef);
root.classList.remove('visible');
root.setAttribute('aria-hidden', 'true');
documentRef.removeEventListener('keydown', onKeyDown, true);
globalThis.removeEventListener?.('resize', onViewportResize);
surfaceObserver?.disconnect();
const remove = () => root.remove();
@@ -411,12 +407,7 @@ export function initFirstRunExperience({
globalThis.setTimeout?.(remove, 400);
// Return the keyboard where it was, not to a node that is being removed —
// but never when yielding, because the surface taking over owns focus now.
if (!restoreFocus) return;
if (typeof previouslyFocused?.focus === 'function' && previouslyFocused.isConnected) {
previouslyFocused.focus({ preventScroll: true });
} else {
documentRef.body?.focus?.({ preventScroll: true });
}
keyboard.deactivate({ restoreFocus });
};
const setBusy = (next, choice = '') => {
@@ -491,60 +482,21 @@ export function initFirstRunExperience({
status.textContent = 'This browser is blocking storage, so that could not be saved.';
};
function onKeyDown(event) {
// THE ARBITRATION RULE: never consume input for a card nobody can see.
// The observer below normally removes the launcher before another surface
// finishes engaging, but MutationObserver callbacks are microtasks, so a
// keydown can still arrive in the window between the class landing and the
// yield running. This check closes that window deterministically.
if (closing || !isTopmost()) return;
// AND THE BELT FOR THE COOPERATIVE HALF. A surface that owns this key marks
// it handled (preventDefault) and silences the rest of us on the way past
// (stopImmediatePropagation). If one of them ever ships only the first half,
// the mark alone still keeps ONE key to ONE action — which is precisely what
// the compact Radio disclosure did with a plain stopPropagation(), a call
// that never blocks later listeners on the same document.
if (event.defaultPrevented) return;
if (event.key === 'Escape') {
// ESC is an exit, not a mission: it must work even mid-flight. The
// launcher is the topmost surface while it is up, so it consumes the key
// rather than also closing a panel the visitor cannot see behind it.
event.preventDefault();
event.stopPropagation();
dismiss();
return;
}
if (event.key !== 'Tab') return;
// Keep Tab inside the launcher while it is up. The rest of the page is
// deliberately still live to the mouse, so this stops short of claiming
// `aria-modal` — it confines the keyboard without asserting the map is inert.
const order = focusables();
if (!order.length) return;
const first = order[0];
const last = order[order.length - 1];
const active = documentRef.activeElement;
// Plain focus(), NOT preventScroll: on a short viewport the mission list
// scrolls inside the card, and a tile the keyboard just reached has to be
// brought into view rather than focused somewhere off-screen.
if (!root.contains(active)) {
event.preventDefault();
(event.shiftKey ? last : first).focus();
return;
}
if (event.shiftKey && active === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && active === last) {
event.preventDefault();
first.focus();
}
}
const keyboard = createSurfaceKeyboard({
root,
documentRef,
// A measurable card may still be hidden by another surface. Check on the
// key itself, before the observer has had a chance to process that change.
isActive: () => !closing && isTopmost(),
onEscape: () => dismiss(),
fallbackFocus: () => documentRef.body,
});
for (const button of buttons) button.addEventListener('click', onChoice);
suppressBox?.addEventListener('change', onSuppressChange);
// Capture phase: the app binds its own global hotkeys (including bare letters
// that cycle detection and styles), and the launcher owns the keyboard first.
documentRef.addEventListener('keydown', onKeyDown, true);
keyboard.activate();
// The scroll fade is an affordance, so it may only appear when the list really
// overflows. On a viewport where all five tiles fit, a faded bottom edge would
@@ -646,7 +598,7 @@ export function initFirstRunExperience({
// Teardown is not a user dismissal and must not change the show preference.
const destroy = () => {
closing = true;
documentRef.removeEventListener('keydown', onKeyDown, true);
keyboard.destroy();
globalThis.removeEventListener?.('resize', onViewportResize);
surfaceObserver?.disconnect();
root.remove();
+15 -9
View File
@@ -217,14 +217,16 @@ test('exclusiveSurfaceActive reads the live body classes', () => {
test('the key handler refuses to act for a card that is not really on screen', () => {
const module = fs.readFileSync(new URL('./firstRunExperience.js', import.meta.url), 'utf8');
assert.match(module, /isActive: \(\) => !closing && isTopmost\(\)/);
// Real visibility, not just the class: the class survives while CSS hides the
// card, which is precisely how a Scene left an invisible ESC handler armed.
assert.match(module, /const isTopmost = \(\) => root\.isConnected/);
assert.match(module, /&& root\.getClientRects\(\)\.length > 0\s*\n\s*&& !coveredByOverlay\(\);/);
const handler = module.slice(module.indexOf('function onKeyDown(event) {'));
const keyboard = fs.readFileSync(new URL('./ui/surfaceKeyboard.js', import.meta.url), 'utf8');
const handler = keyboard.slice(keyboard.indexOf('const onKeyDown = (event) => {'));
assert.match(
handler.slice(0, handler.indexOf("if (event.key === 'Escape')")),
/if \(closing \|\| !isTopmost\(\)\) return;/,
/!isActive\(\)/,
'the handler must bail before consuming anything when it is not topmost',
);
});
@@ -277,10 +279,11 @@ test('one ESC does one thing — the radio disclosure stops the launcher outrigh
// Belt on the launcher side: a key another surface already marked is not ours,
// whether or not that surface remembered to silence us.
const handler = module.slice(module.indexOf('function onKeyDown(event) {'));
const keyboard = fs.readFileSync(new URL('./ui/surfaceKeyboard.js', import.meta.url), 'utf8');
const handler = keyboard.slice(keyboard.indexOf('const onKeyDown = (event) => {'));
assert.match(
handler.slice(0, handler.indexOf("if (event.key === 'Escape')")),
/if \(event\.defaultPrevented\) return;/,
/event\.defaultPrevented\) return;/,
'a marked key must be somebody else\'s key',
);
});
@@ -311,7 +314,7 @@ test('a refused write takes the tick back instead of promising "never again"', (
const handler = module.slice(
module.indexOf('const onSuppressChange = (event) => {'),
module.indexOf('function onKeyDown(event) {'),
module.indexOf(' const keyboard = createSurfaceKeyboard({'),
);
assert.match(handler, /if \(setFirstRunSuppressed\(wanted, storage\)\) return;/);
assert.match(handler, /box\.checked = !wanted;/, 'a refused write must revert the tick');
@@ -617,11 +620,14 @@ test('the launcher keeps focus, restores it, and never disables the focused butt
assert.match(module, /button\.setAttribute\('aria-disabled', String\(next\)\)/);
assert.doesNotMatch(module, /button\.disabled = /);
// Tab is confined to the launcher, and ESC always releases it.
assert.match(module, /event\.key !== 'Tab'/);
assert.match(module, /event\.key === 'Escape'/);
assert.match(module, /previouslyFocused\?\.focus/);
const keyboard = fs.readFileSync(new URL('./ui/surfaceKeyboard.js', import.meta.url), 'utf8');
assert.match(module, /keyboard\.activate\(\)/);
assert.match(module, /keyboard\.deactivate\(\{ restoreFocus \}\)/);
assert.match(keyboard, /event\.key !== 'Tab'/);
assert.match(keyboard, /event\.key === 'Escape'/);
assert.match(keyboard, /target\?\.focus/);
// Capture phase, so the app's global letter hotkeys cannot eat the launcher's keys.
assert.match(module, /addEventListener\('keydown', onKeyDown, true\)/);
assert.match(keyboard, /addEventListener\('keydown', onKeyDown, true\)/);
});
test('the DISPLAY rail starts collapsed on a first run, and a stored choice wins', () => {
+11 -42
View File
@@ -1,3 +1,5 @@
import { createSurfaceKeyboard } from './ui/surfaceKeyboard.js';
/**
* The POWER UP surface — paste a key, get a power.
*
@@ -184,7 +186,6 @@ export async function initKeySetup({ documentRef = globalThis.document, fetchImp
const defaultStatusText = statusLine?.textContent || '';
let busy = false;
let open = false;
let previouslyFocused = null;
const render = (nextStatus) => {
if (disposed) return;
@@ -202,47 +203,18 @@ export async function initKeySetup({ documentRef = globalThis.document, fetchImp
&& root.classList.contains('visible')
&& root.getClientRects().length > 0;
const focusables = () => [
...root.querySelectorAll('button, input, [href], [tabindex]:not([tabindex="-1"])'),
].filter((node) => !node.hasAttribute('disabled') && node.getClientRects().length > 0);
const onKeyDown = (event) => {
if (!open || !visible()) return;
// Cooperative ESC contract (see firstRunExperience.js): whoever handles a
// key first marks it, and everyone else honours the mark.
if (event.defaultPrevented) return;
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
close();
return;
}
if (event.key !== 'Tab') return;
const order = focusables();
if (!order.length) return;
const first = order[0];
const last = order[order.length - 1];
const active = documentRef.activeElement;
if (!root.contains(active)) {
event.preventDefault();
(event.shiftKey ? last : first).focus();
return;
}
if (event.shiftKey && active === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && active === last) {
event.preventDefault();
first.focus();
}
};
const keyboard = createSurfaceKeyboard({
root,
documentRef,
isActive: () => open && visible(),
onEscape: () => close(),
});
const openDialog = () => {
if (disposed || open) return;
open = true;
previouslyFocused = documentRef.activeElement;
keyboard.activate();
root.hidden = false;
documentRef.addEventListener('keydown', onKeyDown, true);
globalThis.requestAnimationFrame?.(() => {
if (!open) return;
root.classList.add('visible');
@@ -253,15 +225,12 @@ export async function initKeySetup({ documentRef = globalThis.document, fetchImp
const close = () => {
if (!open) return;
open = false;
documentRef.removeEventListener('keydown', onKeyDown, true);
root.classList.remove('visible');
const hide = () => { if (!open) root.hidden = true; };
root.addEventListener('transitionend', hide, { once: true });
globalThis.setTimeout?.(hide, 400);
if (statusLine) statusLine.textContent = defaultStatusText;
if (typeof previouslyFocused?.focus === 'function' && previouslyFocused.isConnected) {
previouslyFocused.focus({ preventScroll: true });
}
keyboard.deactivate({ restoreFocus: true });
};
const say = (text) => { if (statusLine) statusLine.textContent = text; };
@@ -365,7 +334,7 @@ export async function initKeySetup({ documentRef = globalThis.document, fetchImp
disposeControls = () => {
open = false;
documentRef.removeEventListener('keydown', onKeyDown, true);
keyboard.destroy();
chip.removeEventListener('click', openDialog);
closeButton?.removeEventListener('click', close);
applyButton?.removeEventListener('click', onApply);
+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);
});