diff --git a/docs/VUE-MIGRATION.md b/docs/VUE-MIGRATION.md
index e71710001..f27019e48 100644
--- a/docs/VUE-MIGRATION.md
+++ b/docs/VUE-MIGRATION.md
@@ -136,14 +136,15 @@ They contain no application state.
starting from `cockpitBriefPages.js`, `cockpitContextState.js` and
`cockpitInstrumentState.js`, which both read; the odometer readouts
(`CockpitRollingValue`) keep their throttle and roll in
- `cockpitRollingValue.js`. The instruments publish on every HUD update
- (about 10 Hz); in a dev build their Vue patch costs about 0.5 ms, close
- to the DOM writes it replaced. The controls keep the controller's
- listeners, and the cards' visibility, collapse and layout stay with the
- controller. The signal rows and the radio controls follow; the signal
- rows become a
- component that keeps their focus-safe reorder, since a keyed Vue list
- would move the focused row and drop focus several times a second).
+ `cockpitRollingValue.js`. `CockpitSignalList` renders the live signal
+ rows; `v-focus-safe-children` turns Vue's move of the focused row into
+ moves of the rows around it, and hands focus to the shown briefing tab
+ before the focused row is removed, so a keyed list reorders several times
+ a second without dropping keyboard focus. The instruments publish on
+ every HUD update (about 10 Hz); in a dev build their Vue patch costs
+ about 0.5 ms, close to the DOM writes it replaced. The controls keep the
+ controller's listeners, and the cards' visibility, collapse and layout
+ stay with the controller. The radio controls follow).
Layers that write into the right rail directly gain a slot instead.
4. **Cleanup.** The build-time template expansion and the attribute
diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json
index e2240c8df..61a03f65e 100644
--- a/scripts/package-boundaries.json
+++ b/scripts/package-boundaries.json
@@ -31,6 +31,7 @@
"src/ui/views/panels/CockpitContext.vue",
"src/ui/views/panels/CockpitRoute.vue",
"src/ui/views/panels/CockpitRollingValue.vue",
+ "src/ui/views/panels/CockpitSignalList.vue",
"src/ui/cockpitBriefPages.js",
"src/ui/cockpitContextState.js",
"src/ui/cockpitInstrumentState.js",
@@ -75,6 +76,7 @@
"src/ui/stores/display.js",
"src/ui/rail/rangeValue.js",
"src/ui/rail/classToggle.js",
+ "src/ui/rail/focusSafeChildren.js",
"src/ui/rail/fieldValue.js"
],
"external": ["vue", "pinia"]
@@ -574,6 +576,7 @@
"src/ui/layerRowText.js",
"src/ui/layerPanel/LayerRows.vue",
"src/ui/rail/classToggle.js",
+ "src/ui/rail/focusSafeChildren.js",
"src/ui/layerPanel/RowText.vue",
"src/ui/railTimeline.js",
"src/ui/clearLayersControl.js",
@@ -890,6 +893,7 @@
"src/ui/layerRowText.js",
"src/ui/layerPanel/LayerRows.vue",
"src/ui/rail/classToggle.js",
+ "src/ui/rail/focusSafeChildren.js",
"src/ui/layerPanel/RowText.vue",
"src/ui/railTimeline.js",
"src/ui/layers.js",
@@ -2595,6 +2599,7 @@
"src/ui/layerRowText.js",
"src/ui/layerPanel/LayerRows.vue",
"src/ui/rail/classToggle.js",
+ "src/ui/rail/focusSafeChildren.js",
"src/ui/layerPanel/RowText.vue",
"src/ui/railTimeline.js",
"src/ui/layers.js",
diff --git a/scripts/qa-cockpit-utility.mjs b/scripts/qa-cockpit-utility.mjs
index e62df75f4..0212b1a03 100644
--- a/scripts/qa-cockpit-utility.mjs
+++ b/scripts/qa-cockpit-utility.mjs
@@ -2168,33 +2168,34 @@ try {
JSON.stringify(contextTransition),
);
// Use the real connected DOM to catch focus loss caused by moving live rows.
- const signalFocus = await page.evaluate(() => {
+ const signalFocus = await page.evaluate(async () => {
const cockpit = window.__godsEyeView.styleManager.cockpitView;
+ // The rows render on Vue's next update; a frame is past it.
+ const show = async (items) => {
+ cockpit.signalItems = items;
+ cockpit.publishCockpitSignals();
+ await new Promise(requestAnimationFrame);
+ };
const originalItems = cockpit.signalItems;
const wasCollapsed = cockpit.signalCollapsed;
cockpit.setSignalCollapsed(false);
const item = (id) => ({ key: `qa-${id}`, tone: 'info', timestamp: Date.now(),
title: `QA ${id}`, detail: 'Keyboard refresh fixture', target: { layerId: 'flights', id } });
try {
- cockpit.signalItems = [item('focus-a'), item('focus-b')];
- cockpit.renderCockpitSignals();
+ await show([item('focus-a'), item('focus-b')]);
const button = cockpit.signalList.querySelector('[data-signal-id="focus-a"]');
button.focus();
const acquired = document.activeElement === button;
- cockpit.signalItems = [item('focus-c'), item('focus-b'), item('focus-a')];
- cockpit.renderCockpitSignals();
+ await show([item('focus-c'), item('focus-b'), item('focus-a')]);
const retained = document.activeElement === button && button.isConnected;
const order = [...cockpit.signalList.querySelectorAll('[data-signal-id]')].map((node) => node.dataset.signalId);
- cockpit.signalItems = [item('focus-b')];
- cockpit.renderCockpitSignals();
+ await show([item('focus-b')]);
const continuation = cockpit.briefTabs[cockpit.briefPageIndex] || cockpit.signalToggle;
const continued = document.activeElement === continuation;
- cockpit.signalItems = [item('focus-a'), item('focus-b')];
- cockpit.renderCockpitSignals();
+ await show([item('focus-a'), item('focus-b')]);
return { acquired, retained, order, continued, notReclaimed: document.activeElement === continuation };
} finally {
- cockpit.signalItems = originalItems;
- cockpit.renderCockpitSignals();
+ await show(originalItems);
cockpit.setSignalCollapsed(wasCollapsed);
}
});
diff --git a/src/cockpitSignalFocus.test.mjs b/src/cockpitSignalFocus.test.mjs
index cfe60a461..a4f73c4ce 100644
--- a/src/cockpitSignalFocus.test.mjs
+++ b/src/cockpitSignalFocus.test.mjs
@@ -1,187 +1,119 @@
+// src/cockpitSignalFocus.test.mjs — the Cockpit signal rows keep keyboard
+// focus while they refresh and reorder, over happy-dom with the real view.
+import './testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
-import { afterEach } from 'node:test';
+import { createPinia } from 'pinia';
+import { createApp, nextTick } from 'vue';
import { formatAwarenessLabel } from './data/militaryAwarenessEngine.js';
-import { CockpitViewController } from './ui/cockpitController.js';
-const previousDocument = globalThis.document;
-afterEach(() => {
- globalThis.document = previousDocument;
-});
+import { createBriefState } from './ui/cockpitBriefPages.js';
+import {
+ handleSignalClick,
+ publishCockpitSignals,
+ pushCockpitSignal,
+ updateCockpitSignals,
+} from './ui/cockpitSignals.js';
+import { useCockpitStore } from './ui/stores/cockpit.js';
+import CockpitBriefing from './ui/views/panels/CockpitBriefing.vue';
-// 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(' ');
- },
- };
+/**
+ * A browser blurs an element it removes or moves. happy-dom only models the
+ * removal, so count both: any insert or removal of a node that holds focus
+ * is a loss, and a moved node gives its focus up as a browser's would.
+ */
+function trackFocus(t) {
+ const counts = { losses: 0, focusCalls: 0 };
+ const holdsFocus = (node) =>
+ node.isConnected && node.contains(document.activeElement);
+ const insertBefore = Node.prototype.insertBefore;
+ t.mock.method(Node.prototype, 'insertBefore', function (node, anchor) {
+ const lost = holdsFocus(node);
+ const result = insertBefore.call(this, node, anchor);
+ if (lost) {
+ counts.losses += 1;
+ document.activeElement.blur();
}
- 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;
- globalThis.document = document;
- const controller = Object.create(CockpitViewController.prototype);
- controller.services = { 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,
- );
+ return result;
+ });
+ const removeChild = Node.prototype.removeChild;
+ t.mock.method(Node.prototype, 'removeChild', function (node) {
+ if (holdsFocus(node)) counts.losses += 1;
+ return removeChild.call(this, node);
+ });
+ const focus = HTMLElement.prototype.focus;
+ t.mock.method(HTMLElement.prototype, 'focus', function (...args) {
+ assert.ok(this.isConnected, 'a focus destination must still be connected');
+ counts.focusCalls += 1;
+ return focus.apply(this, args);
+ });
+ return counts;
+}
+
+function fixture(t) {
+ document.body.replaceChildren();
+ const host = document.body.appendChild(document.createElement('div'));
+ const pinia = createPinia();
+ const app = createApp(CockpitBriefing).use(pinia);
+ app.mount(host);
+ const store = useCockpitStore(pinia);
+ const button = () => {
+ const element = document.createElement('button');
+ element.type = 'button';
+ return document.body.appendChild(element);
+ };
+ const display = button();
+ const radio = button();
+ const outside = button();
+ const counts = trackFocus(t);
+ t.after(() => app.unmount());
const selected = [];
- controller.services.militaryAwarenessLayer = {
- focusTarget: (...args) => selected.push(args),
+ // The signal presenters need only this much of the controller.
+ const owner = {
+ destroyed: false,
+ brief: createBriefState(),
+ signalItems: [],
+ signalSignatures: new Map(),
+ publishSignals: (rows) => store.publishSignals(rows),
+ publishCockpitSignals,
+ scheduleContextLayout() {},
+ services: {
+ formatAwarenessLabel,
+ militaryAwarenessLayer: {
+ focusTarget: (...args) => selected.push(args),
+ },
+ },
};
- const click = (event) => controller.handleSignalClick(event);
- const buttons = () => controller.signalList.querySelectorAll('button');
+ // The controller shows the card on entering Cockpit.
+ document.getElementById('cockpit-signal-stream').hidden = false;
+ const list = document.getElementById('cockpit-signal-list');
+ list.addEventListener('click', (event) =>
+ handleSignalClick.call(owner, event),
+ );
+ const buttons = () => [...list.querySelectorAll('button')];
+ // Tab visits the enabled buttons no hidden ancestor removes from view.
const tab = () => {
- const nodes = document.body.querySelectorAll('button');
- const index = nodes.indexOf(document.activeElement);
- nodes[(index + 1) % nodes.length].focus();
+ const nodes = [...document.querySelectorAll('button')].filter(
+ (node) => !node.disabled && !node.closest('[hidden]'),
+ );
+ nodes[(nodes.indexOf(document.activeElement) + 1) % nodes.length].focus();
};
+ const pageTab = (index) =>
+ document.querySelector(`[data-cockpit-brief-index="${index}"]`);
+ const signalTab = () => pageTab(0);
return {
- controller,
- document,
+ owner,
+ list,
buttons,
display,
radio,
outside,
selected,
- click,
tab,
- losses: () => focusLosses,
- focusCalls: () => focusCalls,
+ pageTab,
+ signalTab,
+ store,
+ losses: () => counts.losses,
+ focusCalls: () => counts.focusCalls,
};
}
@@ -194,9 +126,14 @@ const item = (id, overrides = {}) => ({
timestamp: 1_700_000_000_000,
...overrides,
});
-const render = (f, items) => {
- f.controller.signalItems = items;
- f.controller.renderCockpitSignals();
+const render = async (f, items) => {
+ f.owner.signalItems = items;
+ publishCockpitSignals.call(f.owner);
+ await nextTick();
+};
+const refresh = async (f, distanceM) => {
+ updateCockpitSignals.call(f.owner, snapshot(distanceM), 0);
+ await nextTick();
};
const snapshot = (distanceM = 2000) => ({
evaluatedAt: 1_700_000_000_000,
@@ -210,31 +147,61 @@ const snapshot = (distanceM = 2000) => ({
],
});
-test('actual Context refresh retains the focused signal button across unchanged and changed details', () => {
- const f = fixture();
- f.controller.updateCockpitSignals(snapshot(), 0);
+test('rows render the signals the controller presents', async (t) => {
+ const f = fixture(t);
+ await render(f, [
+ item('a'),
+ item('status', { tone: 'warning', target: null, title: 'UNKNOWN' }),
+ ]);
+ const [action, status] = f.list.children;
+ assert.equal(action.className, 'nearby actionable');
+ assert.equal(action.querySelector('time').textContent, '22:13:20Z');
+ const button = action.querySelector('button.cockpit-signal-target');
+ assert.equal(button.type, 'button');
+ assert.equal(button.dataset.signalLayer, 'flights');
+ assert.equal(button.dataset.signalId, 'a');
+ assert.equal(button.getAttribute('aria-label'), 'Select flight Flight a');
+ assert.equal(
+ button.querySelector('.cockpit-signal-target-label').textContent,
+ 'Flight a',
+ );
+ assert.equal(
+ button.querySelector('.cockpit-signal-target-chevron').textContent,
+ 'chevron_right',
+ );
+ assert.equal(
+ action.querySelector('div > span').textContent,
+ 'COMMERCIAL FLIGHT · 2 KM',
+ );
+ assert.equal(status.className, 'warning');
+ assert.equal(status.querySelector('div > strong').textContent, 'UNKNOWN');
+ assert.equal(status.querySelector('button'), null);
+});
+
+test('actual Context refresh retains the focused signal button across unchanged and changed details', async (t) => {
+ const f = fixture(t);
+ await refresh(f);
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);
+ for (let tick = 0; tick < 8; tick += 1) await refresh(f, 2500);
+ assert.equal(f.buttons()[1] === target, true);
+ assert.equal(document.activeElement === target, true);
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\u00a0KM/);
+ assert.match(f.list.textContent, /2\.5\u00a0KM/);
});
-test('signal content and accessible name update without replacing the active target', () => {
- const f = fixture();
- render(f, [item('a')]);
+test('signal content and accessible name update without replacing the active target', async (t) => {
+ const f = fixture(t);
+ await render(f, [item('a')]);
const target = f.buttons()[0];
target.focus();
- render(f, [
+ await render(f, [
item('a', {
title: 'RENAMED',
tone: 'track',
@@ -242,21 +209,18 @@ test('signal content and accessible name update without replacing the active tar
timestamp: 1_700_000_001_000,
}),
]);
- assert.equal(f.buttons()[0], target);
+ assert.equal(f.buttons()[0] === target, true);
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.match(f.list.textContent, /CURRENT/);
+ assert.equal(f.list.children[0].className, 'track actionable');
+ assert.equal(document.activeElement === target, true);
assert.equal(f.losses(), 0);
});
-test('signal ranking changes move surrounding rows without disconnecting the focused row', () => {
- const f = fixture();
- render(
+test('signal ranking changes move surrounding rows without disconnecting the focused row', async (t) => {
+ const f = fixture(t);
+ await render(
f,
['a', 'b', 'c', 'd'].map((id) => item(id)),
);
@@ -267,8 +231,10 @@ test('signal ranking changes move surrounding rows without disconnecting the foc
['d', 'c', 'b', 'a'],
['b', 'c', 'd', 'a'],
['a', 'd', 'c', 'b'],
+ ['c', 'a', 'b', 'd'],
+ ['b', 'a', 'd', 'c'],
]) {
- render(
+ await render(
f,
order.map((id) => item(id)),
);
@@ -276,97 +242,152 @@ test('signal ranking changes move surrounding rows without disconnecting the foc
f.buttons().map((button) => button.dataset.signalId),
order,
);
- assert.equal(f.document.activeElement, target);
+ assert.equal(document.activeElement === target, true);
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')]);
+test('a new pushed signal preserves existing focus and delegated selection reads the current target', async (t) => {
+ const f = fixture(t);
+ await render(f, [item('a'), item('b')]);
const target = f.buttons()[1];
target.focus();
- f.controller.pushCockpitSignal(
+ pushCockpitSignal.call(
+ f.owner,
'status',
'warning',
'INPUT UNKNOWN',
'SOURCE UNAVAILABLE',
);
- assert.equal(f.document.activeElement, target);
+ await nextTick();
+ assert.equal(f.list.children.length, 3);
+ assert.equal(document.activeElement === target, true);
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);
+ const click = new MouseEvent('click', { bubbles: true, cancelable: true });
+ target.querySelector('.cockpit-signal-target-label').dispatchEvent(click);
+ assert.equal(click.defaultPrevented, 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')]);
+test('removing the focused identity uses the shown briefing tab once and permits forward traversal', async (t) => {
+ const f = fixture(t);
+ await render(f, [item('a'), item('b')]);
const removed = f.buttons()[1];
removed.focus();
const calls = f.focusCalls();
- render(f, [item('a')]);
+ await render(f, [item('a')]);
assert.equal(removed.isConnected, false);
- assert.equal(f.document.activeElement, f.controller.briefTabs[0]);
+ assert.equal(document.activeElement === f.signalTab(), true);
assert.equal(f.focusCalls(), calls + 1);
+ assert.equal(f.losses(), 0);
+ assert.equal(f.signalTab().closest('[hidden]'), null);
+ // Traversal carries on forward from the footer tab.
f.tab();
- f.tab();
- f.tab();
- assert.equal(f.document.activeElement, f.display);
- render(f, [item('a', { detail: 'UPDATED' })]);
+ assert.equal(document.activeElement === f.pageTab(1), true);
+ while (document.activeElement !== f.display) f.tab();
+ await render(f, [item('a', { detail: 'UPDATED' })]);
assert.equal(
- f.document.activeElement,
- f.display,
+ document.activeElement === f.display,
+ true,
'later refresh must not pull focus back',
);
f.tab();
- assert.equal(f.document.activeElement, f.radio);
+ assert.equal(document.activeElement === f.radio, true);
});
-test('a changed target cannot reuse a focused action for another flight', () => {
- const f = fixture();
- render(f, [item('a', { key: 'shared-status-key' })]);
+test('a changed target cannot reuse a focused action for another flight', async (t) => {
+ const f = fixture(t);
+ await render(f, [item('a', { key: 'shared-status-key' })]);
const removed = f.buttons()[0];
removed.focus();
- render(f, [
+ await render(f, [
item('b', {
key: 'shared-status-key',
target: { layerId: 'military', id: 'b' },
}),
]);
const target = f.buttons()[0];
- assert.notEqual(target, removed);
+ assert.equal(target === removed, false);
assert.equal(removed.isConnected, false);
- assert.equal(f.document.activeElement, f.controller.briefTabs[0]);
- f.click({ target, preventDefault() {} });
+ assert.equal(document.activeElement === f.signalTab(), true);
+ target.click();
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')]);
+test('empty, informational and duplicate-key rows have no stale or aliased actions', async (t) => {
+ const f = fixture(t);
+ await render(f, [item('a'), item('a')]);
const [first, second] = f.buttons();
- assert.notEqual(first, second);
+ assert.equal(first === second, false);
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' })]);
+ await render(f, [item('a'), item('a')]);
+ const again = f.buttons();
+ assert.equal(again[0] === first && again[1] === second, true);
+ assert.equal(document.activeElement === second, true);
+ await 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]);
+ assert.match(f.list.textContent, /UNKNOWN/);
+ assert.equal(document.activeElement === f.signalTab(), true);
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);
+ await render(f, []);
+ assert.equal(f.list.children.length, 0);
+ await render(f, [item('a')]);
+ assert.equal(f.buttons()[0] === first, false);
+ assert.equal(document.activeElement === f.outside, true);
+ assert.equal(f.losses(), 0);
+});
+
+test('any reordering of five rows keeps focus wherever it sits', async (t) => {
+ const f = fixture(t);
+ const ids = ['a', 'b', 'c', 'd', 'e'];
+ // A fixed pseudo-random sequence, so a failure reproduces.
+ let seed = 7;
+ const random = () => {
+ seed = (seed * 48271) % 2147483647;
+ return seed / 2147483647;
+ };
+ const shuffle = (list) => {
+ const next = [...list];
+ for (let index = next.length - 1; index > 0; index -= 1) {
+ const swap = Math.floor(random() * (index + 1));
+ [next[index], next[swap]] = [next[swap], next[index]];
+ }
+ return next;
+ };
+ await render(
+ f,
+ ids.map((id) => item(id)),
+ );
+ for (let round = 0; round < 60; round += 1) {
+ const target = f.buttons()[round % ids.length];
+ target.focus();
+ const order = shuffle(ids);
+ await render(
+ f,
+ order.map((id) => item(id)),
+ );
+ assert.deepEqual(
+ f.buttons().map((button) => button.dataset.signalId),
+ order,
+ );
+ assert.equal(document.activeElement === target, true);
+ }
+ assert.equal(f.losses(), 0);
+});
+
+test('the fallback follows the shown page, and the collapse toggle without tabs', async (t) => {
+ const f = fixture(t);
+ f.store.publishBrief({ ...createBriefState(), pageIndex: 2 });
+ await render(f, [item('a'), item('b')]);
+ f.buttons()[1].focus();
+ await render(f, [item('a')]);
+ assert.equal(document.activeElement === f.pageTab(2), true);
+ for (const index of [0, 1, 2]) f.pageTab(index).remove();
+ f.buttons()[0].focus();
+ await render(f, []);
+ assert.equal(
+ document.activeElement === document.getElementById('cockpit-signal-toggle'),
+ true,
+ );
});
diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js
index ed32ac495..85db7ecf0 100644
--- a/src/ui/applicationShell.js
+++ b/src/ui/applicationShell.js
@@ -386,6 +386,8 @@ export class StyleManager extends ShellFacade {
useCockpitStore(applicationPinia()).publishRoute(view),
vision: (view) =>
useCockpitStore(applicationPinia()).publishVision(view),
+ signals: (rows) =>
+ useCockpitStore(applicationPinia()).publishSignals(rows),
},
_layoutRightPanels: (...args) => this._layoutRightPanels(...args),
_setCockpitVision: (...args) => this._setCockpitVision(...args),
diff --git a/src/ui/cockpitBriefing.test.mjs b/src/ui/cockpitBriefing.test.mjs
index 9bb2b76ba..8494823ce 100644
--- a/src/ui/cockpitBriefing.test.mjs
+++ b/src/ui/cockpitBriefing.test.mjs
@@ -240,22 +240,6 @@ test('an unchanged position publishes nothing new', (t) => {
assert.equal(views.length, 1);
});
-test('re-renders leave the signal rows the controller owns', async (t) => {
- const { $, text, owner } = fixture(t);
- const list = $('#cockpit-signal-list');
- const row = document.createElement('li');
- row.textContent = 'N123AB';
- list.append(row);
- $('#cockpit-signal-stream').hidden = false;
- showBriefPage.call(owner, 2);
- renderRegionalBriefStatus.call(owner, 'loading', INFO);
- await nextTick();
- assert.equal($('#cockpit-signal-list') === list, true);
- assert.equal(list.firstElementChild === row, true);
- assert.equal(text('#cockpit-signal-list'), 'N123AB');
- assert.equal($('#cockpit-signal-stream').hidden, false);
-});
-
test('the route card sits in the briefing card, hidden until a route', (t) => {
const { $ } = fixture(t);
const route = $('#cockpit-signal-stream > #cockpit-route');
diff --git a/src/ui/cockpitController.js b/src/ui/cockpitController.js
index b9f78a18e..d0eae03b7 100644
--- a/src/ui/cockpitController.js
+++ b/src/ui/cockpitController.js
@@ -41,7 +41,7 @@ import {
} from './cockpitBriefing.js';
import {
handleSignalClick,
- renderCockpitSignals,
+ publishCockpitSignals,
pushCockpitSignal,
updateCockpitSignals,
} from './cockpitSignals.js';
@@ -139,6 +139,7 @@ export class CockpitViewController {
this.publishInstruments = publish.instruments ?? (() => {});
this.publishRoute = publish.route ?? (() => {});
this.publishVision = publish.vision ?? (() => {});
+ this.publishSignals = publish.signals ?? (() => {});
this.instruments = createInstrumentState();
this.routeView = createRouteState();
this.routeSignature = null;
@@ -332,8 +333,8 @@ export class CockpitViewController {
renderRegionalBrief(payload, info) {
return renderRegionalBrief.call(this, payload, info);
}
- renderCockpitSignals() {
- return renderCockpitSignals.call(this);
+ publishCockpitSignals() {
+ return publishCockpitSignals.call(this);
}
pushCockpitSignal(key, tone, title, detail, target) {
return pushCockpitSignal.call(this, key, tone, title, detail, target);
diff --git a/src/ui/cockpitSignals.js b/src/ui/cockpitSignals.js
index cbac4cd03..78354a699 100644
--- a/src/ui/cockpitSignals.js
+++ b/src/ui/cockpitSignals.js
@@ -1,109 +1,43 @@
-/** Reconcile actionable signal rows without disturbing native keyboard focus. */
+/** The Cockpit signal rows: which contacts and statuses they show, and their selection. */
import { t, tn } from '../i18n/index.js';
import { formatCockpitDistance } from './cockpitPresentation.js';
-export function renderCockpitSignals() {
- if (!this.signalList) return;
- const existing = [...this.signalList.children];
- const focusedEntry = existing.find((entry) =>
- entry.contains(document.activeElement),
+/**
+ * Publish the signal rows. A row's key includes its action target, so a
+ * reused status key never turns a focused flight button into a different
+ * selection; repeats of a key are numbered so each keeps its own row.
+ */
+export function publishCockpitSignals() {
+ const seen = new Map();
+ this.publishSignals(
+ this.signalItems.map((item) => {
+ const identity = JSON.stringify([
+ item.key,
+ Boolean(item.target),
+ item.target?.layerId || '',
+ String(item.target?.id ?? ''),
+ ]);
+ const repeat = seen.get(identity) ?? 0;
+ seen.set(identity, repeat + 1);
+ return {
+ key: repeat ? `${identity}#${repeat}` : identity,
+ className: item.target ? `${item.tone} actionable` : item.tone,
+ time: new Date(item.timestamp).toISOString().slice(11, 19) + 'Z',
+ title: item.title,
+ detail: item.detail,
+ target: item.target && {
+ layerId: item.target.layerId,
+ id: item.target.id,
+ label: t('Select flight {title}', { title: item.title }),
+ },
+ };
+ }),
);
- 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.dataset.signalLayer = item.target.layerId;
- heading.dataset.signalId = item.target.id;
- const label = t('Select flight {title}', { title: item.title });
- if (heading.getAttribute('aria-label') !== label)
- heading.setAttribute('aria-label', label);
- setText(heading.children[0], item.title);
- } else {
- setText(heading, item.title);
- }
- 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();
}
export function pushCockpitSignal(key, tone, title, detail, target = null) {
- if (!this.signalList || !title || !detail) return;
+ if (!title || !detail) return;
const signature = `${title}|${detail}|${target?.layerId || ''}|${target?.id || ''}`;
if (this.signalSignatures.get(key) === signature) return;
this.signalSignatures.set(key, signature);
@@ -116,7 +50,7 @@ export function pushCockpitSignal(key, tone, title, detail, target = null) {
timestamp: Date.now(),
});
this.signalItems = this.signalItems.slice(0, 5);
- this.renderCockpitSignals();
+ this.publishCockpitSignals();
}
export function updateCockpitSignals(snapshot, unknownCount) {
@@ -195,7 +129,7 @@ export function updateCockpitSignals(snapshot, unknownCount) {
}
this.signalItems = nextItems;
this.signalSignatures.clear();
- this.renderCockpitSignals();
+ this.publishCockpitSignals();
}
export function handleSignalClick(event) {
diff --git a/src/ui/rail/focusSafeChildren.js b/src/ui/rail/focusSafeChildren.js
new file mode 100644
index 000000000..48291c63d
--- /dev/null
+++ b/src/ui/rail/focusSafeChildren.js
@@ -0,0 +1,58 @@
+/**
+ * `v-focus-safe-children`: lets a keyed list reorder without dropping keyboard
+ * focus. A browser blurs any element it moves, so Vue moving the row that
+ * holds focus would drop it. This directive turns such a move into moves of
+ * the rows around the focused one, which reach the same order while the
+ * focused row stays put. Before the focused row itself is removed, it calls
+ * the directive's value so focus can go somewhere that stays.
+ */
+const focusedChild = (el, node) =>
+ node.parentNode === el && node.contains(el.ownerDocument.activeElement);
+
+/**
+ * Place `node` before `anchor` (null: at the end) without moving `node`. Only
+ * elements move; the empty text nodes Vue anchors a list with stay put.
+ */
+function moveAround(el, node, anchor) {
+ const insert = (child, before) =>
+ Node.prototype.insertBefore.call(el, child, before);
+ const children = [...el.childNodes];
+ const from = children.indexOf(node);
+ const to = anchor === null ? children.length : children.indexOf(anchor);
+ const elements = (list) =>
+ list.filter((child) => child.nodeType === Node.ELEMENT_NODE);
+ if (to > from) {
+ // The rows between it and its anchor go in front of it.
+ for (const child of elements(children.slice(from + 1, to)))
+ insert(child, node);
+ } else {
+ // The rows from the anchor up to it go behind it, in order.
+ const after = node.nextSibling;
+ for (const child of elements(children.slice(to, from)))
+ insert(child, after);
+ }
+ return node;
+}
+
+export const vFocusSafeChildren = {
+ mounted(el, binding) {
+ el._focusSafeLeave = binding.value;
+ el.insertBefore = function (node, anchor) {
+ if (node !== anchor && focusedChild(el, node))
+ return moveAround(el, node, anchor ?? null);
+ return Node.prototype.insertBefore.call(el, node, anchor);
+ };
+ el.removeChild = function (node) {
+ if (focusedChild(el, node)) el._focusSafeLeave?.();
+ return Node.prototype.removeChild.call(el, node);
+ };
+ },
+ updated(el, binding) {
+ el._focusSafeLeave = binding.value;
+ },
+ unmounted(el) {
+ delete el.insertBefore;
+ delete el.removeChild;
+ delete el._focusSafeLeave;
+ },
+};
diff --git a/src/ui/stores/cockpit.js b/src/ui/stores/cockpit.js
index 53069ec3d..a3c767339 100644
--- a/src/ui/stores/cockpit.js
+++ b/src/ui/stores/cockpit.js
@@ -5,7 +5,8 @@ import { markRaw, shallowRef } from 'vue';
* Cockpit content as its views render it. The Cockpit controller presents
* the briefing card's pages, cycle toggle, regional news and local
* conditions, the Contact card's readouts and weather toggle, the
- * instruments, the flight plan and the vision selector, and publishes them
+ * instruments, the flight plan, the vision selector and the signal rows,
+ * and publishes them
* here; it keeps the controls' listeners.
*/
export const useCockpitStore = defineStore('gev-cockpit', () => {
@@ -49,6 +50,14 @@ export const useCockpitStore = defineStore('gev-cockpit', () => {
vision.value = next && markRaw(next);
}
+ /** The presented signal rows; null until the controller first presents them. */
+ const signals = shallowRef(null);
+
+ /** @param {?object[]} next A fresh presentation of the signal rows. */
+ function publishSignals(next) {
+ signals.value = next && markRaw(next);
+ }
+
return {
brief,
publishBrief,
@@ -60,5 +69,7 @@ export const useCockpitStore = defineStore('gev-cockpit', () => {
publishRoute,
vision,
publishVision,
+ signals,
+ publishSignals,
};
});
diff --git a/src/ui/views/panels/CockpitBriefing.vue b/src/ui/views/panels/CockpitBriefing.vue
index 3edd5afb1..9d4078870 100644
--- a/src/ui/views/panels/CockpitBriefing.vue
+++ b/src/ui/views/panels/CockpitBriefing.vue
@@ -1,15 +1,15 @@
-