mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 03:14:00 +00:00
Merge pull request #387: share surface keyboard lifecycle
Extract shared Tab, Escape and return-focus handling with explicit listener ownership. Preserve screen policy and validate unit, mutation, browser, tracking and CI checks.
This commit is contained in:
12 files changed
+409
-129
No files matched your search
@@ -13,6 +13,9 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
- Extract shared surface keyboard handling for the welcome launcher and Provider
|
||||||
|
Settings, preserving Tab/Escape behavior and releasing the listener on teardown.
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
|
||||||
- Validate configured Google Places coordinates and text queries before rate
|
- 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
|
reactions to state changes. Map Source selection and Location draft cleanup are
|
||||||
provided through callbacks. The component imports no globe, data, application
|
provided through callbacks. The component imports no globe, data, application
|
||||||
or server modules. Package checks and scoped formatting cover this entry.
|
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
|
# 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 disclosure lifecycle
|
||||||
|
|
||||||
Panel collapse buttons, nested Escape handling and dock hover/focus timing now
|
Panel collapse buttons, nested Escape handling and dock hover/focus timing now
|
||||||
|
|||||||
+2
-1
@@ -119,6 +119,7 @@
|
|||||||
"node": "./server/standalone/key-setup.js"
|
"node": "./server/standalone/key-setup.js"
|
||||||
},
|
},
|
||||||
"./search": "./src/search/index.js",
|
"./search": "./src/search/index.js",
|
||||||
"./ui/panels": "./src/ui/panelDisclosure.js"
|
"./ui/panels": "./src/ui/panelDisclosure.js",
|
||||||
|
"./ui/surfaces": "./src/ui/surfaceKeyboard.js"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -120,5 +120,7 @@
|
|||||||
"src/search/google.js",
|
"src/search/google.js",
|
||||||
"src/keylessGeocoder.js",
|
"src/keylessGeocoder.js",
|
||||||
"src/standalone/placeSearch.js",
|
"src/standalone/placeSearch.js",
|
||||||
"src/ui/panelDisclosure.js"
|
"src/ui/panelDisclosure.js",
|
||||||
|
"src/ui/surfaceKeyboard.js",
|
||||||
|
"src/ui/surfaceKeyboard.test.mjs"
|
||||||
]
|
]
|
||||||
@@ -268,5 +268,10 @@
|
|||||||
"exports": ["./ui/panels"],
|
"exports": ["./ui/panels"],
|
||||||
"modules": ["src/ui/panelDisclosure.js"],
|
"modules": ["src/ui/panelDisclosure.js"],
|
||||||
"external": []
|
"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';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.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 = {
|
const FILES = {
|
||||||
|
keyboard: path.join(ROOT, 'src', 'ui', 'surfaceKeyboard.js'),
|
||||||
module: path.join(ROOT, 'src', 'firstRunExperience.js'),
|
module: path.join(ROOT, 'src', 'firstRunExperience.js'),
|
||||||
html: path.join(ROOT, 'index.html'),
|
html: path.join(ROOT, 'index.html'),
|
||||||
css: path.join(ROOT, 'style.css'),
|
css: path.join(ROOT, 'style.css'),
|
||||||
voiceInstructions: path.join(ROOT, 'server/providers/openai/instructions.js'),
|
voiceInstructions: path.join(ROOT, 'server/providers/openai/instructions.js'),
|
||||||
voiceTools: path.join(ROOT, 'server/providers/openai/tools.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'),
|
ui: path.join(ROOT, 'src', 'ui.js'),
|
||||||
docs: path.join(ROOT, 'docs', 'CURRENT-STATE.md'),
|
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',
|
defect: 'the key handler trusts the CLASS, so an invisible card eats ESC',
|
||||||
file: 'module',
|
file: 'module',
|
||||||
from: 'if (closing || !isTopmost()) return;',
|
from: 'isActive: () => !closing && isTopmost(),',
|
||||||
to: 'if (closing) return;',
|
to: 'isActive: () => !closing,',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
defect: 'the launcher stops yielding and fights Cockpit/Scenes for ESC',
|
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',
|
defect: 'the launcher stops honouring a key another surface already claimed',
|
||||||
file: 'module',
|
file: 'keyboard',
|
||||||
from: ' if (event.defaultPrevented) return;',
|
from: ' || event.defaultPrevented',
|
||||||
to: ' /* belt removed */',
|
to: ' /* belt removed */',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -174,8 +175,8 @@ const MUTATIONS = [
|
|||||||
// stopImmediatePropagation() a few lines below and prove nothing about this.
|
// stopImmediatePropagation() a few lines below and prove nothing about this.
|
||||||
defect: 'the radio disclosure returns to stopPropagation, so one ESC does two things',
|
defect: 'the radio disclosure returns to stopPropagation, so one ESC does two things',
|
||||||
file: 'ui',
|
file: 'ui',
|
||||||
from: ' event.stopImmediatePropagation();\n setRadioDisclosure(false, { returnFocus: true });',
|
from: ' event.stopImmediatePropagation();\n const escapedFromDisclosure = event.target === this._contextRadioToggleBtn',
|
||||||
to: ' event.stopPropagation();\n setRadioDisclosure(false, { returnFocus: true });',
|
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',
|
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',
|
defect: 'Tab escapes into an app the visitor has not seen yet',
|
||||||
file: 'module',
|
file: 'keyboard',
|
||||||
from: "if (event.key !== 'Tab') return;",
|
from: "if (event.key !== 'Tab') return;",
|
||||||
to: 'return;',
|
to: 'return;',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
defect: 'ESC no longer dismisses the launcher',
|
defect: 'ESC no longer dismisses the launcher',
|
||||||
file: 'module',
|
file: 'keyboard',
|
||||||
from: "if (event.key === 'Escape') {",
|
from: "if (event.key === 'Escape') {",
|
||||||
to: 'if (false) {',
|
to: 'if (false) {',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
defect: 'focus is never returned to where the visitor left it',
|
defect: 'focus is never returned to where the visitor left it',
|
||||||
file: 'module',
|
file: 'keyboard',
|
||||||
from: "if (typeof previouslyFocused?.focus === 'function' && previouslyFocused.isConnected) {",
|
from: "if (typeof target?.focus === 'function' && target.isConnected) {",
|
||||||
to: 'if (false) {',
|
to: 'if (false) {',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
defect: 'app hotkeys eat the launcher keys (bubble instead of capture)',
|
defect: 'app hotkeys eat the launcher keys (bubble instead of capture)',
|
||||||
file: 'module',
|
file: 'keyboard',
|
||||||
from: "documentRef.addEventListener('keydown', onKeyDown, true);",
|
from: "documentRef.addEventListener('keydown', onKeyDown, true);",
|
||||||
to: "documentRef.addEventListener('keydown', onKeyDown);",
|
to: "documentRef.addEventListener('keydown', onKeyDown);",
|
||||||
},
|
},
|
||||||
@@ -492,7 +493,7 @@ for (const { defect, file, from, to } of MUTATIONS) {
|
|||||||
let red = false;
|
let red = false;
|
||||||
let by = '';
|
let by = '';
|
||||||
try {
|
try {
|
||||||
execFileSync('node', ['--test', TESTS], { cwd: ROOT, encoding: 'utf8', stdio: 'pipe' });
|
execFileSync('node', ['--test', ...TESTS], { cwd: ROOT, encoding: 'utf8', stdio: 'pipe' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
red = true;
|
red = true;
|
||||||
const failed = String(error.stdout || '').split('\n')
|
const failed = String(error.stdout || '').split('\n')
|
||||||
|
|||||||
+14
-62
@@ -1,3 +1,5 @@
|
|||||||
|
import { createSurfaceKeyboard } from './ui/surfaceKeyboard.js';
|
||||||
|
|
||||||
// First-run mission launcher.
|
// First-run mission launcher.
|
||||||
//
|
//
|
||||||
// The map deliberately does not auto-enable live feeds on every visit: doing so
|
// 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 suppressBox = root.querySelector('[data-first-run-suppress]');
|
||||||
const buttons = [...root.querySelectorAll('[data-first-run-choice]')];
|
const buttons = [...root.querySelectorAll('[data-first-run-choice]')];
|
||||||
const defaultStatus = status?.textContent || '';
|
const defaultStatus = status?.textContent || '';
|
||||||
const previouslyFocused = documentRef.activeElement;
|
|
||||||
let busy = false;
|
let busy = false;
|
||||||
let closing = 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.
|
* 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
|
* The attribution lightbox is a full-screen overlay at `z-index: 200` against
|
||||||
@@ -401,7 +398,6 @@ export function initFirstRunExperience({
|
|||||||
rememberFirstRunSessionDismissed(sessionStorageRef);
|
rememberFirstRunSessionDismissed(sessionStorageRef);
|
||||||
root.classList.remove('visible');
|
root.classList.remove('visible');
|
||||||
root.setAttribute('aria-hidden', 'true');
|
root.setAttribute('aria-hidden', 'true');
|
||||||
documentRef.removeEventListener('keydown', onKeyDown, true);
|
|
||||||
globalThis.removeEventListener?.('resize', onViewportResize);
|
globalThis.removeEventListener?.('resize', onViewportResize);
|
||||||
surfaceObserver?.disconnect();
|
surfaceObserver?.disconnect();
|
||||||
const remove = () => root.remove();
|
const remove = () => root.remove();
|
||||||
@@ -411,12 +407,7 @@ export function initFirstRunExperience({
|
|||||||
globalThis.setTimeout?.(remove, 400);
|
globalThis.setTimeout?.(remove, 400);
|
||||||
// Return the keyboard where it was, not to a node that is being removed —
|
// 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.
|
// but never when yielding, because the surface taking over owns focus now.
|
||||||
if (!restoreFocus) return;
|
keyboard.deactivate({ restoreFocus });
|
||||||
if (typeof previouslyFocused?.focus === 'function' && previouslyFocused.isConnected) {
|
|
||||||
previouslyFocused.focus({ preventScroll: true });
|
|
||||||
} else {
|
|
||||||
documentRef.body?.focus?.({ preventScroll: true });
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const setBusy = (next, choice = '') => {
|
const setBusy = (next, choice = '') => {
|
||||||
@@ -491,60 +482,21 @@ export function initFirstRunExperience({
|
|||||||
status.textContent = 'This browser is blocking storage, so that could not be saved.';
|
status.textContent = 'This browser is blocking storage, so that could not be saved.';
|
||||||
};
|
};
|
||||||
|
|
||||||
function onKeyDown(event) {
|
const keyboard = createSurfaceKeyboard({
|
||||||
// THE ARBITRATION RULE: never consume input for a card nobody can see.
|
root,
|
||||||
// The observer below normally removes the launcher before another surface
|
documentRef,
|
||||||
// finishes engaging, but MutationObserver callbacks are microtasks, so a
|
// A measurable card may still be hidden by another surface. Check on the
|
||||||
// keydown can still arrive in the window between the class landing and the
|
// key itself, before the observer has had a chance to process that change.
|
||||||
// yield running. This check closes that window deterministically.
|
isActive: () => !closing && isTopmost(),
|
||||||
if (closing || !isTopmost()) return;
|
onEscape: () => dismiss(),
|
||||||
// AND THE BELT FOR THE COOPERATIVE HALF. A surface that owns this key marks
|
fallbackFocus: () => documentRef.body,
|
||||||
// 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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const button of buttons) button.addEventListener('click', onChoice);
|
for (const button of buttons) button.addEventListener('click', onChoice);
|
||||||
suppressBox?.addEventListener('change', onSuppressChange);
|
suppressBox?.addEventListener('change', onSuppressChange);
|
||||||
// Capture phase: the app binds its own global hotkeys (including bare letters
|
// Capture phase: the app binds its own global hotkeys (including bare letters
|
||||||
// that cycle detection and styles), and the launcher owns the keyboard first.
|
// 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
|
// 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
|
// 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.
|
// Teardown is not a user dismissal and must not change the show preference.
|
||||||
const destroy = () => {
|
const destroy = () => {
|
||||||
closing = true;
|
closing = true;
|
||||||
documentRef.removeEventListener('keydown', onKeyDown, true);
|
keyboard.destroy();
|
||||||
globalThis.removeEventListener?.('resize', onViewportResize);
|
globalThis.removeEventListener?.('resize', onViewportResize);
|
||||||
surfaceObserver?.disconnect();
|
surfaceObserver?.disconnect();
|
||||||
root.remove();
|
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', () => {
|
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');
|
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
|
// 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.
|
// card, which is precisely how a Scene left an invisible ESC handler armed.
|
||||||
assert.match(module, /const isTopmost = \(\) => root\.isConnected/);
|
assert.match(module, /const isTopmost = \(\) => root\.isConnected/);
|
||||||
assert.match(module, /&& root\.getClientRects\(\)\.length > 0\s*\n\s*&& !coveredByOverlay\(\);/);
|
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(
|
assert.match(
|
||||||
handler.slice(0, handler.indexOf("if (event.key === 'Escape')")),
|
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',
|
'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,
|
// Belt on the launcher side: a key another surface already marked is not ours,
|
||||||
// whether or not that surface remembered to silence us.
|
// 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(
|
assert.match(
|
||||||
handler.slice(0, handler.indexOf("if (event.key === 'Escape')")),
|
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',
|
'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(
|
const handler = module.slice(
|
||||||
module.indexOf('const onSuppressChange = (event) => {'),
|
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, /if \(setFirstRunSuppressed\(wanted, storage\)\) return;/);
|
||||||
assert.match(handler, /box\.checked = !wanted;/, 'a refused write must revert the tick');
|
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.match(module, /button\.setAttribute\('aria-disabled', String\(next\)\)/);
|
||||||
assert.doesNotMatch(module, /button\.disabled = /);
|
assert.doesNotMatch(module, /button\.disabled = /);
|
||||||
// Tab is confined to the launcher, and ESC always releases it.
|
// Tab is confined to the launcher, and ESC always releases it.
|
||||||
assert.match(module, /event\.key !== 'Tab'/);
|
const keyboard = fs.readFileSync(new URL('./ui/surfaceKeyboard.js', import.meta.url), 'utf8');
|
||||||
assert.match(module, /event\.key === 'Escape'/);
|
assert.match(module, /keyboard\.activate\(\)/);
|
||||||
assert.match(module, /previouslyFocused\?\.focus/);
|
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.
|
// 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', () => {
|
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.
|
* 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 || '';
|
const defaultStatusText = statusLine?.textContent || '';
|
||||||
let busy = false;
|
let busy = false;
|
||||||
let open = false;
|
let open = false;
|
||||||
let previouslyFocused = null;
|
|
||||||
|
|
||||||
const render = (nextStatus) => {
|
const render = (nextStatus) => {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
@@ -202,47 +203,18 @@ export async function initKeySetup({ documentRef = globalThis.document, fetchImp
|
|||||||
&& root.classList.contains('visible')
|
&& root.classList.contains('visible')
|
||||||
&& root.getClientRects().length > 0;
|
&& root.getClientRects().length > 0;
|
||||||
|
|
||||||
const focusables = () => [
|
const keyboard = createSurfaceKeyboard({
|
||||||
...root.querySelectorAll('button, input, [href], [tabindex]:not([tabindex="-1"])'),
|
root,
|
||||||
].filter((node) => !node.hasAttribute('disabled') && node.getClientRects().length > 0);
|
documentRef,
|
||||||
|
isActive: () => open && visible(),
|
||||||
const onKeyDown = (event) => {
|
onEscape: () => close(),
|
||||||
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 openDialog = () => {
|
const openDialog = () => {
|
||||||
if (disposed || open) return;
|
if (disposed || open) return;
|
||||||
open = true;
|
open = true;
|
||||||
previouslyFocused = documentRef.activeElement;
|
keyboard.activate();
|
||||||
root.hidden = false;
|
root.hidden = false;
|
||||||
documentRef.addEventListener('keydown', onKeyDown, true);
|
|
||||||
globalThis.requestAnimationFrame?.(() => {
|
globalThis.requestAnimationFrame?.(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
root.classList.add('visible');
|
root.classList.add('visible');
|
||||||
@@ -253,15 +225,12 @@ export async function initKeySetup({ documentRef = globalThis.document, fetchImp
|
|||||||
const close = () => {
|
const close = () => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
open = false;
|
open = false;
|
||||||
documentRef.removeEventListener('keydown', onKeyDown, true);
|
|
||||||
root.classList.remove('visible');
|
root.classList.remove('visible');
|
||||||
const hide = () => { if (!open) root.hidden = true; };
|
const hide = () => { if (!open) root.hidden = true; };
|
||||||
root.addEventListener('transitionend', hide, { once: true });
|
root.addEventListener('transitionend', hide, { once: true });
|
||||||
globalThis.setTimeout?.(hide, 400);
|
globalThis.setTimeout?.(hide, 400);
|
||||||
if (statusLine) statusLine.textContent = defaultStatusText;
|
if (statusLine) statusLine.textContent = defaultStatusText;
|
||||||
if (typeof previouslyFocused?.focus === 'function' && previouslyFocused.isConnected) {
|
keyboard.deactivate({ restoreFocus: true });
|
||||||
previouslyFocused.focus({ preventScroll: true });
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const say = (text) => { if (statusLine) statusLine.textContent = text; };
|
const say = (text) => { if (statusLine) statusLine.textContent = text; };
|
||||||
@@ -365,7 +334,7 @@ export async function initKeySetup({ documentRef = globalThis.document, fetchImp
|
|||||||
|
|
||||||
disposeControls = () => {
|
disposeControls = () => {
|
||||||
open = false;
|
open = false;
|
||||||
documentRef.removeEventListener('keydown', onKeyDown, true);
|
keyboard.destroy();
|
||||||
chip.removeEventListener('click', openDialog);
|
chip.removeEventListener('click', openDialog);
|
||||||
closeButton?.removeEventListener('click', close);
|
closeButton?.removeEventListener('click', close);
|
||||||
applyButton?.removeEventListener('click', onApply);
|
applyButton?.removeEventListener('click', onApply);
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
/**
|
||||||
|
* 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,221 @@
|
|||||||
|
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