fix: preserve keyboard focus and separate Space activation from voice

Keep focus visible across controls and stable during Contacts, Cockpit and mission roster updates. Preserve native short Space activation, and claim push-to-talk only after a 500 ms hold. Add panel Escape handling and keyboard-accessible attribution controls.

Retains the Map Source foundation contributed in https://github.com/bilawalsidhu/gods-eye-view/pull/171.
This commit is contained in:
manjunath22466 authored and Sameh Khamis committed 2026-09-10 23:48:12 -07:00
1 parent c82fdd7478
commit 5228ab9e40
22 files changed
+3500 -157

No files matched your search

+223
View File
@@ -0,0 +1,223 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { formatAwarenessLabel } from './data/militaryAwarenessEngine.js';
const source = readFileSync(new URL('./ui.js', import.meta.url), 'utf8');
const renderStart = source.indexOf(' renderCockpitSignals() {');
const renderEnd = source.indexOf(' setContextCollapsed(', renderStart);
assert.ok(renderStart >= 0 && renderEnd > renderStart);
const clickSource = source.match(/this\._listen\(this\.signalList, 'click', (\(event\) => \{[\s\S]*?\n \})\);/)?.[1];
assert.ok(clickSource, 'the installed signal click route exists');
// Model native focus loss on removal AND on ordinary DOM moves. Keeping an
// object reference alone is insufficient if reconciliation disconnects it.
function fixture() {
const document = { activeElement: null };
let focusLosses = 0;
let focusCalls = 0;
class Node {
constructor(tagName) {
this.tagName = tagName.toUpperCase();
this.children = [];
this.parentNode = null;
this.dataset = {};
this.attributes = new Map();
this.className = '';
this._text = '';
this.classList = {
add: (...names) => { this.className = [...new Set([...this.className.split(/\s+/).filter(Boolean), ...names])].join(' '); },
};
}
get isConnected() { return this === document.body || Boolean(this.parentNode?.isConnected); }
get firstElementChild() { return this.children[0] || null; }
get nextElementSibling() { return this.parentNode?.children[this.parentNode.children.indexOf(this) + 1] || null; }
get textContent() { return this._text + this.children.map((node) => node.textContent).join(''); }
set textContent(value) { this.replaceChildren(); this._text = String(value); }
setAttribute(name, value) { this.attributes.set(name, String(value)); }
getAttribute(name) { return this.attributes.get(name) ?? null; }
contains(node) { return this === node || this.children.some((child) => child.contains(node)); }
append(...nodes) { for (const node of nodes) this.insertBefore(node, null); }
insertBefore(node, anchor) {
assert.ok(anchor === null || anchor.parentNode === this, 'insert anchor belongs to its parent');
if (node === anchor) return node;
node.remove();
const index = anchor === null ? this.children.length : this.children.indexOf(anchor);
this.children.splice(index, 0, node);
node.parentNode = this;
return node;
}
remove() {
if (!this.parentNode) return;
if (this.contains(document.activeElement)) {
document.activeElement = document.body;
focusLosses += 1;
}
this.parentNode.children.splice(this.parentNode.children.indexOf(this), 1);
this.parentNode = null;
}
replaceChildren(...nodes) {
for (const child of [...this.children]) child.remove();
this._text = '';
this.append(...nodes);
}
matches(selector) {
if (selector === 'button[data-signal-layer][data-signal-id]') {
return this.tagName === 'BUTTON' && this.dataset.signalLayer != null && this.dataset.signalId != null;
}
return selector.startsWith('.') ? this.className.split(/\s+/).includes(selector.slice(1))
: this.tagName === selector.toUpperCase();
}
closest(selector) {
for (let node = this; node; node = node.parentNode) if (node.matches(selector)) return node;
return null;
}
querySelectorAll(selector) {
return this.children.flatMap((child) => [ ...(child.matches(selector) ? [child] : []), ...child.querySelectorAll(selector) ]);
}
focus() {
assert.ok(this.isConnected, 'a focus destination must still be connected');
focusCalls += 1;
document.activeElement = this;
}
}
document.body = new Node('body');
document.createElement = (tag) => new Node(tag);
document.activeElement = document.body;
const controller = new (new Function('document', 'formatAwarenessLabel',
`return class { ${source.slice(renderStart, renderEnd)} };`)(document, formatAwarenessLabel))();
controller.signalList = new Node('ol');
controller.signalToggle = new Node('button');
controller.briefTabs = [new Node('button'), new Node('button'), new Node('button')];
controller.briefPageIndex = 0;
controller.signalItems = [];
controller.signalSignatures = new Map();
controller.scheduleContextLayout = () => {};
const display = new Node('button');
const radio = new Node('button');
const outside = new Node('button');
document.body.append(controller.signalToggle, controller.signalList, ...controller.briefTabs, display, radio, outside);
const selected = [];
const click = new Function('militaryAwarenessLayer', `return ${clickSource};`)({
focusTarget: (...args) => selected.push(args),
});
const buttons = () => controller.signalList.querySelectorAll('button');
const tab = () => {
const nodes = document.body.querySelectorAll('button');
const index = nodes.indexOf(document.activeElement);
nodes[(index + 1) % nodes.length].focus();
};
return { controller, document, buttons, display, radio, outside, selected, click, tab,
losses: () => focusLosses, focusCalls: () => focusCalls };
}
const item = (id, overrides = {}) => ({
key: `flight:flights:${id}`, tone: 'nearby', title: `Flight ${id}`, detail: 'COMMERCIAL FLIGHT · 2 KM',
target: { layerId: 'flights', id }, timestamp: 1_700_000_000_000, ...overrides,
});
const render = (f, items) => { f.controller.signalItems = items; f.controller.renderCockpitSignals(); };
const snapshot = (distanceM = 2000) => ({
evaluatedAt: 1_700_000_000_000,
subject: { layerId: 'flights', id: 'current', label: 'CURRENT' },
cohorts: [{ id: 'flights', count: 1, nearest: [{ id: 'nearby', label: 'NEARBY', distanceM }] }],
});
test('actual Context refresh retains the focused signal button across unchanged and changed details', () => {
const f = fixture();
f.controller.updateCockpitSignals(snapshot(), 0);
const target = f.buttons()[1]; target.focus();
const calls = f.focusCalls();
for (let tick = 0; tick < 8; tick += 1) f.controller.updateCockpitSignals(snapshot(2500), 0);
assert.equal(f.buttons()[1], target);
assert.equal(f.document.activeElement, target);
assert.equal(f.losses(), 0);
assert.equal(f.focusCalls(), calls, 'refresh must preserve focus without repeatedly restoring it');
assert.match(f.controller.signalList.textContent, /2\.5 KM/);
});
test('signal content and accessible name update without replacing the active target', () => {
const f = fixture(); render(f, [item('a')]);
const target = f.buttons()[0]; target.focus();
render(f, [item('a', { title: 'RENAMED', tone: 'track', detail: 'CURRENT', timestamp: 1_700_000_001_000 })]);
assert.equal(f.buttons()[0], target);
assert.equal(target.getAttribute('aria-label'), 'Select flight RENAMED');
assert.match(target.textContent, /RENAMED/);
assert.match(f.controller.signalList.textContent, /CURRENT/);
assert.equal(f.controller.signalList.children[0].className, 'track actionable');
assert.equal(f.document.activeElement, target);
assert.equal(f.losses(), 0);
});
test('signal ranking changes move surrounding rows without disconnecting the focused row', () => {
const f = fixture(); render(f, ['a', 'b', 'c', 'd'].map((id) => item(id)));
const target = f.buttons()[1]; target.focus();
const calls = f.focusCalls();
for (const order of [['d', 'c', 'b', 'a'], ['b', 'c', 'd', 'a'], ['a', 'd', 'c', 'b']]) {
render(f, order.map((id) => item(id)));
assert.deepEqual(f.buttons().map((button) => button.dataset.signalId), order);
assert.equal(f.document.activeElement, target);
assert.equal(f.losses(), 0);
}
assert.equal(f.focusCalls(), calls);
});
test('a new pushed signal preserves existing focus and delegated selection reads the current target', () => {
const f = fixture(); render(f, [item('a'), item('b')]);
const target = f.buttons()[1]; target.focus();
f.controller.pushCockpitSignal('status', 'warning', 'INPUT UNKNOWN', 'SOURCE UNAVAILABLE');
assert.equal(f.document.activeElement, target);
assert.equal(f.losses(), 0);
const event = { target: target.children[0], prevented: false, preventDefault() { this.prevented = true; } };
f.click(event);
assert.equal(event.prevented, true);
assert.deepEqual(f.selected, [['flights', 'b', { origin: 'user' }]]);
});
test('removing the focused identity uses the existing briefing tab once and permits forward traversal', () => {
const f = fixture(); render(f, [item('a'), item('b')]);
const removed = f.buttons()[1]; removed.focus();
const calls = f.focusCalls();
render(f, [item('a')]);
assert.equal(removed.isConnected, false);
assert.equal(f.document.activeElement, f.controller.briefTabs[0]);
assert.equal(f.focusCalls(), calls + 1);
f.tab(); f.tab(); f.tab();
assert.equal(f.document.activeElement, f.display);
render(f, [item('a', { detail: 'UPDATED' })]);
assert.equal(f.document.activeElement, f.display, 'later refresh must not pull focus back');
f.tab();
assert.equal(f.document.activeElement, f.radio);
});
test('a changed target cannot reuse a focused action for another flight', () => {
const f = fixture(); render(f, [item('a', { key: 'shared-status-key' })]);
const removed = f.buttons()[0]; removed.focus();
render(f, [item('b', { key: 'shared-status-key', target: { layerId: 'military', id: 'b' } })]);
const target = f.buttons()[0];
assert.notEqual(target, removed);
assert.equal(removed.isConnected, false);
assert.equal(f.document.activeElement, f.controller.briefTabs[0]);
f.click({ target, preventDefault() {} });
assert.deepEqual(f.selected, [['military', 'b', { origin: 'user' }]]);
});
test('empty, informational and duplicate-key rows have no stale or aliased actions', () => {
const f = fixture();
render(f, [item('a'), item('a')]);
const [first, second] = f.buttons();
assert.notEqual(first, second);
second.focus();
render(f, [item('a'), item('a')]);
assert.deepEqual(f.buttons(), [first, second]);
assert.equal(f.document.activeElement, second);
render(f, [item('status', { target: null, title: 'UNKNOWN' })]);
assert.equal(f.buttons().length, 0);
assert.match(f.controller.signalList.textContent, /UNKNOWN/);
assert.equal(f.document.activeElement, f.controller.briefTabs[0]);
f.outside.focus();
render(f, []);
assert.equal(f.controller.signalList.children.length, 0);
render(f, [item('a')]);
assert.notEqual(f.buttons()[0], first);
assert.equal(f.document.activeElement, f.outside);
});
+161
View File
@@ -0,0 +1,161 @@
import { readFileSync } from 'node:fs';
import { test } from 'node:test';
import assert from 'node:assert/strict';
const html = readFileSync(new URL('../index.html', import.meta.url), 'utf8');
const ui = readFileSync(new URL('./ui.js', import.meta.url), 'utf8');
const css = readFileSync(new URL('../style.css', import.meta.url), 'utf8');
test('Contacts and Space Missions both participate in the ordinary Tab sequence', () => {
for (const id of ['global-context-flights-btn', 'global-context-missions-btn']) {
const button = html.match(new RegExp(`<button id="${id}"[\\s\\S]*?</button>`));
assert.ok(button, `${id} is missing`);
assert.match(button[0], /role="tab"/);
assert.match(button[0], /tabindex="0"/);
}
const sync = ui.match(/_syncContextModeButtons\(\) \{([\s\S]*?)\n \}\n\n \/\*\* Wire/);
assert.ok(sync, 'Context mode sync is missing');
assert.match(sync[1], /\[this\._globalContextFlightsBtn, this\._globalContextMissionsBtn\]/);
assert.match(sync[1], /button\.tabIndex = 0/);
assert.doesNotMatch(sync[1], /tabIndex\s*=\s*[^;]*\?\s*-1/);
});
test('Context transition state preserves focus and Tab availability until settle', () => {
const sync = ui.match(/_syncContextModeButtons\(\) \{([\s\S]*?)\n \}\n\n \/\*\* Wire/);
assert.ok(sync, 'Context mode sync is missing');
const attributes = () => new Map();
const makeButton = () => {
const attrs = attributes();
let disabled = false;
const button = {
attrs,
tabIndex: -1,
classList: { toggle() {} },
setAttribute(name, value) { attrs.set(name, String(value)); },
get disabled() { return disabled; },
set disabled(value) {
disabled = Boolean(value);
// Model the browser behavior that exposed this regression: native
// disabled drops focus immediately.
if (disabled && globalThis.document?.activeElement === button) {
globalThis.document.activeElement = null;
}
},
};
return button;
};
const contacts = makeButton();
const missions = makeButton();
const panel = { classList: { toggle() {} }, setAttribute() {} };
const priorDocument = globalThis.document;
globalThis.document = { activeElement: missions, getElementById: () => panel };
const owner = {
_contextMode: null,
_contextModeChanging: true,
_globalContextFlightsBtn: contacts,
_globalContextMissionsBtn: missions,
_contextModeStandby: {},
_contextFlightsView: {},
_contextMissionsView: {},
cockpitView: { syncEntry() {} },
_syncContactsDetection() {},
_scheduleRightPanelLayout() {},
};
try {
Function(sync[1]).call(owner);
assert.equal(globalThis.document.activeElement, missions, 'busy sync retains focused Space Missions');
for (const button of [contacts, missions]) {
assert.equal(button.disabled, false);
assert.equal(button.tabIndex, 0);
assert.equal(button.attrs.get('aria-disabled'), 'true');
assert.equal(button.attrs.get('aria-busy'), 'true');
}
owner._contextModeChanging = false;
owner._contextMode = 'space-missions';
Function(sync[1]).call(owner);
assert.equal(globalThis.document.activeElement, missions, 'settled sync retains focused Space Missions');
for (const button of [contacts, missions]) {
assert.equal(button.disabled, false);
assert.equal(button.tabIndex, 0);
assert.equal(button.attrs.get('aria-disabled'), 'false');
assert.equal(button.attrs.get('aria-busy'), 'false');
}
} finally {
globalThis.document = priorDocument;
}
});
test('Context activation and Clear All never native-disable tabs and guard repeated clicks', () => {
const init = ui.slice(
ui.indexOf('_initGlobalContextPanel() {'),
ui.indexOf('async _runUserFacingContextAction(', ui.indexOf('_initGlobalContextPanel() {')),
);
const select = ui.slice(
ui.indexOf('async _selectContextMode('),
ui.indexOf('async _deactivateContextForLayerChange(', ui.indexOf('async _selectContextMode(')),
);
const clear = ui.slice(
ui.indexOf('clearSelectedLayers() {'),
ui.indexOf('resetToGlobeView() {', ui.indexOf('clearSelectedLayers() {')),
);
assert.equal((init.match(/if \(this\._contextModeChanging \|\| this\._clearSelectedLayersPromise\) return;/g) || []).length, 2);
assert.doesNotMatch(select, /_globalContext(?:Flights|Missions)Btn\.disabled\s*=\s*true/);
assert.doesNotMatch(clear, /_globalContext(?:Flights|Missions)Btn\.disabled\s*=\s*true/);
});
test('Context tablist retains Left, Right, Home, and End keyboard navigation', () => {
const init = ui.match(/_initGlobalContextPanel\(\) \{([\s\S]*?)\n this\._globalContextFlightsBtn\?\.addEventListener/);
assert.ok(init, 'Context panel initialization is missing');
assert.match(init[1], /event\.key === 'ArrowRight'/);
assert.match(init[1], /event\.key === 'ArrowLeft'/);
assert.match(init[1], /event\.key === 'Home'/);
assert.match(init[1], /event\.key === 'End'/);
assert.match(init[1], /contextTabs\[nextIndex\]\.focus\(\{ preventScroll: true \}\)/);
assert.match(init[1], /contextTabs\[nextIndex\]\.click\(\)/);
});
test('Context tabs draw a visible keyboard-focus outline including active tabs', () => {
const rules = [...css.matchAll(/([^{}]+)\{([^{}]*)\}/g)];
const focusRule = rules.find(([, selector, body]) => (
selector.trim().endsWith('.context-mode-button:focus-visible')
&& /outline:\s*2px solid var\(--text-primary\)/.test(body)
));
assert.ok(focusRule, 'Context focus-visible rule must draw a two-pixel outline');
assert.match(focusRule[2], /outline-offset:\s*-3px/);
const activeRule = rules.find(([, selector]) => selector.trim().endsWith('.context-mode-button.active'));
assert.ok(activeRule, 'Context active-state rule is missing');
assert.doesNotMatch(activeRule[2], /outline:\s*none/);
});
test('Context async action buttons remain focused while busy', () => {
const init = ui.slice(
ui.indexOf('_initGlobalContextPanel() {'),
ui.indexOf('async _runUserFacingContextAction(', ui.indexOf('_initGlobalContextPanel() {')),
);
const radio = ui.slice(
ui.indexOf('const toggleRadio = async (trigger) => {'),
ui.indexOf('this._contextRadioToggleBtn?.addEventListener', ui.indexOf('const toggleRadio = async (trigger) => {')),
);
const radioSync = ui.slice(
ui.indexOf('_renderRadioState(state) {'),
ui.indexOf('if (this._radioFilter)', ui.indexOf('_renderRadioState(state) {')),
);
const clear = ui.slice(
ui.indexOf('clearSelectedLayers() {'),
ui.indexOf('resetToGlobeView() {', ui.indexOf('clearSelectedLayers() {')),
);
assert.match(init, /button\.getAttribute\('aria-busy'\) === 'true'/);
assert.doesNotMatch(init, /button\.disabled\s*=\s*true/);
assert.match(radio, /trigger\.getAttribute\('aria-busy'\) === 'true'/);
assert.doesNotMatch(radio, /trigger\.disabled\s*=\s*true/);
for (const name of ['_radioEnableBtn', '_contextRadioMiniEnableBtn', '_cockpitRadioEnableBtn']) {
assert.match(radioSync, new RegExp(`${name}\\.disabled = false`));
}
assert.doesNotMatch(clear, /_clearSelectedLayersBtn\.disabled\s*=\s*true/);
assert.match(clear, /_clearSelectedLayersBtn\.setAttribute\('aria-busy', 'true'\)/);
});
+70
View File
@@ -0,0 +1,70 @@
/**
* Adds the keyboard semantics omitted by Cesium's href-less attribution links.
* Cesium retains ownership of showing and hiding its required credit lightbox.
*/
export function configureCreditKeyboardAccess(root = document) {
const expand = root?.querySelector?.('#cesium-credits .cesium-credit-expand-link');
const lightbox = root?.querySelector?.('.cesium-credit-lightbox');
const close = lightbox?.querySelector?.('.cesium-credit-lightbox-close');
if (!expand || !lightbox || !close) return false;
const overlay = lightbox.parentElement;
if (!lightbox.id) lightbox.id = 'cesium-credit-lightbox';
expand.setAttribute('role', 'button');
expand.setAttribute('tabindex', '0');
expand.setAttribute('aria-haspopup', 'dialog');
expand.setAttribute('aria-controls', lightbox.id);
expand.setAttribute('aria-expanded', 'false');
close.setAttribute('role', 'button');
close.setAttribute('tabindex', '0');
close.setAttribute('aria-label', 'Close data attribution');
if (expand.dataset.gevKeyboardReady === 'true') return true;
expand.dataset.gevKeyboardReady = 'true';
const installKeyboardActivation = (control) => {
let spacePressed = false;
control.addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
event.preventDefault();
if (!event.repeat) control.click();
return;
}
if (event.key !== ' ') return;
event.preventDefault();
if (!event.repeat) spacePressed = true;
});
control.addEventListener('keyup', (event) => {
if (event.key !== ' ') return;
event.preventDefault();
if (!spacePressed) return;
spacePressed = false;
control.click();
});
// A long Space hold may hand control to push-to-talk and move focus away.
// Cancelling the armed release prevents that eventual keyup from also
// activating the attribution control.
control.addEventListener('blur', () => { spacePressed = false; });
};
const closeAndRestoreFocus = () => {
expand.setAttribute('aria-expanded', 'false');
expand.focus();
};
installKeyboardActivation(expand);
installKeyboardActivation(close);
lightbox.addEventListener('keydown', (event) => {
if (event.key !== 'Escape' || event.defaultPrevented) return;
event.preventDefault();
event.stopPropagation();
close.click();
});
expand.addEventListener('click', () => {
expand.setAttribute('aria-expanded', 'true');
close.focus();
});
close.addEventListener('click', closeAndRestoreFocus);
overlay?.addEventListener?.('click', (event) => {
if (!lightbox.contains(event.target)) closeAndRestoreFocus();
});
return true;
}
+188
View File
@@ -0,0 +1,188 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { configureCreditKeyboardAccess } from './creditKeyboard.js';
function fakeElement() {
const attributes = new Map();
const listeners = new Map();
return {
dataset: {},
focused: false,
id: '',
setAttribute(name, value) { attributes.set(name, String(value)); },
getAttribute(name) { return attributes.get(name) ?? null; },
addEventListener(type, listener) {
if (!listeners.has(type)) listeners.set(type, []);
listeners.get(type).push(listener);
},
dispatch(type, event = {}) {
const dispatched = {
currentTarget: this,
target: this,
defaultPrevented: false,
propagationStopped: false,
preventDefault() { this.defaultPrevented = true; },
stopPropagation() { this.propagationStopped = true; },
...event,
};
for (const listener of listeners.get(type) || []) listener(dispatched);
return dispatched;
},
click() {
this.onclick?.();
this.dispatch('click');
},
focus() { this.focused = true; },
listenerCount(type) { return listeners.get(type)?.length || 0; },
};
}
function fixture() {
const expand = fakeElement();
const close = fakeElement();
const lightbox = fakeElement();
lightbox.querySelector = () => close;
lightbox.contains = (target) => target === lightbox || target === close;
const overlay = fakeElement();
lightbox.parentElement = overlay;
const root = {
querySelector(selector) {
if (selector === '#cesium-credits .cesium-credit-expand-link') return expand;
if (selector === '.cesium-credit-lightbox') return lightbox;
return null;
},
};
return { close, expand, lightbox, overlay, root };
}
test('Cesium Data attribution and close controls join the keyboard tab order', () => {
const f = fixture();
assert.equal(configureCreditKeyboardAccess(f.root), true);
assert.equal(f.expand.getAttribute('role'), 'button');
assert.equal(f.expand.getAttribute('tabindex'), '0');
assert.equal(f.expand.getAttribute('aria-haspopup'), 'dialog');
assert.equal(f.expand.getAttribute('aria-expanded'), 'false');
assert.equal(f.close.getAttribute('role'), 'button');
assert.equal(f.close.getAttribute('tabindex'), '0');
assert.equal(f.close.getAttribute('aria-label'), 'Close data attribution');
});
test('Enter opens and closes attribution on initial keydown', () => {
const f = fixture();
let opened = 0;
let closed = 0;
f.expand.onclick = () => { opened++; };
f.close.onclick = () => { closed++; };
configureCreditKeyboardAccess(f.root);
const openEvent = f.expand.dispatch('keydown', { key: 'Enter' });
assert.equal(openEvent.defaultPrevented, true);
assert.equal(opened, 1);
assert.equal(f.expand.getAttribute('aria-expanded'), 'true');
assert.equal(f.close.focused, true);
const closeEvent = f.close.dispatch('keydown', { key: 'Enter' });
assert.equal(closeEvent.defaultPrevented, true);
assert.equal(closed, 1);
assert.equal(f.expand.getAttribute('aria-expanded'), 'false');
assert.equal(f.expand.focused, true);
});
test('Space opens and closes attribution only on key release', () => {
const f = fixture();
let opened = 0;
let closed = 0;
f.expand.onclick = () => { opened++; };
f.close.onclick = () => { closed++; };
configureCreditKeyboardAccess(f.root);
const openDown = f.expand.dispatch('keydown', { key: ' ' });
assert.equal(openDown.defaultPrevented, true);
assert.equal(opened, 0);
assert.equal(f.expand.getAttribute('aria-expanded'), 'false');
const openUp = f.expand.dispatch('keyup', { key: ' ' });
assert.equal(openUp.defaultPrevented, true);
assert.equal(opened, 1);
assert.equal(f.expand.getAttribute('aria-expanded'), 'true');
assert.equal(f.close.focused, true);
const closeDown = f.close.dispatch('keydown', { key: ' ' });
assert.equal(closeDown.defaultPrevented, true);
assert.equal(closed, 0);
const closeUp = f.close.dispatch('keyup', { key: ' ' });
assert.equal(closeUp.defaultPrevented, true);
assert.equal(closed, 1);
assert.equal(f.expand.getAttribute('aria-expanded'), 'false');
assert.equal(f.expand.focused, true);
});
test('held activation keys do not repeat open or close actions', () => {
const f = fixture();
let opened = 0;
f.expand.onclick = () => { opened++; };
configureCreditKeyboardAccess(f.root);
f.expand.dispatch('keydown', { key: ' ' });
const repeated = f.expand.dispatch('keydown', { key: ' ', repeat: true });
assert.equal(repeated.defaultPrevented, true);
assert.equal(opened, 0);
f.expand.dispatch('keyup', { key: ' ' });
assert.equal(opened, 1);
f.expand.dispatch('keyup', { key: ' ' });
assert.equal(opened, 1);
const g = fixture();
let enterOpened = 0;
g.expand.onclick = () => { enterOpened++; };
configureCreditKeyboardAccess(g.root);
g.expand.dispatch('keydown', { key: 'Enter' });
g.expand.dispatch('keydown', { key: 'Enter', repeat: true });
assert.equal(enterOpened, 1);
});
test('losing focus during a Space hold cancels release activation', () => {
const f = fixture();
let opened = 0;
f.expand.onclick = () => { opened++; };
configureCreditKeyboardAccess(f.root);
f.expand.dispatch('keydown', { key: ' ' });
f.expand.dispatch('blur');
const release = f.expand.dispatch('keyup', { key: ' ' });
assert.equal(release.defaultPrevented, true);
assert.equal(opened, 0);
assert.equal(f.expand.getAttribute('aria-expanded'), 'false');
});
test('Escape and backdrop dismissal close attribution and restore its disclosure', () => {
const f = fixture();
let closed = 0;
f.close.onclick = () => { closed++; };
configureCreditKeyboardAccess(f.root);
f.expand.click();
const escape = f.lightbox.dispatch('keydown', { key: 'Escape', target: f.close });
assert.equal(escape.defaultPrevented, true);
assert.equal(escape.propagationStopped, true);
assert.equal(closed, 1);
assert.equal(f.expand.getAttribute('aria-expanded'), 'false');
assert.equal(f.expand.focused, true);
f.expand.focused = false;
f.expand.click();
f.overlay.dispatch('click', { target: f.overlay });
assert.equal(f.expand.getAttribute('aria-expanded'), 'false');
assert.equal(f.expand.focused, true);
});
test('credit keyboard setup is idempotent and safely declines incomplete markup', () => {
const f = fixture();
assert.equal(configureCreditKeyboardAccess(f.root), true);
assert.equal(configureCreditKeyboardAccess(f.root), true);
assert.equal(f.expand.listenerCount('keydown'), 1);
assert.equal(f.expand.listenerCount('keyup'), 1);
assert.equal(f.expand.listenerCount('blur'), 1);
assert.equal(f.close.listenerCount('keydown'), 1);
assert.equal(f.close.listenerCount('keyup'), 1);
assert.equal(f.close.listenerCount('blur'), 1);
assert.equal(f.lightbox.listenerCount('keydown'), 1);
assert.equal(f.overlay.listenerCount('click'), 1);
assert.equal(configureCreditKeyboardAccess({ querySelector: () => null }), false);
});
+15 -3
View File
@@ -2043,16 +2043,23 @@ export class DataLayerManager {
count.textContent = layer.stats.count ? this._formatCount(layer.stats.count) : '—';
const toggle = document.createElement('button');
toggle.type = 'button';
toggle.className = `data-toggle-btn${layer.enabled ? ' active' : ''}`;
this._syncToggleButton(toggle, layer);
toggle.addEventListener('click', async () => {
toggle.disabled = true;
// Native `disabled` immediately evicts keyboard focus in Chromium. Keep
// the lifecycle control focusable while it is busy, and enforce the
// same single-flight interaction contract through ARIA instead.
if (toggle.getAttribute('aria-disabled') === 'true') return;
toggle.setAttribute('aria-disabled', 'true');
toggle.setAttribute('aria-busy', 'true');
try {
await this.setEnabled(layer.id, !this.isEnabled(layer.id), { origin: 'user' });
} catch (error) {
console.warn(`[Data] ${layer.id} toggle error:`, error);
} finally {
toggle.disabled = false;
const current = this.getAll().find(({ id }) => id === layer.id);
if (current) this._syncToggleButton(toggle, current);
}
});
@@ -2266,7 +2273,12 @@ export class DataLayerManager {
button.dataset.feedState = transitioning
? layer.lifecycleState
: (uncertain ? 'uncertain' : feedState);
button.disabled = transitioning;
// A busy toggle remains the keyboard focus owner. `aria-disabled` plus the
// click guard above prevents repeat activation without the focus loss caused
// by native `disabled`.
button.disabled = false;
button.setAttribute('aria-disabled', String(transitioning));
button.setAttribute('aria-busy', String(transitioning));
button.textContent = transitioning
? layer.lifecycleState.toUpperCase()
: (uncertain ? 'UNCERTAIN' : (layer.enabled ? FEED_STATE_LABELS[feedState] : 'OFF'));
+158
View File
@@ -2757,6 +2757,7 @@ function makeControlElement() {
focus() { if (globalThis.document) globalThis.document.activeElement = this; },
addEventListener(name, handler) { this.listeners[name] = handler; },
setAttribute(name, value) { this.attributes[name] = String(value); },
getAttribute(name) { return this.attributes[name] ?? null; },
closest(selector) {
const className = selector.slice(1);
return String(this.className).split(/\s+/).includes(className) ? this : null;
@@ -2783,6 +2784,163 @@ function makeControlElement() {
return element;
}
test('keyboard focus survives Data Layer enabling and disabling transitions', async () => {
const originalDocument = globalThis.document;
globalThis.document = { createElement: makeControlElement, activeElement: null };
const mgr = new DataLayerManager({});
let releaseEnable;
let releaseDisable;
const calls = { enable: 0, disable: 0 };
mgr.register({
id: 'focus-layer',
name: 'Focus layer',
icon: '',
source: 'Local focus fixture',
updateInterval: -1,
init() {},
enable() {
calls.enable += 1;
return new Promise((resolve) => { releaseEnable = resolve; });
},
disable() {
calls.disable += 1;
return new Promise((resolve) => { releaseDisable = resolve; });
},
update() {},
destroy() {},
getStats() { return { count: 1, lastUpdate: Date.now() }; },
});
const container = makeControlElement();
const waitFor = async (predicate, label) => {
for (let attempt = 0; attempt < 20; attempt += 1) {
if (predicate()) return;
await Promise.resolve();
}
assert.fail(`timed out waiting for ${label}`);
};
try {
mgr.buildTogglePanel(container);
const row = container.querySelector('[data-layer-id="focus-layer"]');
const button = row.querySelector('.data-toggle-btn');
button.focus();
const enablingClick = button.listeners.click();
await waitFor(() => typeof releaseEnable === 'function', 'enable transition');
assert.equal(globalThis.document.activeElement, button);
assert.equal(button.disabled, false, 'ENABLING never uses native disabled');
assert.equal(button.getAttribute('aria-disabled'), 'true');
assert.equal(button.getAttribute('aria-busy'), 'true');
assert.equal(button.textContent, 'ENABLING');
await button.listeners.click();
assert.equal(calls.enable, 1, 'repeat activation is inert while enabling');
assert.equal(mgr.layers.get('focus-layer').visibilityIntentEpoch, 1);
releaseEnable();
await enablingClick;
assert.equal(globalThis.document.activeElement, button);
assert.equal(button.disabled, false);
assert.equal(button.getAttribute('aria-disabled'), 'false');
assert.equal(button.getAttribute('aria-busy'), 'false');
assert.equal(mgr.isEnabled('focus-layer'), true);
const disablingClick = button.listeners.click();
await waitFor(() => typeof releaseDisable === 'function', 'disable transition');
assert.equal(globalThis.document.activeElement, button);
assert.equal(button.disabled, false, 'DISABLING never uses native disabled');
assert.equal(button.getAttribute('aria-disabled'), 'true');
assert.equal(button.getAttribute('aria-busy'), 'true');
assert.equal(button.textContent, 'DISABLING');
await button.listeners.click();
assert.equal(calls.disable, 1, 'repeat activation is inert while disabling');
assert.equal(mgr.layers.get('focus-layer').visibilityIntentEpoch, 2);
releaseDisable();
await disablingClick;
assert.equal(globalThis.document.activeElement, button);
assert.equal(button.disabled, false);
assert.equal(button.getAttribute('aria-disabled'), 'false');
assert.equal(button.getAttribute('aria-busy'), 'false');
assert.equal(mgr.isEnabled('focus-layer'), false);
} finally {
releaseEnable?.();
releaseDisable?.();
await mgr.destroyAll();
if (originalDocument === undefined) delete globalThis.document;
else globalThis.document = originalDocument;
}
});
test('a failed Data Layer transition clears busy state without losing keyboard focus', async () => {
const originalDocument = globalThis.document;
const originalWarn = console.warn;
globalThis.document = { createElement: makeControlElement, activeElement: null };
console.warn = () => {};
const mgr = new DataLayerManager({});
let rejectEnable;
let enableCalls = 0;
mgr.register({
id: 'focus-failure-layer',
name: 'Focus failure layer',
icon: '',
source: 'Local focus fixture',
updateInterval: -1,
init() {},
enable() {
enableCalls += 1;
if (enableCalls > 1) return true;
return new Promise((resolve, reject) => { rejectEnable = reject; });
},
disable() { return true; },
update() {},
destroy() {},
getStats() { return { count: 0, lastUpdate: null }; },
});
const container = makeControlElement();
try {
mgr.buildTogglePanel(container);
const button = container
.querySelector('[data-layer-id="focus-failure-layer"]')
.querySelector('.data-toggle-btn');
button.focus();
const failedClick = button.listeners.click();
for (let attempt = 0; attempt < 20 && typeof rejectEnable !== 'function'; attempt += 1) {
await Promise.resolve();
}
assert.equal(typeof rejectEnable, 'function');
assert.equal(globalThis.document.activeElement, button);
assert.equal(button.getAttribute('aria-disabled'), 'true');
assert.equal(button.getAttribute('aria-busy'), 'true');
rejectEnable(new Error('QA enable rejected'));
await failedClick;
assert.equal(globalThis.document.activeElement, button, 'failure settlement preserves focus');
assert.equal(button.disabled, false);
assert.equal(button.getAttribute('aria-disabled'), 'false');
assert.equal(button.getAttribute('aria-busy'), 'false');
assert.equal(button.textContent, 'OFF');
assert.deepEqual(mgr.getLayerLifecycleState('focus-failure-layer'), {
enabled: false,
lifecycleState: 'disabled',
uncertain: false,
});
await button.listeners.click();
assert.equal(enableCalls, 2, 'the settled control accepts a later retry');
assert.equal(globalThis.document.activeElement, button);
assert.equal(mgr.isEnabled('focus-failure-layer'), true);
} finally {
rejectEnable?.(new Error('test cleanup'));
console.warn = originalWarn;
await mgr.destroyAll();
if (originalDocument === undefined) delete globalThis.document;
else globalThis.document = originalDocument;
}
});
/** Collect every node in a rendered subtree carrying `className`. */
function collectByClass(node, className) {
const found = [];
+40 -1
View File
@@ -982,6 +982,43 @@ function navigationState() {
};
}
/** Stable identity for a delegated Contacts-panel button across live repaints. */
export function awarenessPanelControlKey(element) {
const control = element?.closest?.(
'button[data-awareness-action], button[data-awareness-layer][data-awareness-id]',
);
if (!control) return null;
if (control.dataset.awarenessAction) return `action:${control.dataset.awarenessAction}`;
return `target:${control.dataset.awarenessLayer}:${control.dataset.awarenessId}`;
}
/** Capture stable identity so a live repaint can restore only the same control. */
export function captureAwarenessPanelFocus(panel, activeElement = document.activeElement) {
if (!panel?.contains?.(activeElement)) return null;
if (activeElement?.matches?.('[data-awareness-focus-continuation]')) {
return { key: 'continuation' };
}
const key = awarenessPanelControlKey(activeElement);
if (!key) return null;
return { key };
}
/** Restore the same control, or continue beyond the list when it is no longer rendered. */
export function restoreAwarenessPanelFocus(panel, snapshot) {
if (!panel || !snapshot) return null;
const continuation = panel.querySelector('[data-awareness-focus-continuation]');
const controls = [...panel.querySelectorAll(
'button[data-awareness-action], button[data-awareness-layer][data-awareness-id]',
)].filter((control) => !control.disabled);
const retained = snapshot.key === 'continuation'
? continuation
: controls.find((control) => awarenessPanelControlKey(control) === snapshot.key);
const target = retained
|| continuation;
target?.focus?.({ preventScroll: true });
return target || null;
}
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>'"]/g, (char) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;' })[char]);
}
@@ -993,11 +1030,13 @@ function renderResults() {
const markup = `<div class="military-awareness-subject">${escapeHtml(subject.label)} · ${formatAwarenessDistance(AWARENESS_RADIUS_M)} FLIGHT / VESSEL WINDOW</div>
${navigationControlsHtml()}
${cohorts.map(rowHtml).join('')}
<p class="military-awareness-note">Open-source mapped/observed context. Missing broadcasts, unloaded map areas, or unmapped sites are not evidence of absence.</p>`;
<p class="military-awareness-note" tabindex="-1" data-awareness-focus-continuation>Open-source mapped/observed context. Missing broadcasts, unloaded map areas, or unmapped sites are not evidence of absence.</p>`;
panel.hidden = false;
if (state.panelMarkup !== markup) {
const focusSnapshot = captureAwarenessPanelFocus(panel);
panel.innerHTML = markup;
state.panelMarkup = markup;
restoreAwarenessPanelFocus(panel, focusSnapshot);
}
}
+93
View File
@@ -10,14 +10,17 @@ import militaryAwarenessLayer, {
awarenessRefreshIntervalMs,
awarenessRefreshRequired,
awarenessResultsAreLive,
awarenessPanelControlKey,
buildAwarenessContextSnapshot,
canNavigateAwarenessNext,
contactsWindowFromSnapshot,
contextTargetFlyToAllowed,
captureAwarenessPanelFocus,
summarizeInstallationViewport,
findCompatibleHistoryIndex,
AWARENESS_QUERY_LIMIT,
historySubjectSnapshot,
restoreAwarenessPanelFocus,
} from './militaryAwareness.js';
import flightsLayer, {
_setTrackedFlightRefreshStateForTest,
@@ -212,6 +215,96 @@ function installAwarenessRuntime({
};
}
function awarenessControl(dataset, { disabled = false } = {}) {
return {
dataset,
disabled,
focusCalls: 0,
closest(selector) {
if (selector === '[data-awareness-focus-continuation]') {
return this.isContinuation ? this : null;
}
return selector.includes('button') && !this.isContinuation ? this : null;
},
matches(selector) {
return selector === '[data-awareness-focus-continuation]' && this.isContinuation;
},
focus(options) {
this.focusCalls += 1;
this.focusOptions = options;
},
};
}
function awarenessPanel(controls) {
const continuation = awarenessControl({});
continuation.isContinuation = true;
return {
controls,
continuation,
contains(element) { return controls.includes(element) || element === continuation; },
querySelectorAll() { return this.controls; },
querySelector(selector) {
return selector === '[data-awareness-focus-continuation]' ? continuation : null;
},
};
}
test('Contacts live repaint restores only stable identity and never retargets another contact', () => {
const previous = awarenessControl({ awarenessAction: 'previous' });
const focus = awarenessControl({ awarenessAction: 'focus' });
const next = awarenessControl({ awarenessAction: 'next' });
const alpha = awarenessControl({ awarenessLayer: 'flights', awarenessId: 'abc123' });
const bravo = awarenessControl({ awarenessLayer: 'military', awarenessId: 'def456' });
const before = awarenessPanel([previous, focus, next, alpha, bravo]);
const alphaSnapshot = captureAwarenessPanelFocus(before, alpha);
assert.deepEqual(alphaSnapshot, { key: 'target:flights:abc123' });
assert.equal(awarenessPanelControlKey(next), 'action:next');
const retainedAlpha = awarenessControl({ awarenessLayer: 'flights', awarenessId: 'abc123' });
const retained = awarenessPanel([previous, focus, next, bravo, retainedAlpha]);
assert.equal(restoreAwarenessPanelFocus(retained, alphaSnapshot), retainedAlpha);
assert.equal(retainedAlpha.focusCalls, 1, 'identity wins even when live distance reorders rows');
assert.deepEqual(retainedAlpha.focusOptions, { preventScroll: true });
const forward = awarenessControl({ awarenessLayer: 'ais-live-vessels', awarenessId: '789' });
const departed = awarenessPanel([previous, focus, next, forward]);
assert.equal(restoreAwarenessPanelFocus(departed, alphaSnapshot), departed.continuation);
assert.equal(forward.focusCalls, 0, 'automatic paging must not transfer focus to another contact');
assert.equal(departed.continuation.focusCalls, 1);
const bravoSnapshot = captureAwarenessPanelFocus(before, bravo);
const noRemainingRow = awarenessPanel([previous, focus, next, alpha]);
assert.equal(
restoreAwarenessPanelFocus(noRemainingRow, bravoSnapshot),
noRemainingRow.continuation,
);
assert.equal(noRemainingRow.continuation.focusCalls, 1, 'the end sentinel continues Tab beyond the list');
const continuationSnapshot = captureAwarenessPanelFocus(departed, departed.continuation);
assert.deepEqual(continuationSnapshot, { key: 'continuation' });
const repaintedAgain = awarenessPanel([previous, focus, next, forward]);
assert.equal(
restoreAwarenessPanelFocus(repaintedAgain, continuationSnapshot),
repaintedAgain.continuation,
);
assert.equal(
repaintedAgain.continuation.focusCalls,
1,
'later live repaints keep focus on the continuation point until the user tabs onward',
);
});
test('Contacts continuation target is the visible explanatory note', () => {
const source = fs.readFileSync(new URL('./militaryAwareness.js', import.meta.url), 'utf8');
assert.match(
source,
/<p class="military-awareness-note" tabindex="-1" data-awareness-focus-continuation>Open-source mapped\/observed context\./,
);
assert.doesNotMatch(source, /<span[^>]*data-awareness-focus-continuation/);
});
test('awareness disable settles every owned dependency release before resolving', async () => {
let releasing = false;
let releaseDependencies;
+146 -24
View File
@@ -61,6 +61,7 @@ let _missionPanel = null;
let _missionRoster = null;
let _missionRosterHoverTimer = null;
let _hoveredRosterLaunchId = null;
let _missionRosterPreviewOwnership = null;
let _missionHoverReticleImage = null;
let _replayVehicleOverlay = null;
let _replayVehicleOverlayText = '';
@@ -786,6 +787,115 @@ export function missionHoverPreviewRange(cameraHeight) {
);
}
/**
* Coordinate pointer and keyboard ownership of the temporary roster preview.
* The most recent input owns the preview until it leaves, then any remaining
* input resumes ownership. This lets keyboard focus take over from a pointer
* resting on another row without losing that pointer preview on blur.
* @param {{preview: (index: number) => void, clear: () => void}} handlers
* @returns {{pointerEnter: (index: number) => void, pointerLeave: () => void,
* focus: (index: number) => void, blur: (nextIndex?: number|null) => void,
* reset: () => void}}
*/
export function createMissionRosterPreviewOwnership({ preview, clear }) {
let pointerIndex = null;
let focusIndex = null;
let latestOwner = null;
let activeIndex = null;
const sync = () => {
const nextIndex = latestOwner === 'pointer'
? pointerIndex ?? focusIndex
: focusIndex ?? pointerIndex;
if (nextIndex === activeIndex) return;
activeIndex = nextIndex;
if (Number.isInteger(nextIndex)) preview(nextIndex);
else clear();
};
return {
pointerEnter(index) {
pointerIndex = index;
latestOwner = 'pointer';
sync();
},
pointerLeave() {
pointerIndex = null;
if (latestOwner === 'pointer') latestOwner = focusIndex === null ? null : 'focus';
sync();
},
focus(index) {
focusIndex = index;
latestOwner = 'focus';
sync();
},
blur(nextIndex = null) {
focusIndex = Number.isInteger(nextIndex) ? nextIndex : null;
if (latestOwner === 'focus') latestOwner = focusIndex === null
? (pointerIndex === null ? null : 'pointer')
: 'focus';
sync();
},
reset() {
pointerIndex = null;
focusIndex = null;
latestOwner = null;
activeIndex = null;
clear();
},
};
}
/**
* Bind native keyboard focus events for one rendered roster row.
* @param {Element} button Mission roster button.
* @param {number} index Source-array mission index.
* @param {{pointerEnter: (index: number) => void, pointerLeave: () => void,
* focus: (index: number) => void, blur: (nextIndex?: number|null) => void}} ownership
* @param {Element} roster Roster root used to reject focus targets outside the list.
*/
export function bindMissionRosterItemKeyboardPreview(button, index, ownership, roster) {
button.addEventListener('mouseenter', () => ownership.pointerEnter(index));
button.addEventListener('mouseleave', () => ownership.pointerLeave());
button.addEventListener('focus', () => ownership.focus(index));
button.addEventListener('blur', (event) => {
const nextButton = event.relatedTarget?.closest?.('[data-mission-roster-index]');
const nextIndex = nextButton && roster.contains(nextButton)
? Number(nextButton.dataset.missionRosterIndex)
: null;
ownership.blur(nextIndex);
});
}
/** Capture the exact mission identity owned by keyboard focus before a refresh. */
export function captureMissionRosterFocus(list, activeElement = globalThis.document?.activeElement) {
const button = activeElement?.closest?.('[data-mission-roster-id]');
if (!button || !list?.contains(button)) return null;
const launchId = button.dataset.missionRosterId;
return launchId ? { launchId } : null;
}
/** Restore focus by mission identity, or continue after the list if it departed. */
export function restoreMissionRosterFocus(list, snapshot, continuation) {
if (!snapshot?.launchId) return 'none';
const button = Array.from(list?.querySelectorAll?.('[data-mission-roster-id]') || [])
.find((candidate) => candidate.dataset.missionRosterId === snapshot.launchId);
if (button) {
button.focus({ preventScroll: true });
return 'restored';
}
if (continuation?.focus) {
continuation.focus({ preventScroll: true });
return 'continued';
}
return 'none';
}
/** Resolve a pending preview against the current refresh, never a stale object. */
export function resolveMissionRosterPreviewLaunch(launches, launchId) {
return (launches || []).find((candidate) => candidate.id === launchId) || null;
}
/**
* Format a mission epoch for compact on-globe replay labels.
* @param {string|Date|null} launchTime ISO-8601 mission time or Date.
@@ -2327,9 +2437,16 @@ function renderMissionRoster() {
const count = _missionRoster.querySelector('[data-mission-roster-count]');
if (count) count.textContent = `${_launches.length} / 30D`;
if (!list) return;
const focusSnapshot = captureMissionRosterFocus(list);
_missionRosterPreviewOwnership?.reset();
const entries = missionRosterEntries(_launches);
if (!entries.length) {
list.innerHTML = '<div class="space-mission-roster-empty">NO MISSIONS AVAILABLE IN THE CURRENT 30-DAY WINDOW</div>';
restoreMissionRosterFocus(
list,
focusSnapshot,
_missionRoster.querySelector('[data-mission-roster-focus-continuation]'),
);
return;
}
list.innerHTML = entries.map(({ launch, index }) => {
@@ -2337,8 +2454,22 @@ function renderMissionRoster() {
const date = launch.launchTime?.slice(0, 10) || 'DATE UNAVAILABLE';
const provider = launch.provider || 'UNSPECIFIED OPERATOR';
const label = shortMissionLabel(launch.name, 27).toUpperCase();
return `<button type="button" class="space-mission-roster-item" data-mission-roster-index="${index}" aria-label="Select ${escapeMissionText(label)}"><span class="space-mission-roster-marker" style="--mission-roster-color:${color}" aria-hidden="true"></span><span class="space-mission-roster-copy"><strong>${escapeMissionText(label)}</strong><small>${escapeMissionText(provider)} · ${escapeMissionText(date)}</small></span><span class="space-mission-roster-chevron" aria-hidden="true">›</span></button>`;
return `<button type="button" class="space-mission-roster-item" data-mission-roster-index="${index}" data-mission-roster-id="${escapeMissionText(launch.id)}" aria-label="Select ${escapeMissionText(label)}"><span class="space-mission-roster-marker" style="--mission-roster-color:${color}" aria-hidden="true"></span><span class="space-mission-roster-copy"><strong>${escapeMissionText(label)}</strong><small>${escapeMissionText(provider)} · ${escapeMissionText(date)}</small></span><span class="space-mission-roster-chevron" aria-hidden="true">›</span></button>`;
}).join('');
list.querySelectorAll('[data-mission-roster-index]').forEach((button) => {
const index = Number(button.dataset.missionRosterIndex);
bindMissionRosterItemKeyboardPreview(
button,
index,
_missionRosterPreviewOwnership,
_missionRoster,
);
});
restoreMissionRosterFocus(
list,
focusSnapshot,
_missionRoster.querySelector('[data-mission-roster-focus-continuation]'),
);
}
function escapeMissionText(value) {
@@ -2384,7 +2515,7 @@ function selectMissionAt(index) {
focusMission(launch);
}
function clearMissionRosterHover() {
function clearMissionRosterPreviewState() {
if (_missionRosterHoverTimer) clearTimeout(_missionRosterHoverTimer);
_missionRosterHoverTimer = null;
const changed = _hoveredRosterLaunchId !== null;
@@ -2392,6 +2523,11 @@ function clearMissionRosterHover() {
if (changed) syncMissionOverlayEntries();
}
function clearMissionRosterHover() {
if (_missionRosterPreviewOwnership) _missionRosterPreviewOwnership.reset();
else clearMissionRosterPreviewState();
}
function previewMissionFromRoster(launch) {
if (!_viewer || !launch || _selectedLaunchId) return;
const range = missionHoverPreviewRange(_viewer.camera.positionCartographic?.height);
@@ -2414,7 +2550,9 @@ function scheduleMissionRosterPreview(index) {
syncMissionOverlayEntries();
_missionRosterHoverTimer = setTimeout(() => {
_missionRosterHoverTimer = null;
if (_hoveredRosterLaunchId === launch.id) previewMissionFromRoster(launch);
if (_hoveredRosterLaunchId !== launch.id) return;
const currentLaunch = resolveMissionRosterPreviewLaunch(_launches, launch.id);
if (currentLaunch) previewMissionFromRoster(currentLaunch);
}, 140);
}
@@ -2499,6 +2637,10 @@ function createMissionPanel() {
if (!host) return;
_missionRoster = document.getElementById('space-mission-roster');
if (_missionRoster) {
_missionRosterPreviewOwnership = createMissionRosterPreviewOwnership({
preview: scheduleMissionRosterPreview,
clear: clearMissionRosterPreviewState,
});
_missionRoster.onclick = (event) => {
const button = event.target instanceof Element
? event.target.closest('[data-mission-roster-index]')
@@ -2506,23 +2648,6 @@ function createMissionPanel() {
if (!button) return;
selectMissionAt(Number(button.dataset.missionRosterIndex));
};
_missionRoster.onmouseover = (event) => {
const button = event.target instanceof Element
? event.target.closest('[data-mission-roster-index]')
: null;
if (!button || button.contains(event.relatedTarget)) return;
scheduleMissionRosterPreview(Number(button.dataset.missionRosterIndex));
};
_missionRoster.onfocusin = (event) => {
const button = event.target instanceof Element
? event.target.closest('[data-mission-roster-index]')
: null;
if (button) scheduleMissionRosterPreview(Number(button.dataset.missionRosterIndex));
};
_missionRoster.onmouseleave = clearMissionRosterHover;
_missionRoster.onfocusout = (event) => {
if (!_missionRoster.contains(event.relatedTarget)) clearMissionRosterHover();
};
}
_missionPanel = document.createElement('aside');
_missionPanel.id = 'space-mission-panel';
@@ -3539,11 +3664,8 @@ const rocketLaunchesLayer = {
_missionPanel = null;
if (_missionRoster) {
_missionRoster.onclick = null;
_missionRoster.onmouseover = null;
_missionRoster.onfocusin = null;
_missionRoster.onmouseleave = null;
_missionRoster.onfocusout = null;
}
_missionRosterPreviewOwnership = null;
_missionRoster = null;
_viewer = null;
if (_dataSource) viewer.dataSources.remove(_dataSource, true);
+149
View File
@@ -6,9 +6,12 @@ import rocketLaunchesLayer, {
_setRocketMissionOverlayHostForTest,
_setSelectedRocketMissionForTest,
approximateOrbitPath,
bindMissionRosterItemKeyboardPreview,
captureMissionRosterFocus,
buildMissionPaths,
cameraHeadingForPath,
compactLaunchSiteName,
createMissionRosterPreviewOwnership,
createRocketMissionElementOverlayEntry,
createRocketMissionMarkerOverlayEntry,
formatMissionEventTime,
@@ -41,6 +44,8 @@ import rocketLaunchesLayer, {
replayAscentDurationSeconds,
replayStartAfterPause,
replayVehicleScreenRotation,
resolveMissionRosterPreviewLaunch,
restoreMissionRosterFocus,
releaseAircraftTracking,
ROCKET_MISSION_AMBIENT_OVERLAY_COHORT_LIMIT,
ROCKET_MISSION_AMBIENT_OVERLAY_COLLISION_CAPACITY,
@@ -490,6 +495,150 @@ test('preserves globe scale for roster hover previews', () => {
assert.equal(missionHoverPreviewRange(Number.NaN), 5000000);
});
test('keyboard focus previews roster missions and clears without selecting them', () => {
const calls = [];
const ownership = createMissionRosterPreviewOwnership({
preview: (index) => calls.push(['preview', index]),
clear: () => calls.push(['clear']),
});
ownership.focus(2);
ownership.blur(3);
ownership.blur();
assert.deepEqual(calls, [
['preview', 2],
['preview', 3],
['clear'],
]);
});
test('rendered mission row focus and blur drive the preview ownership controller', () => {
const listeners = new Map();
const calls = [];
const button = {
addEventListener(type, listener) {
listeners.set(type, listener);
},
};
const nextButton = {
dataset: { missionRosterIndex: '4' },
closest: () => nextButton,
};
const roster = { contains: (candidate) => candidate === nextButton };
const ownership = {
pointerEnter: (index) => calls.push(['pointer-enter', index]),
pointerLeave: () => calls.push(['pointer-leave']),
focus: (index) => calls.push(['focus', index]),
blur: (index) => calls.push(['blur', index]),
};
bindMissionRosterItemKeyboardPreview(button, 2, ownership, roster);
listeners.get('mouseenter')();
listeners.get('mouseleave')();
listeners.get('focus')();
listeners.get('blur')({ relatedTarget: nextButton });
listeners.get('blur')({ relatedTarget: null });
assert.deepEqual(calls, [
['pointer-enter', 2],
['pointer-leave'],
['focus', 2],
['blur', 4],
['blur', null],
]);
});
test('leaving a hovered row for an internal roster gap restores keyboard preview', () => {
const calls = [];
const ownership = createMissionRosterPreviewOwnership({
preview: (index) => calls.push(['preview', index]),
clear: () => calls.push(['clear']),
});
ownership.focus(1);
ownership.pointerEnter(0);
ownership.pointerLeave();
assert.deepEqual(calls, [
['preview', 1],
['preview', 0],
['preview', 1],
]);
});
test('mission roster refresh restores keyboard focus by stable mission identity after reorder', () => {
const focused = {
dataset: { missionRosterId: 'mission-b' },
closest: () => focused,
};
let restored = false;
const replacement = {
dataset: { missionRosterId: 'mission-b' },
focus: ({ preventScroll }) => { restored = preventScroll; },
};
const list = {
contains: (candidate) => candidate === focused,
querySelectorAll: () => [
{ dataset: { missionRosterId: 'mission-c' } },
replacement,
{ dataset: { missionRosterId: 'mission-a' } },
],
};
const snapshot = captureMissionRosterFocus(list, focused);
assert.deepEqual(snapshot, { launchId: 'mission-b' });
assert.equal(restoreMissionRosterFocus(list, snapshot, null), 'restored');
assert.equal(restored, true);
});
test('mission roster refresh continues after the list when the focused mission departed', () => {
const focused = {
dataset: { missionRosterId: 'departed' },
closest: () => focused,
};
let continuationFocused = false;
const list = {
contains: (candidate) => candidate === focused,
querySelectorAll: () => [{ dataset: { missionRosterId: 'remaining' } }],
};
const continuation = {
focus: ({ preventScroll }) => { continuationFocused = preventScroll; },
};
const snapshot = captureMissionRosterFocus(list, focused);
assert.equal(restoreMissionRosterFocus(list, snapshot, continuation), 'continued');
assert.equal(continuationFocused, true);
});
test('pending mission preview resolves the current refreshed record by identity', () => {
const stale = { id: 'mission-a', name: 'Stale' };
const current = { id: 'mission-a', name: 'Current' };
assert.equal(resolveMissionRosterPreviewLaunch([current], stale.id), current);
assert.equal(resolveMissionRosterPreviewLaunch([], stale.id), null);
});
test('latest roster input owns preview and restores the remaining owner on leave', () => {
const calls = [];
const ownership = createMissionRosterPreviewOwnership({
preview: (index) => calls.push(['preview', index]),
clear: () => calls.push(['clear']),
});
ownership.pointerEnter(0);
ownership.focus(1);
ownership.blur();
ownership.pointerLeave();
assert.deepEqual(calls, [
['preview', 0],
['preview', 1],
['preview', 0],
['clear'],
]);
});
test('assigns distinct stable colors to mission operators', () => {
assert.equal(missionMarkerColor({ provider: 'NASA', name: 'Science Flight' }).toCssColorString(), 'rgb(255,159,67)');
assert.equal(missionMarkerColor({ provider: 'SpaceX', name: 'Starlink Group' }).toCssColorString(), 'rgb(76,201,240)');
+86
View File
@@ -0,0 +1,86 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
const css = readFileSync(new URL('../style.css', import.meta.url), 'utf8');
function ruleBody(selector) {
const start = css.indexOf(selector);
assert.ok(start >= 0, `${selector} rule exists`);
const open = css.indexOf('{', start);
const close = css.indexOf('}', open);
return css.slice(open + 1, close);
}
function ruleText(selector) {
const start = css.indexOf(selector);
assert.ok(start >= 0, `${selector} rule exists`);
const close = css.indexOf('}', css.indexOf('{', start));
return css.slice(start, close + 1);
}
function ruleBodyContaining(selector, declaration) {
let start = -1;
while ((start = css.indexOf(selector, start + 1)) >= 0) {
const open = css.indexOf('{', start);
const close = css.indexOf('}', open);
const body = css.slice(open + 1, close);
if (body.includes(declaration)) return body;
}
assert.fail(`${selector} rule containing ${declaration} exists`);
}
test('the global keyboard ring survives local active and outline-reset rules', () => {
const rule = ruleText(':where(\n button,');
assert.match(rule, /outline:\s*2px solid var\(--text-primary\) !important;/);
for (const selector of [
"[role='button']",
"[role='radio']",
"[role='slider']",
"[role='tab']",
'[tabindex]',
'a[href]',
]) assert.ok(rule.includes(selector), `${selector} receives the global ring`);
});
test('Location city, POI, search toggle and search field use an inset ring', () => {
const rule = ruleText('.location-pill:focus-visible,');
assert.match(rule, /\.poi-pill:focus-visible/);
assert.match(rule, /\.search-toggle-btn:focus-visible/);
assert.match(rule, /#location-search:focus-visible/);
assert.match(rule, /outline:\s*2px solid var\(--text-primary\)/);
assert.match(rule, /outline-offset:\s*-3px/);
for (const selector of ['.location-pill {', '.poi-pill {', '.search-toggle-btn {']) {
assert.doesNotMatch(ruleBody(selector), /transition:\s*all\b/);
}
});
test('Cockpit Display and Radio launcher glyphs have a complete inset ring', () => {
const body = ruleBody('.cockpit-utility-glyph:focus-visible');
assert.match(body, /outline:\s*2px solid var\(--text-primary\)/);
assert.match(body, /outline-offset:\s*-3px/);
assert.doesNotMatch(body, /outline:\s*none/);
});
test('focusable controls do not animate the global outline', () => {
for (const selector of [
'.panel-collapse-btn {',
'#top-center-actions button {',
'.data-toggle-chip {',
'.scene-btn {',
'.scene-shot-btn {',
]) {
assert.doesNotMatch(
ruleBody(selector),
/transition:\s*all\b/,
`${selector} must leave the focus outline immediate`,
);
}
});
test('opening a dock popover makes its controls keyboard-reachable immediately', () => {
const base = ruleBodyContaining('#command-dock .dock-popover-content {', 'visibility: hidden');
assert.match(base, /visibility\s+0s\s+linear\s+180ms/);
const open = ruleText('#command-dock #location-bar:not(.collapsed) .dock-popover-content,');
assert.match(open, /transition-delay:\s*0s/);
});
+2
View File
@@ -17,6 +17,7 @@ import militaryAwarenessLayer from './data/militaryAwareness.js';
import localDataLayers from './data/localLayers.js';
import { LAYER_STATE_REGISTRY } from './data/layerState.js';
import { registerDataCredits } from './data/dataCredits.js';
import { configureCreditKeyboardAccess } from './creditKeyboard.js';
import { SceneDirector } from './scenes/director.js';
import { initGevVoiceCommands } from './voice/gevRealtime.js';
import { MapStackController } from './mapStackController.js';
@@ -131,6 +132,7 @@ async function init() {
// expandable bottom-left credit lightbox (showOnScreen=false), so they never
// clutter the on-globe line. See docs/pre-ship-audit-2026-07-01.md H11.
registerDataCredits(viewer);
configureCreditKeyboardAccess(document);
// Hide Cesium's default globe — Google Photorealistic 3D Tiles provide their own
// globe at all LODs (street level → orbital). The default globe's 2D imagery
+153 -5
View File
@@ -4,13 +4,21 @@ import { readFileSync } from 'node:fs';
// Exercise the installed event routes and central close method, without WebGL.
const source = readFileSync(new URL('./ui.js', import.meta.url), 'utf8');
const markup = readFileSync(new URL('../index.html', import.meta.url), 'utf8');
const locationMarkup = markup.slice(markup.indexOf('<div id="location-bar"'), markup.indexOf('<div id="left-panel-stack"'));
const locationToggleMarkup = locationMarkup.match(/<button\b([^>]*\bid="location-bar-toggle"[^>]*)>([\s\S]*?)<\/button>/);
const locationToggleAttributes = Object.fromEntries(
[...(locationToggleMarkup?.[1] || '').matchAll(/([\w-]+)="([^"]*)"/g)].map((match) => [match[1], match[2]]),
);
const initStart = source.indexOf(' _initAutoHoverPanel(');
const initEnd = source.indexOf(' /**\n * Sets up drag-to-reposition', initStart);
const escapeStart = source.indexOf(' _collapsePanelOnEscape(');
const escapeEnd = source.indexOf(' _initCommandDockPins(', escapeStart);
const closeStart = source.indexOf(' setPanelCollapsed(panelId, collapsed, {');
const closeEnd = source.indexOf(' /**\n * Toggles "clean view"', closeStart);
const syncStart = source.indexOf(' _syncPanelCollapseButton(panelEl) {');
const syncEnd = source.indexOf(' /**\n * Converts a panel', syncStart);
assert.ok(initStart >= 0 && initEnd > initStart
assert.ok(initStart >= 0 && initEnd > initStart && escapeStart >= 0 && escapeEnd > escapeStart
&& closeStart >= 0 && closeEnd > closeStart);
assert.ok(syncStart >= 0 && syncEnd > syncStart);
@@ -46,13 +54,16 @@ function harness({ hidden = false, selected = true, noChips = false } = {}) {
location.classes.add('collapsed');
const disclosure = makeNode('control-panel-toggle');
disclosure.isDisclosure = true;
const locationDisclosure = locationToggleMarkup ? makeNode('location-bar-toggle', { ...locationToggleAttributes }) : null;
if (locationDisclosure) locationDisclosure.isDisclosure = true;
const locationSearch = makeNode('location-search');
const first = makeNode('photoreal');
const active = makeNode('osm');
const elsewhere = makeNode('elsewhere');
const nodes = [panel, location, disclosure, first, active, elsewhere].filter(Boolean);
const nodes = [panel, location, disclosure, locationDisclosure, locationSearch, first, active, elsewhere].filter(Boolean);
document.getElementById = (id) => nodes.find((node) => node.id === id);
panel.contains = (node) => [panel, disclosure, first, active].includes(node);
location.contains = (node) => Boolean(node) && [location].includes(node);
location.contains = (node) => Boolean(node) && [location, locationDisclosure, locationSearch].includes(node);
panel.querySelector = (selector) => {
if (selector.startsWith('[data-dock-toggle-target')) return disclosure;
if (selector.split(',').some((part) => part.trim() === '.panel-title')) return { textContent: 'VISUAL STYLES' };
@@ -61,6 +72,9 @@ function harness({ hidden = false, selected = true, noChips = false } = {}) {
return selector === '.map-stack-chip' ? first : null;
};
location.querySelector = (selector) => {
if (selector === '[data-dock-toggle-target="location-bar"]') {
return locationDisclosure?.getAttribute('data-dock-toggle-target') === 'location-bar' ? locationDisclosure : null;
}
if (selector.split(',').some((part) => part.trim() === '.location-toolbar-label')) return { textContent: 'LOCATION' };
return null;
};
@@ -86,6 +100,7 @@ function harness({ hidden = false, selected = true, noChips = false } = {}) {
}
};
disclosure.focus = () => focus(disclosure);
if (locationDisclosure) locationDisclosure.focus = () => focus(locationDisclosure);
for (const chip of [first, active]) {
chip.focus = () => { focusCalls += 1; if (!hidden) focus(chip); };
}
@@ -93,7 +108,7 @@ function harness({ hidden = false, selected = true, noChips = false } = {}) {
setTimeout(callback, delay) { timers.set(++nextTimer, { callback, at: now + delay }); return nextTimer; },
};
const methods = new Function('document', 'window', 'clearTimeout', 'performance', 'requestAnimationFrame',
`return ({${source.slice(initStart, initEnd)},\n${source.slice(closeStart, closeEnd)},\n${source.slice(syncStart, syncEnd)}});`)(
`return ({${source.slice(initStart, initEnd)},\n${source.slice(escapeStart, escapeEnd)},\n${source.slice(closeStart, closeEnd)},\n${source.slice(syncStart, syncEnd)}});`)(
document, window, (id) => timers.delete(id), { now: () => now }, () => {},
);
const saves = [];
@@ -117,14 +132,28 @@ function harness({ hidden = false, selected = true, noChips = false } = {}) {
timers.delete(next[0]); now = next[1].at; next[1].callback();
};
return {
manager, panel, location, disclosure,
manager, panel, location, disclosure, locationDisclosure, locationSearch,
first, active, elsewhere, document, timers, emit, focus, tick, saves, claims,
calls: () => focusCalls,
shareSyncs: () => shareSyncs,
show() { hidden = false; },
select(value) { selected = value; },
key(key = 'Enter', repeat = false) { return emit(disclosure, 'keydown', { key, repeat }); },
keyUp(key = ' ', repeat = false) {
const event = emit(disclosure, 'keyup', { key, repeat });
if (key === ' ' && !repeat && document.activeElement === disclosure && !event.defaultPrevented) emit(disclosure, 'click', { detail: 0 });
return event;
},
escape() { emit(panel, 'keydown', { key: 'Escape' }); },
locationKey(key = 'Enter', repeat = false) { return emit(locationDisclosure, 'keydown', { key, repeat }); },
locationKeyUp(key = ' ', repeat = false) {
const event = emit(locationDisclosure, 'keyup', { key, repeat });
if (key === ' ' && !repeat && document.activeElement === locationDisclosure && !event.defaultPrevented) {
emit(locationDisclosure, 'click', { detail: 0 });
}
return event;
},
locationEscape() { return emit(location, 'keydown', { key: 'Escape', target: document.activeElement }); },
click(detail = 1) { emit(disclosure, 'click', { detail }); },
drain() { for (let i = 0; i < 40 && timers.size; i += 1) tick(); },
};
@@ -136,6 +165,17 @@ test('Enter focuses selected OSM rather than the first chip', () => {
assert.equal(h.panel.classList.contains('collapsed'), false);
assert.equal(h.timers.size, 0);
});
test('Space waits for release before its synthesized click opens the tray', () => {
const h = harness();
const down = h.key(' ');
assert.equal(down.defaultPrevented, false);
assert.equal(down.propagationStopped, false);
assert.equal(h.panel.classList.contains('collapsed'), true);
assert.equal(h.timers.size, 0);
h.keyUp(' '); h.tick();
assert.equal(h.panel.classList.contains('collapsed'), false);
assert.equal(h.document.activeElement, h.active);
});
test('repeated keydown does not close an opening tray or replace its timer', () => {
const h = harness(); h.key(); const timer = [...h.timers.keys()][0]; h.key('Enter', true);
assert.equal([...h.timers.keys()][0], timer);
@@ -230,6 +270,114 @@ test('pointer leave preserves established keyboard focus and the open tray', ()
});
test('Location markup provides one named native disclosure linked to its popover', () => {
assert.ok(locationToggleMarkup, 'Location must expose a native button in the real markup');
assert.equal(locationToggleAttributes.type, 'button');
assert.equal(locationToggleAttributes['data-dock-toggle-target'], 'location-bar');
assert.equal(locationToggleAttributes['aria-controls'], 'location-bar-popover');
assert.equal(locationToggleAttributes['aria-expanded'], 'false');
assert.match(locationToggleAttributes['aria-label'], /expand.*location/i);
assert.match(locationToggleAttributes.class, /(?:^|\s)dock-tray-toggle(?:\s|$)/);
assert.match(locationToggleMarkup[2], /class="location-toolbar-label"/);
assert.doesNotMatch(locationToggleMarkup[2], /<button\b/);
assert.match(locationMarkup.slice(locationToggleMarkup.index + locationToggleMarkup[0].length),
/^\s*<button class="panel-collapse-btn" data-collapse-target="location-bar"/);
assert.match(locationMarkup, /<div id="location-bar-popover" class="dock-popover-content">/);
});
test('Location Enter stays immediate while Space waits for native key release', () => {
for (const key of ['Enter', ' ']) {
const h = harness(); h.focus(h.locationDisclosure); h.drain();
const event = h.locationKey(key);
assert.equal(event.defaultPrevented, key === 'Enter');
assert.equal(event.propagationStopped, key === 'Enter');
if (key === ' ') {
assert.equal(h.location.classList.contains('collapsed'), true);
h.locationKeyUp(key);
}
assert.equal(h.location.classList.contains('collapsed'), false);
assert.equal(h.locationDisclosure.getAttribute('aria-expanded'), 'true');
assert.equal(h.locationDisclosure.getAttribute('aria-label'), 'Collapse LOCATION');
assert.equal(h.document.activeElement, h.locationDisclosure);
assert.equal(h.timers.size, 0);
assert.equal(h.calls(), 0);
const repeated = h.locationKey(key, true);
assert.equal(repeated.defaultPrevented, key === 'Enter');
assert.equal(repeated.propagationStopped, key === 'Enter');
assert.equal(h.location.classList.contains('collapsed'), false);
assert.equal(h.timers.size, 0);
const release = key === ' ' ? h.locationKeyUp(key, true) : h.emit(h.locationDisclosure, 'keyup', { key });
assert.equal(release.defaultPrevented, false, 'global voice release must remain reachable');
assert.equal(release.propagationStopped, false);
h.locationKey(key);
if (key === ' ') h.locationKeyUp(key);
assert.equal(h.location.classList.contains('collapsed'), true);
assert.equal(h.locationDisclosure.getAttribute('aria-label'), 'Expand LOCATION');
}
});
test('Location synthesized click and Escape retain focus and its accessible name', () => {
const h = harness(); h.focus(h.locationDisclosure); h.drain();
h.emit(h.locationDisclosure, 'click', { detail: 0 });
assert.equal(h.location.classList.contains('collapsed'), false);
assert.equal(h.calls(), 0);
assert.equal(h.timers.size, 0);
h.focus(h.locationSearch);
const escape = h.locationEscape();
assert.equal(escape.defaultPrevented, true);
assert.equal(h.location.classList.contains('collapsed'), true);
assert.equal(h.locationDisclosure.getAttribute('aria-expanded'), 'false');
assert.equal(h.locationDisclosure.getAttribute('aria-label'), 'Expand LOCATION');
assert.equal(h.locationDisclosure.title, 'Expand LOCATION');
assert.equal(h.document.activeElement, h.locationDisclosure);
h.drain();
assert.equal(h.calls(), 0);
});
test('Location keyboard opening cancels the unpinned sibling and cannot revive its old callback', () => {
const h = harness({ hidden: true }); h.key(); h.tick();
const stale = [...h.timers.values()][0].callback;
h.focus(h.locationDisclosure); h.locationKey();
assert.equal(h.panel.classList.contains('collapsed'), true);
assert.equal(h.location.classList.contains('collapsed'), false);
const attemptsBefore = h.calls();
h.show(); stale(); h.drain();
assert.equal(h.calls(), attemptsBefore);
assert.equal(h.document.activeElement, h.locationDisclosure);
});
test('Location keyboard opening preserves a pinned sibling and a pinned Location survives sibling opening', () => {
const h = harness(); h.panel.classes.add('dock-pinned'); h.key(); h.tick();
h.focus(h.locationDisclosure); h.locationKey(); h.drain();
assert.equal(h.panel.classList.contains('collapsed'), false);
assert.equal(h.location.classList.contains('collapsed'), false);
assert.equal(h.document.activeElement, h.locationDisclosure);
h.location.classes.add('dock-pinned');
h.manager.setPanelCollapsed('control-panel', true);
h.focus(h.disclosure); h.key(); h.tick();
assert.equal(h.location.classList.contains('collapsed'), false);
assert.equal(h.locationDisclosure.getAttribute('aria-expanded'), 'true');
assert.equal(h.document.activeElement, h.active);
});
test('Location restoration syncs its real name without claiming focus, collapsing its sibling or persisting', () => {
const h = harness(); h.key(); h.tick();
const saves = h.saves.length;
const claims = h.claims.length;
const shareSyncs = h.shareSyncs();
h.manager.setPanelCollapsed('location-bar', false, { restore: true, persist: false, syncShare: false });
assert.equal(h.panel.classList.contains('collapsed'), false);
assert.equal(h.location.classList.contains('collapsed'), false);
assert.equal(h.locationDisclosure.getAttribute('aria-label'), 'Collapse LOCATION');
assert.equal(h.document.activeElement, h.active);
assert.equal(h.saves.length, saves);
assert.equal(h.claims.length, claims);
assert.equal(h.shareSyncs(), shareSyncs);
h.locationDisclosure.setAttribute('aria-label', 'stale');
h.manager.setPanelCollapsed('location-bar', false, { restore: true, persist: false, syncShare: false });
assert.equal(h.locationDisclosure.getAttribute('aria-label'), 'Collapse LOCATION', 'same-state synchronization keeps the Location name');
assert.equal(h.timers.size, 0);
});
test('disposed UI cannot complete a pending handoff', () => {
const h = harness({ hidden: true }); h.key(); h.tick();
+169
View File
@@ -0,0 +1,169 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import test from 'node:test';
const source = fs.readFileSync(new URL('./ui.js', import.meta.url), 'utf8');
function method(name, nextName) {
const start = source.indexOf(` ${name}(`);
const end = source.indexOf(`\n ${nextName}(`, start);
assert.ok(start >= 0 && end > start, `${name} source is available`);
return source.slice(start, end);
}
function panelFixture({ id = 'data-panel', nested = false, nestedCollapsed = false, onDisclosure = false } = {}) {
let collapsed = false;
let focused = false;
let blurred = false;
const disclosure = {
focus: () => { focused = true; },
blur: () => { blurred = true; focused = false; },
contains: (candidate) => candidate === disclosure,
};
const panel = {
id,
classList: { contains: (name) => name === 'collapsed' && collapsed },
contains: (target) => target?.panel === panel,
querySelector: () => disclosure,
};
const inner = nested ? { id: 'param-slider-panel' } : panel;
const target = onDisclosure ? disclosure : {
panel,
closest: (selector) => (
nested && nestedCollapsed && selector.includes(':not(.collapsed)') ? panel : inner
),
};
if (onDisclosure) {
disclosure.panel = panel;
disclosure.closest = () => panel;
}
const document = { getElementById: (candidate) => candidate === id ? panel : null };
const manager = new Function('document', `return {${method('_collapsePanelOnEscape', '_initCommandDockPins')}};`)(document);
manager.setPanelCollapsed = (candidate, value, options) => {
assert.equal(candidate, id);
assert.equal(value, true);
assert.deepEqual(options, { explicit: true });
collapsed = true;
};
const event = {
key: 'Escape', target, defaultPrevented: false, propagationStopped: false,
preventDefault() { this.defaultPrevented = true; },
stopPropagation() { this.propagationStopped = true; },
};
return {
manager, panel, inner, target, event,
collapsed: () => collapsed,
focused: () => focused,
blurred: () => blurred,
};
}
test('Escape collapses an expanded panel and returns focus to its disclosure', () => {
const fixture = panelFixture();
assert.equal(fixture.manager._collapsePanelOnEscape(fixture.event, 'data-panel'), true);
assert.equal(fixture.collapsed(), true);
assert.equal(fixture.focused(), true);
assert.equal(fixture.event.defaultPrevented, true);
assert.equal(fixture.event.propagationStopped, true);
});
test('Escape on an expanded panel disclosure closes it without retaining focus', () => {
const fixture = panelFixture({ onDisclosure: true });
assert.equal(fixture.manager._collapsePanelOnEscape(fixture.event, 'data-panel'), true);
assert.equal(fixture.collapsed(), true);
assert.equal(fixture.focused(), false);
assert.equal(fixture.blurred(), true);
assert.equal(fixture.event.defaultPrevented, true);
assert.equal(fixture.event.propagationStopped, true);
});
test('non-Escape, handled, collapsed, and outside events do not collapse a panel', () => {
for (const mutate of [
(fixture) => { fixture.event.key = 'Enter'; },
(fixture) => { fixture.event.defaultPrevented = true; },
(fixture) => { fixture.manager.setPanelCollapsed('data-panel', true, { explicit: true }); },
(fixture) => { fixture.event.target = { panel: null, closest: () => null }; },
]) {
const fixture = panelFixture();
mutate(fixture);
const wasCollapsed = fixture.collapsed();
assert.equal(fixture.manager._collapsePanelOnEscape(fixture.event, 'data-panel'), false);
assert.equal(fixture.collapsed(), wasCollapsed);
assert.equal(fixture.focused(), false);
}
});
test('an expanded nested panel owns Escape before its expanded parent', () => {
const inner = panelFixture({ id: 'param-slider-panel' });
assert.equal(inner.manager._collapsePanelOnEscape(inner.event, 'param-slider-panel'), true);
const outer = panelFixture({ id: 'pp-toggles', nested: true });
assert.equal(outer.manager._collapsePanelOnEscape(outer.event, 'pp-toggles'), false);
assert.equal(outer.collapsed(), false);
assert.equal(outer.focused(), false);
});
test('a collapsed nested panel does not block the next Escape from closing its parent', () => {
const outer = panelFixture({ id: 'pp-toggles', nested: true, nestedCollapsed: true });
assert.equal(outer.manager._collapsePanelOnEscape(outer.event, 'pp-toggles'), true);
assert.equal(outer.collapsed(), true);
assert.equal(outer.focused(), true);
});
test('Location Escape clears a hidden draft search before restoring disclosure focus', () => {
const fixture = panelFixture({ id: 'location-bar' });
const removed = [];
let blurred = false;
fixture.manager._locationSearch = {
classList: { remove: (...names) => removed.push(...names) },
value: 'focus cleanup',
blur: () => { blurred = true; },
};
assert.equal(fixture.manager._collapsePanelOnEscape(fixture.event, 'location-bar'), true);
assert.deepEqual(removed, ['expanded']);
assert.equal(fixture.manager._locationSearch.value, '');
assert.equal(blurred, true);
assert.equal(fixture.focused(), true);
});
test('panel chrome wires Escape for every declared collapse target', () => {
const init = method('_initPanelChrome', '_collapsePanelOnEscape');
assert.match(init, /for \(const targetId of targets\)[\s\S]*?addEventListener\('keydown'[\s\S]*?_collapsePanelOnEscape\(event, targetId\)/);
assert.match(source, /_initAutoHoverPanel[\s\S]*?if \(event\.key !== 'Escape'\) return;[\s\S]*?_collapsePanelOnEscape\(event, panelId\)[\s\S]*?clearOpen\(\);[\s\S]*?clearClose\(\);/);
});
test('Cockpit Escape collapses Contact or Live Signals before exiting Cockpit', () => {
const onKeyDown = method('onKeyDown', 'enter');
assert.match(
onKeyDown,
/event\.target\?\.closest\?\.\('\.cesium-credit-lightbox'\)[\s\S]*?return;/,
'the focused attribution lightbox keeps ownership of Escape before Cockpit',
);
assert.match(
onKeyDown,
/this\.context\?\.contains\(event\.target\)[\s\S]*?setContextCollapsed\(true\)[\s\S]*?event\.target === this\.contextToggle[\s\S]*?contextToggle\?\.blur[\s\S]*?contextToggle\?\.focus/,
);
assert.match(
onKeyDown,
/this\.signalStream\?\.contains\(event\.target\)[\s\S]*?setSignalCollapsed\(true, \{ user: true \}\)[\s\S]*?event\.target === this\.signalToggle[\s\S]*?signalToggle\?\.blur[\s\S]*?signalToggle\?\.focus/,
);
assert.ok(onKeyDown.indexOf('setContextCollapsed(true)') < onKeyDown.indexOf('this.exit()'));
assert.ok(onKeyDown.indexOf('setSignalCollapsed(true') < onKeyDown.indexOf('this.exit()'));
});
test('Cockpit utility Escape leaves an expanded nested Parameters panel to the shared handler', () => {
assert.match(
source,
/const nestedPanel = event\.target\?\.closest\?\.\('\.panel-collapsible:not\(\.collapsed\), #param-slider-panel:not\(\.collapsed\)'\);[\s\S]*?if \(nestedPanel\) return;[\s\S]*?setCockpitDisclosure/,
);
assert.match(
source,
/const kind = displayOpen \? 'display' : 'radio';[\s\S]*?escapedFromDisclosure[\s\S]*?returnFocus: !escapedFromDisclosure[\s\S]*?disclosure\?\.blur/,
'Cockpit utility disclosures clear their own focus when Escape closes them',
);
assert.match(
source,
/_contextRadioDock\?\.classList\.contains\('disclosure-open'\)[\s\S]*?escapedFromDisclosure[\s\S]*?setRadioDisclosure\(false, \{ returnFocus: !escapedFromDisclosure \}\)[\s\S]*?_contextRadioToggleBtn\?\.blur/,
'compact Radio disclosure clears its own focus when Escape closes it',
);
});
+201 -58
View File
@@ -1016,8 +1016,34 @@ class CockpitViewController {
onKeyDown(event) {
if (event.repeat || event.isComposing) return;
if (event.key === 'Escape' && this.active) {
// The credit lightbox owns Escape while its Close control or links hold
// focus. Its target handler closes the overlay and restores attribution
// focus; Cockpit must stay active behind it.
if (event.target?.closest?.('.cesium-credit-lightbox')) return;
if (document.getElementById('context-radio-dock')?.classList.contains('disclosure-open')) return;
if (document.querySelector('#cockpit-utility-controls [aria-expanded="true"]')) return;
if (this.context?.contains(event.target) && !this.contextCollapsed) {
event.preventDefault();
event.stopImmediatePropagation();
this.setContextCollapsed(true);
if (event.target === this.contextToggle || this.contextToggle?.contains?.(event.target)) {
this.contextToggle?.blur?.();
} else {
this.contextToggle?.focus({ preventScroll: true });
}
return;
}
if (this.signalStream?.contains(event.target) && !this.signalCollapsed) {
event.preventDefault();
event.stopImmediatePropagation();
this.setSignalCollapsed(true, { user: true });
if (event.target === this.signalToggle || this.signalToggle?.contains?.(event.target)) {
this.signalToggle?.blur?.();
} else {
this.signalToggle?.focus({ preventScroll: true });
}
return;
}
event.preventDefault();
event.stopImmediatePropagation();
this.exit();
@@ -1851,40 +1877,89 @@ class CockpitViewController {
renderCockpitSignals() {
if (!this.signalList) return;
this.signalList.replaceChildren(...this.signalItems.map((item) => {
const entry = document.createElement('li');
entry.className = item.tone;
const time = document.createElement('time');
time.textContent = new Date(item.timestamp).toISOString().slice(11, 19) + 'Z';
const body = document.createElement('div');
const heading = item.target ? document.createElement('button') : document.createElement('strong');
const existing = [...this.signalList.children];
const focusedEntry = existing.find((entry) => entry.contains(document.activeElement));
const entriesByKey = new Map();
for (const entry of existing) {
const key = entry.dataset.signalKey;
if (!entriesByKey.has(key)) entriesByKey.set(key, []);
entriesByKey.get(key).push(entry);
}
const setText = (element, value) => {
if (element.textContent !== value) element.textContent = value;
};
const entries = this.signalItems.map((item) => {
// Identity includes the action target: a reused status key must never
// silently turn a focused flight button into a different selection.
const key = JSON.stringify([
item.key, Boolean(item.target), item.target?.layerId || '', String(item.target?.id ?? ''),
]);
let entry = entriesByKey.get(key)?.shift();
if (!entry) {
entry = document.createElement('li');
entry.dataset.signalKey = key;
const time = document.createElement('time');
const body = document.createElement('div');
const heading = document.createElement(item.target ? 'button' : 'strong');
if (item.target) {
heading.type = 'button';
heading.className = 'cockpit-signal-target';
const label = document.createElement('span');
label.className = 'cockpit-signal-target-label';
const rule = document.createElement('span');
rule.className = 'cockpit-signal-target-rule';
rule.setAttribute('aria-hidden', 'true');
const chevron = document.createElement('span');
chevron.className = 'material-symbols-outlined cockpit-signal-target-chevron';
chevron.setAttribute('aria-hidden', 'true');
chevron.textContent = 'chevron_right';
heading.append(label, rule, chevron);
}
body.append(heading, document.createElement('span'));
entry.append(time, body);
}
const [time, body] = entry.children;
const [heading, copy] = body.children;
const className = item.target ? `${item.tone} actionable` : item.tone;
if (entry.className !== className) entry.className = className;
setText(time, new Date(item.timestamp).toISOString().slice(11, 19) + 'Z');
if (item.target) {
heading.type = 'button';
heading.className = 'cockpit-signal-target';
heading.dataset.signalLayer = item.target.layerId;
heading.dataset.signalId = item.target.id;
heading.setAttribute('aria-label', `Select flight ${item.title}`);
const label = document.createElement('span');
label.className = 'cockpit-signal-target-label';
label.textContent = item.title;
const rule = document.createElement('span');
rule.className = 'cockpit-signal-target-rule';
rule.setAttribute('aria-hidden', 'true');
const chevron = document.createElement('span');
chevron.className = 'material-symbols-outlined cockpit-signal-target-chevron';
chevron.setAttribute('aria-hidden', 'true');
chevron.textContent = 'chevron_right';
heading.append(label, rule, chevron);
entry.classList.add('actionable');
const label = `Select flight ${item.title}`;
if (heading.getAttribute('aria-label') !== label) heading.setAttribute('aria-label', label);
setText(heading.children[0], item.title);
} else {
heading.textContent = item.title;
setText(heading, item.title);
}
const copy = document.createElement('span');
copy.textContent = item.detail;
body.append(heading, copy);
entry.append(time, body);
setText(copy, item.detail);
return entry;
}));
});
const retainedFocus = entries.includes(focusedEntry) ? focusedEntry : null;
if (focusedEntry && !retainedFocus) {
// A departed contact cannot remain selectable. Continue from the stable
// briefing footer instead of dropping keyboard traversal to the page top.
(this.briefTabs?.[this.briefPageIndex] || this.signalToggle)?.focus({ preventScroll: true });
}
for (const entry of existing) if (!entries.includes(entry)) entry.remove();
// Ordinary insertBefore moves disconnect an element and lose its focus.
// Reorder the other rows around the focused row, which stays connected.
const focusIndex = retainedFocus ? entries.indexOf(retainedFocus) : -1;
if (retainedFocus) {
let anchor = retainedFocus;
for (let index = focusIndex - 1; index >= 0; index -= 1) {
const entry = entries[index];
if (entry.nextElementSibling !== anchor) this.signalList.insertBefore(entry, anchor);
anchor = entry;
}
}
let anchor = retainedFocus ? retainedFocus.nextElementSibling : this.signalList.firstElementChild;
for (let index = focusIndex + 1; index < entries.length; index += 1) {
const entry = entries[index];
if (entry === anchor) anchor = anchor.nextElementSibling;
else this.signalList.insertBefore(entry, anchor);
}
this.scheduleContextLayout();
}
@@ -3946,6 +4021,10 @@ export class StyleManager {
});
for (const targetId of targets) {
const panelEl = document.getElementById(targetId);
panelEl?.addEventListener('keydown', (event) => {
this._collapsePanelOnEscape(event, targetId);
});
this._restorePanelCollapsedState(targetId, {
allowStored: !this._initialShareState,
});
@@ -3961,6 +4040,46 @@ export class StyleManager {
this._maybeNotifyLayoutReset();
}
/**
* Collapses the nearest expanded panel that owns keyboard focus on Escape.
* Nested panels consume the event first, so one key closes one level and
* returns focus to that level's disclosure. If Escape was pressed on the
* disclosure itself, remove focus after closing so the collapsed button does
* not keep a stale keyboard ring.
* @param {KeyboardEvent} event - Candidate Escape key event.
* @param {string} panelId - Collapsible panel containing the listener.
* @returns {boolean} Whether this panel handled the key.
*/
_collapsePanelOnEscape(event, panelId) {
if (event.key !== 'Escape' || event.defaultPrevented) return false;
const panelEl = document.getElementById(panelId);
if (!panelEl || panelEl.classList.contains('collapsed') || !panelEl.contains(event.target)) {
return false;
}
const focusedPanel = event.target?.closest?.(
'.panel-collapsible:not(.collapsed), #param-slider-panel:not(.collapsed)',
);
if (focusedPanel && focusedPanel !== panelEl) return false;
event.preventDefault();
event.stopPropagation();
if (panelId === 'location-bar' && this._locationSearch) {
// The document-level Escape cleanup cannot run after this panel consumes
// the event. Mirror that cleanup here so reopening Location never reveals
// a hidden draft query or expanded search field.
this._locationSearch.classList.remove('expanded');
this._locationSearch.value = '';
this._locationSearch.blur();
}
this.setPanelCollapsed(panelId, true, { explicit: true });
const disclosure = panelEl.querySelector(`[data-dock-toggle-target="${panelId}"]`)
|| panelEl.querySelector(`[data-collapse-target="${panelId}"]`);
const escapedFromDisclosure = event.target === disclosure
|| disclosure?.contains?.(event.target);
if (escapedFromDisclosure) disclosure?.blur?.();
else disclosure?.focus?.({ preventScroll: true });
return true;
}
/**
* Allows either command-dock tray to remain open until explicitly unpinned.
* Both trays may be pinned; transient and error trays stack above them.
@@ -4263,10 +4382,9 @@ export class StyleManager {
toggleDisclosure({ focusSource: event.detail === 0 });
});
disclosure?.addEventListener('keydown', (event) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
// The application-level Space shortcut must not steal activation from a
// focused disclosure. One non-repeating keydown is enough; no keyup latch
// is retained after focus moves into the tray.
if (event.key !== 'Enter') return;
// Preserve immediate Enter activation while leaving Space to the native
// button path, which emits its synthesized click only after key release.
event.preventDefault();
event.stopPropagation();
if (event.repeat) return;
@@ -4281,13 +4399,10 @@ export class StyleManager {
});
panelEl.addEventListener('keydown', (event) => {
if (event.key !== 'Escape') return;
if (!event.defaultPrevented) this._collapsePanelOnEscape(event, panelId);
cancelMapSourceFocus();
if (panelEl.classList.contains('collapsed')) return;
event.preventDefault();
clearOpen();
clearClose();
this.setPanelCollapsed(panelId, true, { explicit: true });
disclosure?.focus?.({ preventScroll: true });
});
}
@@ -4657,6 +4772,7 @@ export class StyleManager {
contextTabs[nextIndex].click();
}));
this._globalContextFlightsBtn?.addEventListener('click', () => {
if (this._contextModeChanging || this._clearSelectedLayersPromise) return;
const nextMode = this._contextMode === 'flights' ? null : 'flights';
this._claimContextVisualAuthority();
void this._runUserFacingContextAction(
@@ -4674,6 +4790,7 @@ export class StyleManager {
});
});
this._globalContextMissionsBtn?.addEventListener('click', () => {
if (this._contextModeChanging || this._clearSelectedLayersPromise) return;
const nextMode = this._contextMode === 'space-missions' ? null : 'space-missions';
this._claimContextVisualAuthority();
void this._runUserFacingContextAction(
@@ -4693,7 +4810,9 @@ export class StyleManager {
this._installationsSearchBtn?.addEventListener('click', () => {
if (!this._dataManager?.layers?.has('military-installations')) return;
const button = this._installationsSearchBtn;
button.disabled = true;
if (button.getAttribute('aria-busy') === 'true') return;
button.setAttribute('aria-disabled', 'true');
button.setAttribute('aria-busy', 'true');
void this._runUserFacingContextAction(async (notificationToken) => {
const enabled = await this._dataManager.setEnabled('military-installations', true, {
origin: 'user',
@@ -4708,7 +4827,8 @@ export class StyleManager {
: 'Nearby installations refreshed'));
return true;
}, 'Nearby installations could not be refreshed; try again').finally(() => {
button.disabled = false;
button.setAttribute('aria-disabled', 'false');
button.setAttribute('aria-busy', 'false');
});
});
}
@@ -4923,8 +5043,7 @@ export class StyleManager {
this._contextModeEntryIntent = null;
this._contextModeReplacementIntent = null;
this._contextModeChanging = true;
this._globalContextFlightsBtn && (this._globalContextFlightsBtn.disabled = true);
this._globalContextMissionsBtn && (this._globalContextMissionsBtn.disabled = true);
this._syncContextModeButtons();
try {
if (mode !== 'flights' && this.cockpitView?.active) {
this.cockpitView.exit({ restoreTracking: false });
@@ -5078,8 +5197,6 @@ export class StyleManager {
} finally {
if (isCurrent()) {
this._contextModeChanging = false;
this._globalContextFlightsBtn && (this._globalContextFlightsBtn.disabled = false);
this._globalContextMissionsBtn && (this._globalContextMissionsBtn.disabled = false);
this._syncContextModeButtons();
}
}
@@ -5326,8 +5443,18 @@ export class StyleManager {
this._globalContextFlightsBtn?.setAttribute('aria-selected', String(flightsActive));
this._globalContextMissionsBtn?.classList.toggle('active', missionsActive);
this._globalContextMissionsBtn?.setAttribute('aria-selected', String(missionsActive));
if (this._globalContextFlightsBtn) this._globalContextFlightsBtn.tabIndex = missionsActive ? -1 : 0;
if (this._globalContextMissionsBtn) this._globalContextMissionsBtn.tabIndex = missionsActive ? 0 : -1;
const transitionBusy = Boolean(this._contextModeChanging);
// Both Context choices stay in the ordinary Tab sequence. Arrow keys still
// provide tablist navigation, but must not be the only way to reach Space
// Missions from the keyboard. Semantic busy state keeps them perceivable
// while synchronous click guards prevent a second transition.
for (const button of [this._globalContextFlightsBtn, this._globalContextMissionsBtn]) {
if (!button) continue;
button.disabled = false;
button.tabIndex = 0;
button.setAttribute('aria-disabled', String(transitionBusy));
button.setAttribute('aria-busy', String(transitionBusy));
}
if (this._contextModeStandby) this._contextModeStandby.hidden = flightsActive || missionsActive;
if (this._contextFlightsView) this._contextFlightsView.hidden = !flightsActive;
if (this._contextMissionsView) this._contextMissionsView.hidden = !missionsActive;
@@ -5574,9 +5701,11 @@ export class StyleManager {
};
const toggleRadio = async (trigger) => {
if (!this._dataManager?.layers?.has('radio')) return;
if (trigger.getAttribute('aria-busy') === 'true') return;
const enabling = !this._dataManager.isEnabled('radio');
const revealAfterEnable = enabling && trigger === this._radioEnableBtn;
trigger.disabled = true;
trigger.setAttribute('aria-disabled', 'true');
trigger.setAttribute('aria-busy', 'true');
try {
const toggled = await this._runUserFacingContextAction(
(notificationToken) => this._dataManager.setEnabled('radio', enabling, {
@@ -5592,7 +5721,8 @@ export class StyleManager {
}
if (revealAfterEnable) await this._revealRadioControlsAfterExplicitEnable(trigger);
} finally {
trigger.disabled = false;
trigger.setAttribute('aria-disabled', 'false');
trigger.setAttribute('aria-busy', 'false');
if (revealAfterEnable && trigger.isConnected) trigger.focus({ preventScroll: true });
}
};
@@ -5644,16 +5774,25 @@ export class StyleManager {
// first-run launcher — one key, two actions. Matches the cockpit
// disclosure handler directly below.
event.stopImmediatePropagation();
setRadioDisclosure(false, { returnFocus: true });
const escapedFromDisclosure = event.target === this._contextRadioToggleBtn
|| this._contextRadioToggleBtn?.contains?.(event.target);
setRadioDisclosure(false, { returnFocus: !escapedFromDisclosure });
if (escapedFromDisclosure) this._contextRadioToggleBtn?.blur?.();
}, { capture: true, signal: this._radioTunerAbort.signal });
document.addEventListener('keydown', (event) => {
if (event.key !== 'Escape') return;
const displayOpen = this._cockpitDisplayToggleBtn?.getAttribute('aria-expanded') === 'true';
const radioOpen = this._cockpitRadioToggleBtn?.getAttribute('aria-expanded') === 'true';
if (!displayOpen && !radioOpen) return;
const nestedPanel = event.target?.closest?.('.panel-collapsible:not(.collapsed), #param-slider-panel:not(.collapsed)');
if (nestedPanel) return;
event.preventDefault();
event.stopImmediatePropagation();
setCockpitDisclosure(displayOpen ? 'display' : 'radio', false, { returnFocus: true });
const kind = displayOpen ? 'display' : 'radio';
const disclosure = displayOpen ? this._cockpitDisplayToggleBtn : this._cockpitRadioToggleBtn;
const escapedFromDisclosure = event.target === disclosure || disclosure?.contains?.(event.target);
setCockpitDisclosure(kind, false, { returnFocus: !escapedFromDisclosure });
if (escapedFromDisclosure) disclosure?.blur?.();
}, { capture: true, signal: this._radioTunerAbort.signal });
window.addEventListener('gev:cockpit-mode-changed', (event) => {
if (event?.detail?.active) return;
@@ -5947,7 +6086,9 @@ export class StyleManager {
'aria-label',
uncertain ? 'Reconcile Radio — lifecycle uncertain' : `${enabled ? 'Disable' : 'Enable'} Radio`,
);
this._radioEnableBtn.disabled = transitioning;
this._radioEnableBtn.disabled = false;
this._radioEnableBtn.setAttribute('aria-disabled', String(transitioning));
this._radioEnableBtn.setAttribute('aria-busy', String(transitioning));
}
if (this._contextRadioMiniEnableBtn) {
this._contextRadioMiniEnableBtn.classList.toggle('active', enabled);
@@ -5959,7 +6100,9 @@ export class StyleManager {
'aria-label',
uncertain ? 'Reconcile Radio — lifecycle uncertain' : `${enabled ? 'Disable' : 'Enable'} Radio`,
);
this._contextRadioMiniEnableBtn.disabled = transitioning;
this._contextRadioMiniEnableBtn.disabled = false;
this._contextRadioMiniEnableBtn.setAttribute('aria-disabled', String(transitioning));
this._contextRadioMiniEnableBtn.setAttribute('aria-busy', String(transitioning));
}
if (this._cockpitRadioEnableBtn) {
this._cockpitRadioEnableBtn.classList.toggle('active', enabled);
@@ -5971,7 +6114,9 @@ export class StyleManager {
'aria-label',
uncertain ? 'Reconcile Radio — lifecycle uncertain' : `${enabled ? 'Disable' : 'Enable'} Radio`,
);
this._cockpitRadioEnableBtn.disabled = transitioning;
this._cockpitRadioEnableBtn.disabled = false;
this._cockpitRadioEnableBtn.setAttribute('aria-disabled', String(transitioning));
this._cockpitRadioEnableBtn.setAttribute('aria-busy', String(transitioning));
}
if (this._radioFilter) {
@@ -7475,7 +7620,7 @@ export class StyleManager {
});
const dockToggle = panelEl.querySelector(`[data-dock-toggle-target="${panelEl.id}"]`);
if (dockToggle) {
const panelName = panelEl.querySelector('.panel-title')?.textContent?.trim() || 'panel';
const panelName = panelEl.querySelector('.panel-title, .location-toolbar-label')?.textContent?.trim() || 'panel';
const action = collapsed ? 'Expand' : 'Collapse';
dockToggle.setAttribute('aria-expanded', String(!collapsed));
dockToggle.setAttribute('aria-label', `${action} ${panelName}`);
@@ -9694,9 +9839,8 @@ export class StyleManager {
this._preservePanelStateDuringLayerClear = true;
this._syncContextModeButtons();
this._userFacingContextNotificationTokens.add(notificationToken);
this._globalContextFlightsBtn && (this._globalContextFlightsBtn.disabled = true);
this._globalContextMissionsBtn && (this._globalContextMissionsBtn.disabled = true);
this._clearSelectedLayersBtn.disabled = true;
this._clearSelectedLayersBtn.setAttribute('aria-disabled', 'true');
this._clearSelectedLayersBtn.setAttribute('aria-busy', 'true');
this._clearSelectedLayersBtn.setAttribute('aria-label', 'Clearing selected data layers');
const managerOperation = this._dataManager.clearSelectedLayers({
@@ -9728,10 +9872,9 @@ export class StyleManager {
if (generation === this._contextModeGeneration) {
this._contextModeChanging = false;
this._syncContextModeButtons();
this._globalContextFlightsBtn && (this._globalContextFlightsBtn.disabled = false);
this._globalContextMissionsBtn && (this._globalContextMissionsBtn.disabled = false);
}
this._clearSelectedLayersBtn.disabled = false;
this._clearSelectedLayersBtn.setAttribute('aria-disabled', 'false');
this._clearSelectedLayersBtn.setAttribute('aria-busy', 'false');
this._clearSelectedLayersBtn.setAttribute('aria-label', 'Clear selected data layers');
this._preservePanelStateDuringLayerClear = false;
this._clearSelectedLayersManagerPromise = null;
+156 -32
View File
@@ -20,6 +20,7 @@ const STATUS = {
error: 'ERROR',
};
const CALL_DEDUPE_MS = 2500;
export const PUSH_TO_TALK_HOLD_DELAY_MS = 500;
// WebRTC 'disconnected' is frequently momentary (a brief network blip that ICE
// recovers on its own). Give it this long to return to 'connected' before we
// treat it as a real drop (H8).
@@ -307,6 +308,11 @@ export class GevRealtimeController {
this.pushToTalkMode = false;
this.pushToTalkKeyHeld = false;
this.spaceKeyHeld = false;
this.pushToTalkHoldTimer = null;
this.pushToTalkHoldGeneration = 0;
this.pushToTalkHoldFocusOwner = null;
this.pushToTalkHoldControl = null;
this.pushToTalkHoldPreservesNative = false;
this.shortcutKeyDownHandler = null;
this.shortcutKeyUpHandler = null;
this.shortcutBlurHandler = null;
@@ -568,65 +574,122 @@ export class GevRealtimeController {
}
}
/**
* Registers hold-Space push-to-talk without hijacking typing or modified shortcuts.
* @returns {void}
*/
/** Registers delayed hold-Space push-to-talk while preserving short control taps. */
bindPushToTalkShortcut() {
if (this.shortcutKeyDownHandler) return;
this.shortcutKeyDownHandler = (event) => {
if (!shouldHandlePushToTalkKeyDown(event)) return;
if (event.repeat) {
if (this.spaceKeyHeld) event.preventDefault();
if (this.spaceKeyHeld && !this.pushToTalkHoldPreservesNative) event.preventDefault();
if (this.pushToTalkKeyHeld) event.preventDefault();
return;
}
this.spaceKeyHeld = true;
// Space must not generate the focused mic button's native click on keyup.
event.preventDefault();
this.pauseRadioForVoice();
if (this.pushToTalkKeyHeld) return;
this.pushToTalkHoldFocusOwner = document.activeElement;
this.pushToTalkHoldControl = isInteractiveSpaceTarget(document.activeElement)
? document.activeElement
: (isInteractiveSpaceTarget(event.target)
? event.target.closest?.(SPACE_INTERACTIVE_SELECTOR) || event.target
: null);
this.pushToTalkHoldPreservesNative = Boolean(this.pushToTalkHoldControl);
// Background Space is reserved immediately to avoid scrolling. A focused
// control keeps its native keydown and release unless the hold is claimed.
if (!this.pushToTalkHoldPreservesNative) event.preventDefault();
// A click-started session is intentionally open-mic. Space only claims an
// idle session (or a session it already started) so releasing the key can
// never surprise the user by muting a click-started conversation.
if (this.isActive() && !this.pushToTalkMode) return;
this.pushToTalkKeyHeld = true;
this.ui.root.dataset.pushToTalk = 'held';
if (this.isActive()) {
this.setMicrophoneEnabled(true);
if (this.status === 'listening') this.setStatus('listening', 'Release Space to send');
} else {
this.start({ pushToTalk: true });
}
this.cancelPushToTalkHold();
const holdGeneration = ++this.pushToTalkHoldGeneration;
this.pushToTalkHoldTimer = setTimeout(() => {
this.pushToTalkHoldTimer = null;
if (holdGeneration !== this.pushToTalkHoldGeneration) return;
if (!this.spaceKeyHeld || this.pushToTalkKeyHeld) return;
// A pointer or Tab focus change during the delay cancels the original
// gesture rather than letting voice claim a key held for another owner.
if (document.activeElement !== this.pushToTalkHoldFocusOwner) return;
if (
document.visibilityState === 'hidden'
|| (typeof document.hasFocus === 'function' && !document.hasFocus())
) return;
if (this.isActive() && !this.pushToTalkMode) return;
// Blur before voice starts. This removes the focused state and ensures
// the eventual Space release cannot activate the old control.
if (
this.pushToTalkHoldControl
&& document.activeElement === this.pushToTalkHoldControl
&& typeof this.pushToTalkHoldControl.blur === 'function'
) {
this.pushToTalkHoldControl.blur();
}
this.pauseRadioForVoice();
this.pushToTalkKeyHeld = true;
if (this.isActive()) {
this.ui.root.dataset.pushToTalk = 'held';
this.setMicrophoneEnabled(true);
if (this.status === 'listening') this.setStatus('listening', 'Release Space to send');
} else {
this.start({ pushToTalk: true });
// start() performs a controlled stop() before connecting. Restore the
// marker it clears so the UI reflects this still-held gesture.
if (this.pushToTalkKeyHeld) this.ui.root.dataset.pushToTalk = 'held';
}
}, PUSH_TO_TALK_HOLD_DELAY_MS);
};
this.shortcutKeyUpHandler = (event) => {
if (!isPushToTalkKey(event)) return;
const wasHoldingSpace = this.spaceKeyHeld;
const preservedNativeActivation = this.pushToTalkHoldPreservesNative;
this.spaceKeyHeld = false;
this.cancelPushToTalkHold();
if (!this.pushToTalkKeyHeld) {
if (wasHoldingSpace) event.preventDefault();
if (wasHoldingSpace && !preservedNativeActivation) event.preventDefault();
this.resetPushToTalkGesture();
return;
}
event.preventDefault();
this.releasePushToTalkKey();
this.resetPushToTalkGesture();
};
this.shortcutBlurHandler = () => {
this.spaceKeyHeld = false;
this.cancelPushToTalkHold();
this.releasePushToTalkKey();
this.resetPushToTalkGesture();
};
this.shortcutVisibilityHandler = () => {
if (document.visibilityState === 'hidden') this.shortcutBlurHandler();
};
document.addEventListener('keydown', this.shortcutKeyDownHandler);
document.addEventListener('keyup', this.shortcutKeyUpHandler);
// Observe before custom controls call preventDefault for their own Space
// behavior. This lets the same physical hold cross the 500ms voice
// threshold while short taps still reach the control unchanged.
document.addEventListener('keydown', this.shortcutKeyDownHandler, true);
document.addEventListener('keyup', this.shortcutKeyUpHandler, true);
window.addEventListener('blur', this.shortcutBlurHandler);
document.addEventListener('visibilitychange', this.shortcutVisibilityHandler);
}
/** Cancels an unclaimed Space hold before it starts voice. */
cancelPushToTalkHold() {
this.pushToTalkHoldGeneration++;
if (this.pushToTalkHoldTimer === null) return;
clearTimeout(this.pushToTalkHoldTimer);
this.pushToTalkHoldTimer = null;
}
/** Clears the owner metadata for the current physical Space gesture. */
resetPushToTalkGesture() {
this.pushToTalkHoldFocusOwner = null;
this.pushToTalkHoldControl = null;
this.pushToTalkHoldPreservesNative = false;
}
/**
* Mutes a keyboard-started microphone while leaving WebRTC alive for the reply.
* @returns {void}
*/
releasePushToTalkKey() {
this.cancelPushToTalkHold();
if (!this.pushToTalkKeyHeld) return;
this.pushToTalkKeyHeld = false;
delete this.ui.root.dataset.pushToTalk;
@@ -773,6 +836,7 @@ export class GevRealtimeController {
// releases its own resources instead of promoting them onto a stopped
// controller (H7).
this.startEpoch++;
this.cancelPushToTalkHold();
this.radioHandoffEpoch++;
for (const controller of this.activeToolAbortControllers) controller.abort();
this.activeToolAbortControllers.clear();
@@ -844,6 +908,7 @@ export class GevRealtimeController {
this.pushToTalkMode = false;
this.pushToTalkKeyHeld = false;
this.spaceKeyHeld = false;
this.resetPushToTalkGesture();
if (this.ui?.root) {
delete this.ui.root.dataset.pushToTalk;
delete this.ui.root.dataset.microphone;
@@ -857,8 +922,8 @@ export class GevRealtimeController {
this.tierHandler = null;
}
if (removeUi) {
if (this.shortcutKeyDownHandler) document.removeEventListener('keydown', this.shortcutKeyDownHandler);
if (this.shortcutKeyUpHandler) document.removeEventListener('keyup', this.shortcutKeyUpHandler);
if (this.shortcutKeyDownHandler) document.removeEventListener('keydown', this.shortcutKeyDownHandler, true);
if (this.shortcutKeyUpHandler) document.removeEventListener('keyup', this.shortcutKeyUpHandler, true);
if (this.shortcutBlurHandler) window.removeEventListener('blur', this.shortcutBlurHandler);
if (this.shortcutVisibilityHandler) {
document.removeEventListener('visibilitychange', this.shortcutVisibilityHandler);
@@ -2454,18 +2519,77 @@ export function isPushToTalkKey(event) {
return event?.code === 'Space' || event?.key === ' ';
}
const SPACE_INTERACTIVE_SELECTOR = [
'button',
'input',
'textarea',
'select',
'option',
'a[href]',
'summary',
'audio[controls]',
'video[controls]',
'[contenteditable]',
'[tabindex]',
'[role="button"]',
'[role="checkbox"]',
'[role="combobox"]',
'[role="link"]',
'[role="listbox"]',
'[role="menuitem"]',
'[role="menuitemcheckbox"]',
'[role="menuitemradio"]',
'[role="option"]',
'[role="radio"]',
'[role="searchbox"]',
'[role="slider"]',
'[role="spinbutton"]',
'[role="switch"]',
'[role="tab"]',
'[role="textbox"]',
'[role="treeitem"]',
].join(', ');
/**
* Protects text entry and modified shortcuts from the global push-to-talk key.
* @param {KeyboardEvent|object|null} event
* @returns {boolean}
* Returns whether Space began on the map surface reserved for push-to-talk.
* Cesium gives its canvas `tabindex="0"` for camera input, which otherwise
* makes the generic focus guard treat a map click like a button activation.
*/
export function isPushToTalkSurface(target) {
if (target?.tagName?.toUpperCase?.() !== 'CANVAS') return false;
if (target.id === 'world-overlay-canvas') return true;
return Boolean(target.closest?.('#cesiumContainer'));
}
/** Returns whether a focused target owns Space for UI interaction. */
export function isInteractiveSpaceTarget(target) {
if (!target) return false;
if (isPushToTalkSurface(target)) return false;
if (target.isContentEditable) return true;
return Boolean(target.closest?.(SPACE_INTERACTIVE_SELECTOR));
}
/** Returns whether Space belongs to a text-entry surface. */
export function isEditingSpaceTarget(target) {
if (!target) return false;
if (target.isContentEditable || target.closest?.('[contenteditable]')) return true;
if (target.closest?.('textarea, [role="textbox"], [role="searchbox"], [role="spinbutton"]')) {
return true;
}
const input = target.closest?.('input');
if (!input) return false;
const type = String(input.type || 'text').toLowerCase();
return [
'text', 'search', 'email', 'url', 'tel', 'password', 'number',
'date', 'datetime-local', 'month', 'week', 'time',
].includes(type);
}
/** Protects text entry and modified shortcuts from push-to-talk arbitration. */
export function shouldHandlePushToTalkKeyDown(event) {
if (!isPushToTalkKey(event) || event.defaultPrevented) return false;
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return false;
const target = event.target;
if (target?.isContentEditable) return false;
const editingControl = target?.closest?.('input, textarea, select, [contenteditable], [role="textbox"]');
return !editingControl;
return !isEditingSpaceTarget(event.target);
}
/**
@@ -2510,7 +2634,7 @@ export function resolveVoiceVisualizerSpeaker(currentSpeaker, nextSpeaker, keepC
export function resolveVoiceControlHint(pushToTalkMode, pushToTalkKeyHeld) {
return pushToTalkMode && pushToTalkKeyHeld
? 'Release Space to send'
: 'Hold Space to speak · click mic to toggle voice';
: 'Hold Space to speak · tap Space to activate focused controls';
}
/**
@@ -2559,7 +2683,7 @@ function createVoiceControl({ reset = false } = {}) {
<span id="gev-voice-cost-value" class="gev-voice-cost-value" data-level="ok" title="Estimated session cost">~$0.00</span>
</div>
</div>
<button id="gev-voice-button" type="button" aria-label="Voice control — hold Space to speak; click to toggle voice" aria-describedby="gev-voice-help">
<button id="gev-voice-button" type="button" aria-label="Voice control — activate to toggle voice; hold Space to speak" aria-describedby="gev-voice-help">
<span class="gev-mic-orbit"><img src="/mic.svg" alt="" /></span>
<span class="gev-mic-label">ON/OFF</span>
</button>
@@ -2571,7 +2695,7 @@ function createVoiceControl({ reset = false } = {}) {
</div>
<div id="gev-voice-help" class="gev-voice-help-tray" role="tooltip">
<span class="gev-voice-help-kicker">VOICE CONTROL</span>
<span class="gev-voice-help-detail">Hold Space to speak · click mic to toggle voice</span>
<span class="gev-voice-help-detail">Hold Space to speak · tap Space to activate focused controls</span>
</div>
<div class="gev-voice-error-tray" role="alert" aria-live="assertive">
<div class="gev-voice-error-header">
+427 -4
View File
@@ -12,7 +12,11 @@ import {
GevRealtimeController,
gateVoiceVisualizerLevel,
isBenignViewportDeleteError,
isEditingSpaceTarget,
isInteractiveSpaceTarget,
isPushToTalkKey,
isPushToTalkSurface,
PUSH_TO_TALK_HOLD_DELAY_MS,
resolveVoiceControlHint,
resolveVoiceVisualizerSpeaker,
selectVoiceVisualizerSignal,
@@ -30,23 +34,442 @@ import {
import { createVoiceCostTracker } from './voiceCost.js';
test('push-to-talk recognizes Space by code or key', () => {
assert.equal(PUSH_TO_TALK_HOLD_DELAY_MS, 500);
assert.equal(isPushToTalkKey({ code: 'Space', key: 'Unidentified' }), true);
assert.equal(isPushToTalkKey({ code: '', key: ' ' }), true);
assert.equal(isPushToTalkKey({ code: 'KeyM', key: 'm' }), false);
});
test('push-to-talk ignores typing targets and modified shortcuts', () => {
test('push-to-talk protects text entry but arbitrates non-editing controls', () => {
const plainTarget = { isContentEditable: false, closest: () => null };
assert.equal(shouldHandlePushToTalkKeyDown({ code: 'Space', target: plainTarget }), true);
assert.equal(shouldHandlePushToTalkKeyDown({ code: 'Space', target: plainTarget, metaKey: true }), false);
assert.equal(shouldHandlePushToTalkKeyDown({
code: 'Space',
target: { isContentEditable: false, closest: () => ({ tagName: 'INPUT' }) },
target: pushToTalkTarget({ tagName: 'INPUT', type: 'text' }),
}), false);
assert.equal(shouldHandlePushToTalkKeyDown({
code: 'Space',
target: { isContentEditable: true, closest: () => null },
}), false);
assert.equal(shouldHandlePushToTalkKeyDown({
code: 'Space',
target: pushToTalkTarget({ tagName: 'BUTTON' }),
}), true);
assert.equal(isEditingSpaceTarget(pushToTalkTarget({ tagName: 'INPUT', type: 'range' })), false);
});
// Dispatch through the installed document/window listeners, keeping WebRTC at
// the start boundary. Native button activation itself belongs to browser QA.
function createPushToTalkFixture(t) {
const savedGlobals = new Map(['document', 'window', 'setTimeout', 'clearTimeout'].map((name) => (
[name, Object.getOwnPropertyDescriptor(globalThis, name)]
)));
const eventHub = () => {
const listeners = new Map();
return {
addEventListener(type, listener) {
if (!listeners.has(type)) listeners.set(type, new Set());
listeners.get(type).add(listener);
},
removeEventListener(type, listener) { listeners.get(type)?.delete(listener); },
dispatch(type, event = {}) {
for (const listener of listeners.get(type) || []) listener(event);
return event;
},
count(type) { return listeners.get(type)?.size || 0; },
};
};
let clock = 0;
let nextTimerId = 1;
const timers = new Map();
const setTimeout = (callback, delay = 0) => {
const id = nextTimerId++;
timers.set(id, { callback, due: clock + Number(delay || 0) });
return id;
};
const clearTimeout = (id) => timers.delete(id);
const advance = (milliseconds) => {
const end = clock + milliseconds;
while (true) {
const due = [...timers.entries()]
.filter(([, timer]) => timer.due <= end)
.sort((a, b) => a[1].due - b[1].due || a[0] - b[0])[0];
if (!due) break;
const [id, timer] = due;
timers.delete(id);
clock = timer.due;
timer.callback();
}
clock = end;
};
const document = { ...eventHub(), visibilityState: 'visible', activeElement: null };
const window = eventHub();
Object.assign(globalThis, { document, window, setTimeout, clearTimeout });
const starts = [];
const radio = [];
const controllers = [];
const makeController = () => {
const microphone = { enabled: false, stopped: false, stop() { this.stopped = true; } };
const controller = new GevRealtimeController({
ui: {
root: { dataset: {}, querySelectorAll: () => [], remove() {} },
status: {}, detail: {},
},
runner: async () => ({}),
radioLayer: {
pause: () => radio.push('pause'),
setVoiceDucked: (ducked) => radio.push(ducked ? 'duck' : 'unduck'),
},
});
controller.debugLog = () => {};
controller.start = ({ pushToTalk }) => {
starts.push({ pushToTalk });
controller.pushToTalkMode = pushToTalk;
controller.status = 'listening';
controller.stream = {
getAudioTracks: () => [microphone],
getTracks: () => [microphone],
};
controller.setMicrophoneEnabled(true);
};
controllers.push(controller);
controller.bindPushToTalkShortcut();
return { controller, microphone };
};
t.after(() => {
for (const controller of controllers) controller.stop({ removeUi: true });
for (const [name, descriptor] of savedGlobals) {
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
else delete globalThis[name];
}
});
const key = (type, target, options = {}) => {
document.activeElement = options.activeElement === undefined ? target : options.activeElement;
return document.dispatch(type, {
code: 'Space', key: ' ', target, defaultPrevented: false,
preventDefault() { this.defaultPrevented = true; },
...options,
});
};
return { advance, document, window, starts, radio, key, makeController, timers, ...makeController() };
}
function pushToTalkTarget({
tagName = null,
id = null,
style = null,
parentElement = null,
role = null,
tabIndex = null,
href = null,
controls = false,
type = null,
contentEditable = false,
mapSurface = false,
} = {}) {
const resolvedTagName = (tagName || (style ? 'BUTTON' : 'DIV')).toUpperCase();
return {
tagName: resolvedTagName,
id,
style,
parentElement,
role,
tabIndex,
href,
controls,
type,
blurred: 0,
blur() { this.blurred += 1; },
isContentEditable: contentEditable,
closest(selector) {
for (let node = this; node; node = node.parentElement) {
if (selector === '#cesiumContainer' && (node.id === 'cesiumContainer' || node.mapSurface)) {
return node;
}
for (const part of selector.split(/,\s*/)) {
if (part.toUpperCase() === node.tagName) return node;
if (part === 'a[href]' && node.tagName === 'A' && node.href !== null) return node;
if (part === 'audio[controls]' && node.tagName === 'AUDIO' && node.controls) return node;
if (part === 'video[controls]' && node.tagName === 'VIDEO' && node.controls) return node;
if (part === '[contenteditable]' && node.isContentEditable) return node;
if (part === '[tabindex]' && node.tabIndex !== null) return node;
const roleMatch = part.match(/^\[role="([^"]+)"\]$/);
if (roleMatch && node.role === roleMatch[1]) return node;
}
}
return null;
},
mapSurface,
};
}
test('push-to-talk starts from Cesium canvases and the document background', (t) => {
const f = createPushToTalkFixture(t);
const cesiumContainer = pushToTalkTarget({ tagName: 'DIV', id: 'cesiumContainer' });
const cesiumCanvas = pushToTalkTarget({
tagName: 'CANVAS',
tabIndex: 0,
parentElement: cesiumContainer,
});
const overlayCanvas = pushToTalkTarget({
tagName: 'CANVAS',
id: 'world-overlay-canvas',
tabIndex: 0,
});
for (const [index, canvas] of [cesiumCanvas, overlayCanvas].entries()) {
assert.equal(isPushToTalkSurface(canvas), true);
assert.equal(isInteractiveSpaceTarget(canvas), false);
assert.equal(f.key('keydown', canvas).defaultPrevented, true);
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS);
assert.equal(f.starts.length, 1, 'the first map hold opens one reusable voice session');
assert.equal(f.controller.pushToTalkKeyHeld, true);
assert.equal(f.microphone.enabled, true, `map hold ${index + 1} enables the microphone`);
assert.equal(f.key('keyup', canvas).defaultPrevented, true);
assert.equal(f.microphone.enabled, false, `map release ${index + 1} mutes the microphone`);
}
const background = pushToTalkTarget();
assert.equal(f.key('keydown', background).defaultPrevented, true);
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS);
assert.equal(f.controller.pushToTalkKeyHeld, true);
assert.equal(f.key('keyup', background).defaultPrevented, true);
});
test('short Space on a focused control preserves release activation', (t) => {
const f = createPushToTalkFixture(t);
const button = pushToTalkTarget({ tagName: 'BUTTON' });
let activations = 0;
assert.equal(f.key('keydown', button).defaultPrevented, false);
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS - 1);
const release = f.key('keyup', button);
if (!release.defaultPrevented) activations += 1;
assert.equal(release.defaultPrevented, false);
assert.equal(activations, 1);
assert.equal(button.blurred, 0);
assert.deepEqual(f.starts, []);
});
test('long Space blurs a focused control before voice and consumes release', (t) => {
const f = createPushToTalkFixture(t);
const button = pushToTalkTarget({ tagName: 'BUTTON' });
const order = [];
button.blur = () => {
button.blurred += 1;
order.push('blur');
f.document.activeElement = null;
};
f.controller.pauseRadioForVoice = () => order.push('voice');
assert.equal(f.key('keydown', button).defaultPrevented, false);
assert.equal(f.key('keydown', button, { repeat: true }).defaultPrevented, false);
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS);
assert.deepEqual(order, ['blur', 'voice']);
assert.equal(button.blurred, 1);
assert.equal(f.controller.pushToTalkKeyHeld, true);
assert.equal(f.key('keydown', button, { repeat: true, activeElement: null }).defaultPrevented, true);
let activations = 0;
const release = f.key('keyup', button, { activeElement: null });
if (!release.defaultPrevented) activations += 1;
assert.equal(release.defaultPrevented, true);
assert.equal(activations, 0);
assert.equal(f.microphone.enabled, false);
});
test('push-to-talk still ignores modified, already-handled and typing keydowns', (t) => {
const f = createPushToTalkFixture(t);
const target = pushToTalkTarget();
for (const modifier of ['altKey', 'ctrlKey', 'metaKey', 'shiftKey']) {
assert.equal(f.key('keydown', target, { [modifier]: true }).defaultPrevented, false);
}
f.key('keydown', target, { defaultPrevented: true });
f.key('keydown', target, { code: 'Enter', key: 'Enter' });
for (const tagName of ['INPUT', 'TEXTAREA']) f.key('keydown', pushToTalkTarget({ tagName }));
f.key('keydown', pushToTalkTarget({ role: 'textbox' }));
f.key('keydown', { isContentEditable: true });
assert.deepEqual(f.starts, []);
assert.deepEqual(f.radio, []);
assert.equal(f.controller.spaceKeyHeld, false);
});
test('push-to-talk starts once at 500ms and repeat does not reset the threshold', (t) => {
const f = createPushToTalkFixture(t);
const background = pushToTalkTarget();
assert.equal(f.key('keydown', background).defaultPrevented, true);
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS - 1);
assert.deepEqual(f.starts, []);
assert.deepEqual(f.radio, []);
assert.equal(f.key('keydown', background, { repeat: true }).defaultPrevented, true);
f.advance(1);
assert.deepEqual(f.starts, [{ pushToTalk: true }]);
assert.equal(f.controller.pushToTalkKeyHeld, true);
assert.equal(f.microphone.enabled, true);
const radioAfterStart = f.radio.slice();
assert.equal(f.key('keydown', background, { repeat: true }).defaultPrevented, true);
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS * 2);
assert.deepEqual(f.radio, radioAfterStart);
assert.equal(f.starts.length, 1);
assert.equal(
f.key('keyup', pushToTalkTarget({ tagName: 'BUTTON' })).defaultPrevented,
true,
'release follows a voice-owned hold after focus moves',
);
assert.equal(f.controller.spaceKeyHeld, false);
assert.equal(f.controller.pushToTalkKeyHeld, false);
assert.equal(f.microphone.enabled, false);
assert.equal(f.controller.ui.root.dataset.pushToTalk, undefined);
assert.equal(f.controller.ui.detail.textContent, 'Hold Space to talk');
});
test('push-to-talk treats a Space release before 500ms as a background tap', (t) => {
const f = createPushToTalkFixture(t);
const background = pushToTalkTarget();
assert.equal(f.key('keydown', background).defaultPrevented, true);
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS - 1);
assert.equal(f.key('keyup', background).defaultPrevented, true);
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS + 1);
assert.deepEqual(f.starts, []);
assert.deepEqual(f.radio, []);
assert.equal(f.controller.spaceKeyHeld, false);
assert.equal(f.controller.pushToTalkKeyHeld, false);
assert.equal(f.timers.size, 0);
});
test('push-to-talk rechecks focus before the hold threshold claims voice', (t) => {
const f = createPushToTalkFixture(t);
const background = pushToTalkTarget();
const control = pushToTalkTarget({ tagName: 'BUTTON' });
assert.equal(f.key('keydown', background).defaultPrevented, true);
f.document.activeElement = control;
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS);
assert.deepEqual(f.starts, []);
assert.deepEqual(f.radio, []);
assert.equal(f.key('keyup', control).defaultPrevented, true, 'release follows the background gesture owner');
assert.equal(f.controller.spaceKeyHeld, false);
});
test('push-to-talk never claims a control-started hold after focus moves outside', (t) => {
const f = createPushToTalkFixture(t);
const control = pushToTalkTarget({ tagName: 'BUTTON' });
const background = pushToTalkTarget();
assert.equal(f.key('keydown', control).defaultPrevented, false);
assert.equal(f.key('keydown', background, { repeat: true }).defaultPrevented, false);
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS + 1);
assert.equal(f.key('keyup', background).defaultPrevented, false);
assert.deepEqual(f.starts, []);
assert.deepEqual(f.radio, []);
});
test('push-to-talk cancels pending and active holds on blur or hidden document', (t) => {
const f = createPushToTalkFixture(t);
const background = pushToTalkTarget();
for (const exit of ['blur', 'hidden']) {
const startsBeforePending = f.starts.length;
const radioBeforePending = f.radio.length;
f.key('keydown', background);
const staleCallback = [...f.timers.values()][0].callback;
if (exit === 'blur') f.window.dispatch('blur');
else {
f.document.visibilityState = 'hidden';
f.document.dispatch('visibilitychange');
}
staleCallback();
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS);
assert.equal(f.starts.length, startsBeforePending);
assert.equal(f.radio.length, radioBeforePending);
assert.equal(f.controller.spaceKeyHeld, false);
f.document.visibilityState = 'visible';
f.key('keydown', background);
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS);
assert.equal(f.microphone.enabled, true);
if (exit === 'blur') f.window.dispatch('blur');
else {
f.document.visibilityState = 'hidden';
f.document.dispatch('visibilitychange');
}
assert.equal(f.controller.spaceKeyHeld, false);
assert.equal(f.controller.pushToTalkKeyHeld, false);
assert.equal(f.microphone.enabled, false);
assert.equal(f.controller.ui.root.dataset.pushToTalk, undefined);
assert.equal(f.key('keyup', background).defaultPrevented, false);
f.document.visibilityState = 'visible';
}
});
test('push-to-talk timer refuses hidden or unfocused documents before lifecycle events arrive', (t) => {
const f = createPushToTalkFixture(t);
const background = pushToTalkTarget();
for (const state of ['hidden', 'unfocused']) {
f.key('keydown', background);
if (state === 'hidden') f.document.visibilityState = 'hidden';
else f.document.hasFocus = () => false;
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS);
assert.deepEqual(f.starts, []);
assert.deepEqual(f.radio, []);
assert.equal(f.key('keyup', background).defaultPrevented, true);
f.document.visibilityState = 'visible';
delete f.document.hasFocus;
}
});
test('focused controls and background Space preserve a click-started open mic', (t) => {
const f = createPushToTalkFixture(t);
f.controller.start({ pushToTalk: false });
f.starts.length = 0;
const style = pushToTalkTarget({ style: 'retro' });
f.key('keydown', style);
f.key('keydown', style, { repeat: true });
assert.equal(f.key('keyup', style).defaultPrevented, false);
assert.deepEqual(f.radio, []);
assert.equal(f.microphone.enabled, true);
assert.equal(f.controller.pushToTalkMode, false);
assert.equal(f.key('keydown', pushToTalkTarget()).defaultPrevented, true);
assert.equal(f.controller.spaceKeyHeld, true);
assert.equal(f.controller.pushToTalkKeyHeld, false);
assert.equal(f.timers.size, 0);
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS + 1);
assert.equal(f.key('keyup', style).defaultPrevented, true);
assert.equal(f.controller.spaceKeyHeld, false);
assert.equal(f.microphone.enabled, true, 'an outside Space gesture must not claim or mute an open-mic session');
assert.deepEqual(f.starts, []);
});
test('push-to-talk teardown removes installed listeners and replacement binds once', (t) => {
const f = createPushToTalkFixture(t);
const outside = pushToTalkTarget();
f.controller.bindPushToTalkShortcut();
assert.equal(f.document.count('keydown'), 1);
f.key('keydown', outside);
const staleCallback = [...f.timers.values()][0].callback;
f.controller.stop({ removeUi: true });
staleCallback();
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS);
assert.equal(f.microphone.stopped, false);
assert.equal(f.controller.spaceKeyHeld, false);
assert.equal(f.controller.pushToTalkKeyHeld, false);
assert.equal(f.controller.pushToTalkMode, false);
assert.equal(f.document.count('keydown'), 0);
assert.equal(f.document.count('keyup'), 0);
assert.equal(f.document.count('visibilitychange'), 0);
assert.equal(f.window.count('blur'), 0);
f.key('keydown', outside);
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS);
assert.equal(f.starts.length, 0, 'the removed controller must not respond');
const replacement = f.makeController();
replacement.controller.bindPushToTalkShortcut();
assert.equal(f.document.count('keydown'), 1);
assert.equal(f.document.count('keyup'), 1);
assert.equal(f.document.count('visibilitychange'), 1);
assert.equal(f.window.count('blur'), 1);
f.key('keydown', pushToTalkTarget({ tagName: 'INPUT', type: 'text' }));
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS);
assert.equal(f.starts.length, 0);
f.key('keydown', outside);
f.advance(PUSH_TO_TALK_HOLD_DELAY_MS);
assert.equal(f.starts.length, 1);
assert.equal(replacement.microphone.enabled, true);
f.key('keyup', outside);
assert.equal(replacement.microphone.enabled, false);
});
test('mic clicks are ignored while Space is physically held', () => {
@@ -57,12 +480,12 @@ test('mic clicks are ignored while Space is physically held', () => {
test('voice control help tray reflects the push-to-talk key state', () => {
assert.equal(
resolveVoiceControlHint(false, false),
'Hold Space to speak · click mic to toggle voice',
'Hold Space to speak · tap Space to activate focused controls',
);
assert.equal(resolveVoiceControlHint(true, true), 'Release Space to send');
assert.equal(
resolveVoiceControlHint(true, false),
'Hold Space to speak · click mic to toggle voice',
'Hold Space to speak · tap Space to activate focused controls',
);
});