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

+3
View File
@@ -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
+18
View File
@@ -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.
+12
View File
@@ -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
View File
@@ -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"
}
}
+5
View File
@@ -268,5 +268,10 @@
"exports": ["./ui/panels"],
"modules": ["src/ui/panelDisclosure.js"],
"external": []
},
"surface-keyboard": {
"exports": ["./ui/surfaces"],
"modules": ["src/ui/surfaceKeyboard.js"],
"external": []
}
}
+15 -14
View File
@@ -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
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);
});