diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c4ca0590..bfab34d86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md index d640df92c..272605ef6 100644 --- a/docs/CODE-BOUNDARIES.md +++ b/docs/CODE-BOUNDARIES.md @@ -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. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 0ecc6d72d..431ed2426 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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 diff --git a/package.json b/package.json index 5913db387..702540200 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 22e450e63..02aff268b 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -268,5 +268,10 @@ "exports": ["./ui/panels"], "modules": ["src/ui/panelDisclosure.js"], "external": [] + }, + "surface-keyboard": { + "exports": ["./ui/surfaces"], + "modules": ["src/ui/surfaceKeyboard.js"], + "external": [] } } diff --git a/scripts/qa-firstrun-mutations.mjs b/scripts/qa-firstrun-mutations.mjs index 3d5f15ba5..b8b7c9f5e 100644 --- a/scripts/qa-firstrun-mutations.mjs +++ b/scripts/qa-firstrun-mutations.mjs @@ -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') diff --git a/src/firstRunExperience.js b/src/firstRunExperience.js index a664a70bc..c9f09b1a9 100644 --- a/src/firstRunExperience.js +++ b/src/firstRunExperience.js @@ -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(); diff --git a/src/firstRunExperience.test.mjs b/src/firstRunExperience.test.mjs index f5ed953e3..c45446183 100644 --- a/src/firstRunExperience.test.mjs +++ b/src/firstRunExperience.test.mjs @@ -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', () => { diff --git a/src/keySetup.js b/src/keySetup.js index 82bb44c81..b0006e7f5 100644 --- a/src/keySetup.js +++ b/src/keySetup.js @@ -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); diff --git a/src/ui/surfaceKeyboard.js b/src/ui/surfaceKeyboard.js new file mode 100644 index 000000000..e7eb07404 --- /dev/null +++ b/src/ui/surfaceKeyboard.js @@ -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 }; +} diff --git a/src/ui/surfaceKeyboard.test.mjs b/src/ui/surfaceKeyboard.test.mjs new file mode 100644 index 000000000..36a3d03b5 --- /dev/null +++ b/src/ui/surfaceKeyboard.test.mjs @@ -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); +});