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();