mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 02:44:02 +00:00
refactor(ui): share surface keyboard lifecycle
This commit is contained in:
1 parent
d5b09efa74
commit
e1ffb9c572
11 files changed
+367
-128
No files matched your search
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Own capture-phase Escape and Tab handling while a surface is active.
|
||||
* The caller owns visibility, content, initial focus and dismissal policy.
|
||||
* This does not make the surface modal or make the rest of the page inert.
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {HTMLElement} options.root Surface containing the keyboard controls.
|
||||
* @param {Document} [options.documentRef] Document that dispatches keyboard input.
|
||||
* @param {() => boolean} options.isActive Whether this surface may handle input now.
|
||||
* @param {() => void} options.onEscape Caller-owned dismissal action.
|
||||
* @param {() => HTMLElement} [options.fallbackFocus] Return target if the opener is gone.
|
||||
* @returns {{activate: Function, deactivate: Function, destroy: Function}}
|
||||
*/
|
||||
export function createSurfaceKeyboard({
|
||||
root,
|
||||
documentRef = root.ownerDocument,
|
||||
isActive,
|
||||
onEscape,
|
||||
fallbackFocus,
|
||||
}) {
|
||||
let active = false;
|
||||
let destroyed = false;
|
||||
let previouslyFocused = null;
|
||||
|
||||
const onKeyDown = (event) => {
|
||||
// Visibility/arbitration belongs to the screen, including overlays that
|
||||
// cover a measurable box. Respect another handler's claim on the same key.
|
||||
if (!active || destroyed || !isActive() || event.defaultPrevented) return;
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onEscape();
|
||||
return;
|
||||
}
|
||||
if (event.key !== 'Tab') return;
|
||||
const order = [
|
||||
...root.querySelectorAll('button, input, [href], [tabindex]:not([tabindex="-1"])'),
|
||||
].filter((node) => !node.hasAttribute('disabled') && node.getClientRects().length > 0);
|
||||
if (!order.length) return;
|
||||
const first = order[0];
|
||||
const last = order[order.length - 1];
|
||||
const focused = documentRef.activeElement;
|
||||
// Allow native scrolling when cycling to an off-screen control in a short
|
||||
// surface. Initial focus and return focus use the caller's separate policy.
|
||||
if (!root.contains(focused)) {
|
||||
event.preventDefault();
|
||||
(event.shiftKey ? last : first).focus();
|
||||
} else if (event.shiftKey && focused === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && focused === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const activate = () => {
|
||||
if (destroyed || active) return;
|
||||
active = true;
|
||||
previouslyFocused = documentRef.activeElement;
|
||||
documentRef.addEventListener('keydown', onKeyDown, true);
|
||||
};
|
||||
|
||||
const deactivate = ({ restoreFocus = false } = {}) => {
|
||||
if (!active) return;
|
||||
active = false;
|
||||
documentRef.removeEventListener('keydown', onKeyDown, true);
|
||||
const target = previouslyFocused;
|
||||
previouslyFocused = null;
|
||||
if (!restoreFocus) return;
|
||||
if (typeof target?.focus === 'function' && target.isConnected) {
|
||||
target.focus({ preventScroll: true });
|
||||
} else {
|
||||
fallbackFocus?.()?.focus?.({ preventScroll: true });
|
||||
}
|
||||
};
|
||||
|
||||
const destroy = () => {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
deactivate();
|
||||
};
|
||||
|
||||
return { activate, deactivate, destroy };
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createSurfaceKeyboard } from './surfaceKeyboard.js';
|
||||
|
||||
function fixture() {
|
||||
const listeners = new Set();
|
||||
const documentRef = {
|
||||
activeElement: null,
|
||||
addEventListener(type, handler, capture) {
|
||||
assert.equal(type, 'keydown');
|
||||
assert.equal(capture, true);
|
||||
listeners.add(handler);
|
||||
},
|
||||
removeEventListener(type, handler, capture) {
|
||||
assert.equal(type, 'keydown');
|
||||
assert.equal(capture, true);
|
||||
listeners.delete(handler);
|
||||
},
|
||||
};
|
||||
const node = () => ({
|
||||
isConnected: true,
|
||||
hidden: false,
|
||||
disabled: false,
|
||||
focusCalls: [],
|
||||
hasAttribute(name) { return name === 'disabled' && this.disabled; },
|
||||
getClientRects() { return this.hidden ? [] : [{}]; },
|
||||
focus(options) { documentRef.activeElement = this; this.focusCalls.push(options); },
|
||||
});
|
||||
const opener = node();
|
||||
const fallback = node();
|
||||
const first = node();
|
||||
const middle = node();
|
||||
const last = node();
|
||||
let controls = [first, middle, last];
|
||||
const root = {
|
||||
ownerDocument: documentRef,
|
||||
querySelectorAll: () => controls,
|
||||
contains: (target) => controls.includes(target),
|
||||
};
|
||||
let visible = true;
|
||||
let escapes = 0;
|
||||
let onEscape = () => { escapes += 1; };
|
||||
const controller = createSurfaceKeyboard({
|
||||
root,
|
||||
isActive: () => visible,
|
||||
onEscape: () => onEscape(),
|
||||
fallbackFocus: () => fallback,
|
||||
});
|
||||
opener.focus();
|
||||
const event = (key, extra = {}) => ({
|
||||
key,
|
||||
defaultPrevented: false,
|
||||
stopped: false,
|
||||
preventDefault() { this.defaultPrevented = true; },
|
||||
stopPropagation() { this.stopped = true; },
|
||||
...extra,
|
||||
});
|
||||
const send = (key, extra) => {
|
||||
const e = event(key, extra);
|
||||
for (const listener of [...listeners]) listener(e);
|
||||
return e;
|
||||
};
|
||||
return { controller, listeners, documentRef, opener, fallback, first, middle, last, node, event, send,
|
||||
setVisible(value) { visible = value; },
|
||||
setControls(value) { controls = value; },
|
||||
setEscape(value) { onEscape = value; },
|
||||
escapes: () => escapes,
|
||||
};
|
||||
}
|
||||
|
||||
test('construction is inert; activation is idempotent and captures the opener once', () => {
|
||||
const f = fixture();
|
||||
assert.equal(f.listeners.size, 0);
|
||||
f.controller.activate();
|
||||
f.first.focus();
|
||||
f.controller.activate();
|
||||
assert.equal(f.listeners.size, 1);
|
||||
f.controller.deactivate({ restoreFocus: true });
|
||||
assert.equal(f.listeners.size, 0);
|
||||
assert.equal(f.documentRef.activeElement, f.opener);
|
||||
assert.deepEqual(f.opener.focusCalls.at(-1), { preventScroll: true });
|
||||
});
|
||||
|
||||
test('Tab enters from outside in either direction and wraps only at an edge', () => {
|
||||
const f = fixture();
|
||||
f.controller.activate();
|
||||
assert.equal(f.send('Tab').defaultPrevented, true);
|
||||
assert.equal(f.documentRef.activeElement, f.first);
|
||||
assert.equal(f.send('Tab').defaultPrevented, false);
|
||||
f.middle.focus();
|
||||
assert.equal(f.send('Tab', { shiftKey: true }).defaultPrevented, false);
|
||||
f.last.focus();
|
||||
f.send('Tab');
|
||||
assert.equal(f.documentRef.activeElement, f.first);
|
||||
f.send('Tab', { shiftKey: true });
|
||||
assert.equal(f.documentRef.activeElement, f.last);
|
||||
f.opener.focus();
|
||||
f.send('Tab', { shiftKey: true });
|
||||
assert.equal(f.documentRef.activeElement, f.last);
|
||||
assert.equal(f.last.focusCalls.at(-1), undefined, 'wrapping permits native scroll into view');
|
||||
});
|
||||
|
||||
test('Tab uses current controls and skips hidden/disabled nodes without changing native middle movement', () => {
|
||||
const f = fixture();
|
||||
f.controller.activate();
|
||||
f.first.hidden = true;
|
||||
f.last.disabled = true;
|
||||
f.send('Tab');
|
||||
assert.equal(f.documentRef.activeElement, f.middle);
|
||||
const replacement = f.node();
|
||||
f.setControls([replacement]);
|
||||
f.send('Tab');
|
||||
assert.equal(f.documentRef.activeElement, replacement);
|
||||
f.setControls([]);
|
||||
assert.equal(f.send('Tab').defaultPrevented, false);
|
||||
});
|
||||
|
||||
test('hidden or covered surfaces and already-claimed keys never dismiss or redirect focus', () => {
|
||||
const f = fixture();
|
||||
f.controller.activate();
|
||||
f.setVisible(false);
|
||||
assert.equal(f.send('Escape').defaultPrevented, false);
|
||||
assert.equal(f.send('Tab').defaultPrevented, false);
|
||||
f.setVisible(true);
|
||||
f.send('Escape', { defaultPrevented: true });
|
||||
f.send('Tab', { defaultPrevented: true });
|
||||
assert.equal(f.escapes(), 0);
|
||||
assert.equal(f.documentRef.activeElement, f.opener);
|
||||
assert.equal(f.send('x').defaultPrevented, false);
|
||||
});
|
||||
|
||||
test('Escape claims input before calling dismissal; reentrant destruction is safe', () => {
|
||||
const f = fixture();
|
||||
f.controller.activate();
|
||||
const handler = [...f.listeners][0];
|
||||
const e = f.event('Escape');
|
||||
f.setEscape(() => {
|
||||
assert.equal(e.defaultPrevented, true);
|
||||
assert.equal(e.stopped, true);
|
||||
f.controller.destroy();
|
||||
});
|
||||
handler(e);
|
||||
assert.equal(f.listeners.size, 0);
|
||||
const late = f.event('Escape');
|
||||
handler(late);
|
||||
assert.equal(late.defaultPrevented, false);
|
||||
});
|
||||
|
||||
test('deactivation can yield without stealing focus, then reopen with a new return target', () => {
|
||||
const f = fixture();
|
||||
f.controller.activate();
|
||||
f.first.focus();
|
||||
f.controller.deactivate();
|
||||
assert.equal(f.documentRef.activeElement, f.first);
|
||||
f.last.focus();
|
||||
f.controller.activate();
|
||||
f.middle.focus();
|
||||
f.controller.deactivate({ restoreFocus: true });
|
||||
assert.equal(f.documentRef.activeElement, f.last);
|
||||
const calls = f.last.focusCalls.length;
|
||||
f.controller.deactivate({ restoreFocus: true });
|
||||
assert.equal(f.last.focusCalls.length, calls);
|
||||
});
|
||||
|
||||
test('a disconnected opener uses the caller fallback', () => {
|
||||
const f = fixture();
|
||||
f.controller.activate();
|
||||
f.opener.isConnected = false;
|
||||
f.controller.deactivate({ restoreFocus: true });
|
||||
assert.equal(f.documentRef.activeElement, f.fallback);
|
||||
});
|
||||
|
||||
test('destroy is permanent, releases its listener and never restores focus', () => {
|
||||
const f = fixture();
|
||||
f.controller.activate();
|
||||
const handler = [...f.listeners][0];
|
||||
f.first.focus();
|
||||
f.controller.destroy();
|
||||
f.controller.destroy();
|
||||
f.controller.activate();
|
||||
f.controller.deactivate({ restoreFocus: true });
|
||||
assert.equal(f.listeners.size, 0);
|
||||
assert.equal(f.documentRef.activeElement, f.first);
|
||||
const e = f.event('Tab');
|
||||
handler(e);
|
||||
assert.equal(e.defaultPrevented, false);
|
||||
});
|
||||
Reference in new issue
Block a user