mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
refactor(ui): share surface keyboard lifecycle
This commit is contained in:
1 parent
d5b09efa74
commit
e1ffb9c572
11 files changed
+367
-128
No files matched your search
@@ -13,6 +13,9 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- Extract shared surface keyboard handling for the welcome launcher and Provider
|
||||
Settings, preserving Tab/Escape behavior and releasing the listener on teardown.
|
||||
|
||||
### Security
|
||||
|
||||
- Validate configured Google Places coordinates and text queries before rate
|
||||
|
||||
@@ -195,3 +195,21 @@ or moving focus. Call it before removing or replacing the controls.
|
||||
reactions to state changes. Map Source selection and Location draft cleanup are
|
||||
provided through callbacks. The component imports no globe, data, application
|
||||
or server modules. Package checks and scoped formatting cover this entry.
|
||||
|
||||
## Surface keyboard handling
|
||||
|
||||
`gods-eye-view/ui/surfaces` exports `createSurfaceKeyboard` from
|
||||
`src/ui/surfaceKeyboard.js`. It receives a root DOM node, an optional document,
|
||||
an `isActive` predicate, an `onEscape` action and an optional return-focus fallback.
|
||||
Construction is inert. `activate()` remembers the opener and installs one capture
|
||||
listener; repeated activation is harmless. `deactivate({ restoreFocus: true })`
|
||||
removes that listener and restores the opener when connected, otherwise invoking
|
||||
the supplied fallback. Omit return focus when yielding to another surface.
|
||||
`destroy()` permanently releases ownership without moving focus.
|
||||
|
||||
The welcome launcher and Provider Settings retain content, visibility, initial
|
||||
focus, animation and screen-specific policy. Tab boundaries are read from the
|
||||
current visible/enabled controls for each key; ordinary movement within those
|
||||
boundaries remains native. The component honors already-handled keys and has no
|
||||
app, server, storage or network dependencies. Its package boundary is checked
|
||||
independently from the standalone screens that consume it.
|
||||
@@ -1,5 +1,17 @@
|
||||
# God's Eye View Current State
|
||||
|
||||
## Surface keyboard lifecycle
|
||||
|
||||
`ui/surfaces` owns the capture-phase keyboard listener, Tab cycling and return
|
||||
focus shared by the first-run launcher and Provider Settings. Each caller
|
||||
activates it while open and deactivates it on dismissal; destruction releases
|
||||
keyboard ownership without moving focus. Reopening captures the current opener.
|
||||
The launcher retains its hit-test/exclusive-surface arbitration and dismissal
|
||||
preferences. Provider Settings retains its existing visibility and save policy.
|
||||
Initial focus, transitions and DOM content remain with each screen. This
|
||||
component does not add modal semantics or make the map inert.
|
||||
|
||||
|
||||
## Panel disclosure lifecycle
|
||||
|
||||
Panel collapse buttons, nested Escape handling and dock hover/focus timing now
|
||||
|
||||
+2
-1
@@ -119,6 +119,7 @@
|
||||
"node": "./server/standalone/key-setup.js"
|
||||
},
|
||||
"./search": "./src/search/index.js",
|
||||
"./ui/panels": "./src/ui/panelDisclosure.js"
|
||||
"./ui/panels": "./src/ui/panelDisclosure.js",
|
||||
"./ui/surfaces": "./src/ui/surfaceKeyboard.js"
|
||||
}
|
||||
}
|
||||
@@ -268,5 +268,10 @@
|
||||
"exports": ["./ui/panels"],
|
||||
"modules": ["src/ui/panelDisclosure.js"],
|
||||
"external": []
|
||||
},
|
||||
"surface-keyboard": {
|
||||
"exports": ["./ui/surfaces"],
|
||||
"modules": ["src/ui/surfaceKeyboard.js"],
|
||||
"external": []
|
||||
}
|
||||
}
|
||||
@@ -25,15 +25,16 @@ import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const TESTS = 'src/firstRunExperience.test.mjs';
|
||||
const TESTS = ['src/firstRunExperience.test.mjs', 'src/standalone/startupChrome.test.mjs'];
|
||||
|
||||
const FILES = {
|
||||
keyboard: path.join(ROOT, 'src', 'ui', 'surfaceKeyboard.js'),
|
||||
module: path.join(ROOT, 'src', 'firstRunExperience.js'),
|
||||
html: path.join(ROOT, 'index.html'),
|
||||
css: path.join(ROOT, 'style.css'),
|
||||
voiceInstructions: path.join(ROOT, 'server/providers/openai/instructions.js'),
|
||||
voiceTools: path.join(ROOT, 'server/providers/openai/tools.js'),
|
||||
main: path.join(ROOT, 'src', 'main.js'),
|
||||
main: path.join(ROOT, 'src', 'standalone', 'startupChrome.js'),
|
||||
ui: path.join(ROOT, 'src', 'ui.js'),
|
||||
docs: path.join(ROOT, 'docs', 'CURRENT-STATE.md'),
|
||||
};
|
||||
@@ -118,8 +119,8 @@ const MUTATIONS = [
|
||||
{
|
||||
defect: 'the key handler trusts the CLASS, so an invisible card eats ESC',
|
||||
file: 'module',
|
||||
from: 'if (closing || !isTopmost()) return;',
|
||||
to: 'if (closing) return;',
|
||||
from: 'isActive: () => !closing && isTopmost(),',
|
||||
to: 'isActive: () => !closing,',
|
||||
},
|
||||
{
|
||||
defect: 'the launcher stops yielding and fights Cockpit/Scenes for ESC',
|
||||
@@ -165,8 +166,8 @@ const MUTATIONS = [
|
||||
},
|
||||
{
|
||||
defect: 'the launcher stops honouring a key another surface already claimed',
|
||||
file: 'module',
|
||||
from: ' if (event.defaultPrevented) return;',
|
||||
file: 'keyboard',
|
||||
from: ' || event.defaultPrevented',
|
||||
to: ' /* belt removed */',
|
||||
},
|
||||
{
|
||||
@@ -174,8 +175,8 @@ const MUTATIONS = [
|
||||
// stopImmediatePropagation() a few lines below and prove nothing about this.
|
||||
defect: 'the radio disclosure returns to stopPropagation, so one ESC does two things',
|
||||
file: 'ui',
|
||||
from: ' event.stopImmediatePropagation();\n setRadioDisclosure(false, { returnFocus: true });',
|
||||
to: ' event.stopPropagation();\n setRadioDisclosure(false, { returnFocus: true });',
|
||||
from: ' event.stopImmediatePropagation();\n const escapedFromDisclosure = event.target === this._contextRadioToggleBtn',
|
||||
to: ' event.stopPropagation();\n const escapedFromDisclosure = event.target === this._contextRadioToggleBtn',
|
||||
},
|
||||
{
|
||||
defect: 'the "no timer" decision is deleted, so the next editor re-litigates it blind',
|
||||
@@ -344,25 +345,25 @@ const MUTATIONS = [
|
||||
},
|
||||
{
|
||||
defect: 'Tab escapes into an app the visitor has not seen yet',
|
||||
file: 'module',
|
||||
file: 'keyboard',
|
||||
from: "if (event.key !== 'Tab') return;",
|
||||
to: 'return;',
|
||||
},
|
||||
{
|
||||
defect: 'ESC no longer dismisses the launcher',
|
||||
file: 'module',
|
||||
file: 'keyboard',
|
||||
from: "if (event.key === 'Escape') {",
|
||||
to: 'if (false) {',
|
||||
},
|
||||
{
|
||||
defect: 'focus is never returned to where the visitor left it',
|
||||
file: 'module',
|
||||
from: "if (typeof previouslyFocused?.focus === 'function' && previouslyFocused.isConnected) {",
|
||||
file: 'keyboard',
|
||||
from: "if (typeof target?.focus === 'function' && target.isConnected) {",
|
||||
to: 'if (false) {',
|
||||
},
|
||||
{
|
||||
defect: 'app hotkeys eat the launcher keys (bubble instead of capture)',
|
||||
file: 'module',
|
||||
file: 'keyboard',
|
||||
from: "documentRef.addEventListener('keydown', onKeyDown, true);",
|
||||
to: "documentRef.addEventListener('keydown', onKeyDown);",
|
||||
},
|
||||
@@ -492,7 +493,7 @@ for (const { defect, file, from, to } of MUTATIONS) {
|
||||
let red = false;
|
||||
let by = '';
|
||||
try {
|
||||
execFileSync('node', ['--test', TESTS], { cwd: ROOT, encoding: 'utf8', stdio: 'pipe' });
|
||||
execFileSync('node', ['--test', ...TESTS], { cwd: ROOT, encoding: 'utf8', stdio: 'pipe' });
|
||||
} catch (error) {
|
||||
red = true;
|
||||
const failed = String(error.stdout || '').split('\n')
|
||||
|
||||
+14
-62
@@ -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();
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user