feat(ui): render the Cockpit signal rows from a store, keeping focus

The Cockpit controller no longer reconciles the live signal rows by hand.
publishCockpitSignals turns its signal items into rows, keyed by the
signal and its action target as before, with repeats of a key numbered,
and publishes them to the gev-cockpit store; a new CockpitSignalList
component renders them with a keyed v-for inside the briefing card.

Rows reorder several times a second, and a browser blurs any element it
moves. The new v-focus-safe-children directive turns Vue's move of the
row holding focus into moves of the rows around it, reaching the same
order while the focused row stays connected, and before the focused row
is removed it hands focus to the shown briefing page's tab, or the
collapse toggle, as the reconciler did. Selection stays with the
controller's click listener.

The focus test now runs the real components over happy-dom and counts
any move or removal of the focused row as a loss, including across 60
random reorders. The Cockpit QA script publishes and waits a frame
before reading the rows.
This commit is contained in:
Sameh Khamis committed 2026-10-31 16:39:08 -07:00
1 parent a2bca0fb22
commit 58504e370c
12 files changed
+434 -383

No files matched your search

+9 -8
View File
@@ -136,14 +136,15 @@ They contain no application state.
starting from `cockpitBriefPages.js`, `cockpitContextState.js` and starting from `cockpitBriefPages.js`, `cockpitContextState.js` and
`cockpitInstrumentState.js`, which both read; the odometer readouts `cockpitInstrumentState.js`, which both read; the odometer readouts
(`CockpitRollingValue`) keep their throttle and roll in (`CockpitRollingValue`) keep their throttle and roll in
`cockpitRollingValue.js`. The instruments publish on every HUD update `cockpitRollingValue.js`. `CockpitSignalList` renders the live signal
(about 10 Hz); in a dev build their Vue patch costs about 0.5 ms, close rows; `v-focus-safe-children` turns Vue's move of the focused row into
to the DOM writes it replaced. The controls keep the controller's moves of the rows around it, and hands focus to the shown briefing tab
listeners, and the cards' visibility, collapse and layout stay with the before the focused row is removed, so a keyed list reorders several times
controller. The signal rows and the radio controls follow; the signal a second without dropping keyboard focus. The instruments publish on
rows become a every HUD update (about 10 Hz); in a dev build their Vue patch costs
component that keeps their focus-safe reorder, since a keyed Vue list about 0.5 ms, close to the DOM writes it replaced. The controls keep the
would move the focused row and drop focus several times a second). 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. Layers that write into the right rail directly gain a slot instead.
4. **Cleanup.** The build-time template expansion and the attribute 4. **Cleanup.** The build-time template expansion and the attribute
+5
View File
@@ -31,6 +31,7 @@
"src/ui/views/panels/CockpitContext.vue", "src/ui/views/panels/CockpitContext.vue",
"src/ui/views/panels/CockpitRoute.vue", "src/ui/views/panels/CockpitRoute.vue",
"src/ui/views/panels/CockpitRollingValue.vue", "src/ui/views/panels/CockpitRollingValue.vue",
"src/ui/views/panels/CockpitSignalList.vue",
"src/ui/cockpitBriefPages.js", "src/ui/cockpitBriefPages.js",
"src/ui/cockpitContextState.js", "src/ui/cockpitContextState.js",
"src/ui/cockpitInstrumentState.js", "src/ui/cockpitInstrumentState.js",
@@ -75,6 +76,7 @@
"src/ui/stores/display.js", "src/ui/stores/display.js",
"src/ui/rail/rangeValue.js", "src/ui/rail/rangeValue.js",
"src/ui/rail/classToggle.js", "src/ui/rail/classToggle.js",
"src/ui/rail/focusSafeChildren.js",
"src/ui/rail/fieldValue.js" "src/ui/rail/fieldValue.js"
], ],
"external": ["vue", "pinia"] "external": ["vue", "pinia"]
@@ -574,6 +576,7 @@
"src/ui/layerRowText.js", "src/ui/layerRowText.js",
"src/ui/layerPanel/LayerRows.vue", "src/ui/layerPanel/LayerRows.vue",
"src/ui/rail/classToggle.js", "src/ui/rail/classToggle.js",
"src/ui/rail/focusSafeChildren.js",
"src/ui/layerPanel/RowText.vue", "src/ui/layerPanel/RowText.vue",
"src/ui/railTimeline.js", "src/ui/railTimeline.js",
"src/ui/clearLayersControl.js", "src/ui/clearLayersControl.js",
@@ -890,6 +893,7 @@
"src/ui/layerRowText.js", "src/ui/layerRowText.js",
"src/ui/layerPanel/LayerRows.vue", "src/ui/layerPanel/LayerRows.vue",
"src/ui/rail/classToggle.js", "src/ui/rail/classToggle.js",
"src/ui/rail/focusSafeChildren.js",
"src/ui/layerPanel/RowText.vue", "src/ui/layerPanel/RowText.vue",
"src/ui/railTimeline.js", "src/ui/railTimeline.js",
"src/ui/layers.js", "src/ui/layers.js",
@@ -2595,6 +2599,7 @@
"src/ui/layerRowText.js", "src/ui/layerRowText.js",
"src/ui/layerPanel/LayerRows.vue", "src/ui/layerPanel/LayerRows.vue",
"src/ui/rail/classToggle.js", "src/ui/rail/classToggle.js",
"src/ui/rail/focusSafeChildren.js",
"src/ui/layerPanel/RowText.vue", "src/ui/layerPanel/RowText.vue",
"src/ui/railTimeline.js", "src/ui/railTimeline.js",
"src/ui/layers.js", "src/ui/layers.js",
+12 -11
View File
@@ -2168,33 +2168,34 @@ try {
JSON.stringify(contextTransition), JSON.stringify(contextTransition),
); );
// Use the real connected DOM to catch focus loss caused by moving live rows. // 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; 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 originalItems = cockpit.signalItems;
const wasCollapsed = cockpit.signalCollapsed; const wasCollapsed = cockpit.signalCollapsed;
cockpit.setSignalCollapsed(false); cockpit.setSignalCollapsed(false);
const item = (id) => ({ key: `qa-${id}`, tone: 'info', timestamp: Date.now(), const item = (id) => ({ key: `qa-${id}`, tone: 'info', timestamp: Date.now(),
title: `QA ${id}`, detail: 'Keyboard refresh fixture', target: { layerId: 'flights', id } }); title: `QA ${id}`, detail: 'Keyboard refresh fixture', target: { layerId: 'flights', id } });
try { try {
cockpit.signalItems = [item('focus-a'), item('focus-b')]; await show([item('focus-a'), item('focus-b')]);
cockpit.renderCockpitSignals();
const button = cockpit.signalList.querySelector('[data-signal-id="focus-a"]'); const button = cockpit.signalList.querySelector('[data-signal-id="focus-a"]');
button.focus(); button.focus();
const acquired = document.activeElement === button; const acquired = document.activeElement === button;
cockpit.signalItems = [item('focus-c'), item('focus-b'), item('focus-a')]; await show([item('focus-c'), item('focus-b'), item('focus-a')]);
cockpit.renderCockpitSignals();
const retained = document.activeElement === button && button.isConnected; const retained = document.activeElement === button && button.isConnected;
const order = [...cockpit.signalList.querySelectorAll('[data-signal-id]')].map((node) => node.dataset.signalId); const order = [...cockpit.signalList.querySelectorAll('[data-signal-id]')].map((node) => node.dataset.signalId);
cockpit.signalItems = [item('focus-b')]; await show([item('focus-b')]);
cockpit.renderCockpitSignals();
const continuation = cockpit.briefTabs[cockpit.briefPageIndex] || cockpit.signalToggle; const continuation = cockpit.briefTabs[cockpit.briefPageIndex] || cockpit.signalToggle;
const continued = document.activeElement === continuation; const continued = document.activeElement === continuation;
cockpit.signalItems = [item('focus-a'), item('focus-b')]; await show([item('focus-a'), item('focus-b')]);
cockpit.renderCockpitSignals();
return { acquired, retained, order, continued, notReclaimed: document.activeElement === continuation }; return { acquired, retained, order, continued, notReclaimed: document.activeElement === continuation };
} finally { } finally {
cockpit.signalItems = originalItems; await show(originalItems);
cockpit.renderCockpitSignals();
cockpit.setSignalCollapsed(wasCollapsed); cockpit.setSignalCollapsed(wasCollapsed);
} }
}); });
+264 -243
View File
@@ -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 test from 'node:test';
import assert from 'node:assert/strict'; 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 { formatAwarenessLabel } from './data/militaryAwarenessEngine.js';
import { CockpitViewController } from './ui/cockpitController.js'; import { createBriefState } from './ui/cockpitBriefPages.js';
const previousDocument = globalThis.document; import {
afterEach(() => { handleSignalClick,
globalThis.document = previousDocument; 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. * A browser blurs an element it removes or moves. happy-dom only models the
function fixture() { * removal, so count both: any insert or removal of a node that holds focus
const document = { activeElement: null }; * is a loss, and a moved node gives its focus up as a browser's would.
let focusLosses = 0; */
let focusCalls = 0; function trackFocus(t) {
class Node { const counts = { losses: 0, focusCalls: 0 };
constructor(tagName) { const holdsFocus = (node) =>
this.tagName = tagName.toUpperCase(); node.isConnected && node.contains(document.activeElement);
this.children = []; const insertBefore = Node.prototype.insertBefore;
this.parentNode = null; t.mock.method(Node.prototype, 'insertBefore', function (node, anchor) {
this.dataset = {}; const lost = holdsFocus(node);
this.attributes = new Map(); const result = insertBefore.call(this, node, anchor);
this.className = ''; if (lost) {
this._text = ''; counts.losses += 1;
this.classList = { document.activeElement.blur();
add: (...names) => { }
this.className = [ return result;
...new Set([ });
...this.className.split(/\s+/).filter(Boolean), const removeChild = Node.prototype.removeChild;
...names, t.mock.method(Node.prototype, 'removeChild', function (node) {
]), if (holdsFocus(node)) counts.losses += 1;
].join(' '); 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 = [];
// 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),
},
}, },
}; };
} // The controller shows the card on entering Cockpit.
get isConnected() { document.getElementById('cockpit-signal-stream').hidden = false;
return this === document.body || Boolean(this.parentNode?.isConnected); const list = document.getElementById('cockpit-signal-list');
} list.addEventListener('click', (event) =>
get firstElementChild() { handleSignalClick.call(owner, event),
return this.children[0] || null;
}
get nextElementSibling() {
return (
this.parentNode?.children[this.parentNode.children.indexOf(this) + 1] ||
null
); );
} const buttons = () => [...list.querySelectorAll('button')];
get textContent() { // Tab visits the enabled buttons no hidden ancestor removes from view.
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,
);
const selected = [];
controller.services.militaryAwarenessLayer = {
focusTarget: (...args) => selected.push(args),
};
const click = (event) => controller.handleSignalClick(event);
const buttons = () => controller.signalList.querySelectorAll('button');
const tab = () => { const tab = () => {
const nodes = document.body.querySelectorAll('button'); const nodes = [...document.querySelectorAll('button')].filter(
const index = nodes.indexOf(document.activeElement); (node) => !node.disabled && !node.closest('[hidden]'),
nodes[(index + 1) % nodes.length].focus(); );
nodes[(nodes.indexOf(document.activeElement) + 1) % nodes.length].focus();
}; };
const pageTab = (index) =>
document.querySelector(`[data-cockpit-brief-index="${index}"]`);
const signalTab = () => pageTab(0);
return { return {
controller, owner,
document, list,
buttons, buttons,
display, display,
radio, radio,
outside, outside,
selected, selected,
click,
tab, tab,
losses: () => focusLosses, pageTab,
focusCalls: () => focusCalls, signalTab,
store,
losses: () => counts.losses,
focusCalls: () => counts.focusCalls,
}; };
} }
@@ -194,9 +126,14 @@ const item = (id, overrides = {}) => ({
timestamp: 1_700_000_000_000, timestamp: 1_700_000_000_000,
...overrides, ...overrides,
}); });
const render = (f, items) => { const render = async (f, items) => {
f.controller.signalItems = items; f.owner.signalItems = items;
f.controller.renderCockpitSignals(); publishCockpitSignals.call(f.owner);
await nextTick();
};
const refresh = async (f, distanceM) => {
updateCockpitSignals.call(f.owner, snapshot(distanceM), 0);
await nextTick();
}; };
const snapshot = (distanceM = 2000) => ({ const snapshot = (distanceM = 2000) => ({
evaluatedAt: 1_700_000_000_000, 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', () => { test('rows render the signals the controller presents', async (t) => {
const f = fixture(); const f = fixture(t);
f.controller.updateCockpitSignals(snapshot(), 0); 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]; const target = f.buttons()[1];
target.focus(); target.focus();
const calls = f.focusCalls(); const calls = f.focusCalls();
for (let tick = 0; tick < 8; tick += 1) for (let tick = 0; tick < 8; tick += 1) await refresh(f, 2500);
f.controller.updateCockpitSignals(snapshot(2500), 0); assert.equal(f.buttons()[1] === target, true);
assert.equal(f.buttons()[1], target); assert.equal(document.activeElement === target, true);
assert.equal(f.document.activeElement, target);
assert.equal(f.losses(), 0); assert.equal(f.losses(), 0);
assert.equal( assert.equal(
f.focusCalls(), f.focusCalls(),
calls, calls,
'refresh must preserve focus without repeatedly restoring it', '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', () => { test('signal content and accessible name update without replacing the active target', async (t) => {
const f = fixture(); const f = fixture(t);
render(f, [item('a')]); await render(f, [item('a')]);
const target = f.buttons()[0]; const target = f.buttons()[0];
target.focus(); target.focus();
render(f, [ await render(f, [
item('a', { item('a', {
title: 'RENAMED', title: 'RENAMED',
tone: 'track', tone: 'track',
@@ -242,21 +209,18 @@ test('signal content and accessible name update without replacing the active tar
timestamp: 1_700_000_001_000, 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.equal(target.getAttribute('aria-label'), 'Select flight RENAMED');
assert.match(target.textContent, /RENAMED/); assert.match(target.textContent, /RENAMED/);
assert.match(f.controller.signalList.textContent, /CURRENT/); assert.match(f.list.textContent, /CURRENT/);
assert.equal( assert.equal(f.list.children[0].className, 'track actionable');
f.controller.signalList.children[0].className, assert.equal(document.activeElement === target, true);
'track actionable',
);
assert.equal(f.document.activeElement, target);
assert.equal(f.losses(), 0); assert.equal(f.losses(), 0);
}); });
test('signal ranking changes move surrounding rows without disconnecting the focused row', () => { test('signal ranking changes move surrounding rows without disconnecting the focused row', async (t) => {
const f = fixture(); const f = fixture(t);
render( await render(
f, f,
['a', 'b', 'c', 'd'].map((id) => item(id)), ['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'], ['d', 'c', 'b', 'a'],
['b', 'c', 'd', 'a'], ['b', 'c', 'd', 'a'],
['a', 'd', 'c', 'b'], ['a', 'd', 'c', 'b'],
['c', 'a', 'b', 'd'],
['b', 'a', 'd', 'c'],
]) { ]) {
render( await render(
f, f,
order.map((id) => item(id)), 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), f.buttons().map((button) => button.dataset.signalId),
order, order,
); );
assert.equal(f.document.activeElement, target); assert.equal(document.activeElement === target, true);
assert.equal(f.losses(), 0); assert.equal(f.losses(), 0);
} }
assert.equal(f.focusCalls(), calls); assert.equal(f.focusCalls(), calls);
}); });
test('a new pushed signal preserves existing focus and delegated selection reads the current target', () => { test('a new pushed signal preserves existing focus and delegated selection reads the current target', async (t) => {
const f = fixture(); const f = fixture(t);
render(f, [item('a'), item('b')]); await render(f, [item('a'), item('b')]);
const target = f.buttons()[1]; const target = f.buttons()[1];
target.focus(); target.focus();
f.controller.pushCockpitSignal( pushCockpitSignal.call(
f.owner,
'status', 'status',
'warning', 'warning',
'INPUT UNKNOWN', 'INPUT UNKNOWN',
'SOURCE UNAVAILABLE', '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); assert.equal(f.losses(), 0);
const event = { const click = new MouseEvent('click', { bubbles: true, cancelable: true });
target: target.children[0], target.querySelector('.cockpit-signal-target-label').dispatchEvent(click);
prevented: false, assert.equal(click.defaultPrevented, true);
preventDefault() {
this.prevented = true;
},
};
f.click(event);
assert.equal(event.prevented, true);
assert.deepEqual(f.selected, [['flights', 'b', { origin: 'user' }]]); assert.deepEqual(f.selected, [['flights', 'b', { origin: 'user' }]]);
}); });
test('removing the focused identity uses the existing briefing tab once and permits forward traversal', () => { test('removing the focused identity uses the shown briefing tab once and permits forward traversal', async (t) => {
const f = fixture(); const f = fixture(t);
render(f, [item('a'), item('b')]); await render(f, [item('a'), item('b')]);
const removed = f.buttons()[1]; const removed = f.buttons()[1];
removed.focus(); removed.focus();
const calls = f.focusCalls(); const calls = f.focusCalls();
render(f, [item('a')]); await render(f, [item('a')]);
assert.equal(removed.isConnected, false); 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.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(document.activeElement === f.pageTab(1), true);
f.tab(); while (document.activeElement !== f.display) f.tab();
assert.equal(f.document.activeElement, f.display); await render(f, [item('a', { detail: 'UPDATED' })]);
render(f, [item('a', { detail: 'UPDATED' })]);
assert.equal( assert.equal(
f.document.activeElement, document.activeElement === f.display,
f.display, true,
'later refresh must not pull focus back', 'later refresh must not pull focus back',
); );
f.tab(); 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', () => { test('a changed target cannot reuse a focused action for another flight', async (t) => {
const f = fixture(); const f = fixture(t);
render(f, [item('a', { key: 'shared-status-key' })]); await render(f, [item('a', { key: 'shared-status-key' })]);
const removed = f.buttons()[0]; const removed = f.buttons()[0];
removed.focus(); removed.focus();
render(f, [ await render(f, [
item('b', { item('b', {
key: 'shared-status-key', key: 'shared-status-key',
target: { layerId: 'military', id: 'b' }, target: { layerId: 'military', id: 'b' },
}), }),
]); ]);
const target = f.buttons()[0]; const target = f.buttons()[0];
assert.notEqual(target, removed); assert.equal(target === removed, false);
assert.equal(removed.isConnected, false); assert.equal(removed.isConnected, false);
assert.equal(f.document.activeElement, f.controller.briefTabs[0]); assert.equal(document.activeElement === f.signalTab(), true);
f.click({ target, preventDefault() {} }); target.click();
assert.deepEqual(f.selected, [['military', 'b', { origin: 'user' }]]); assert.deepEqual(f.selected, [['military', 'b', { origin: 'user' }]]);
}); });
test('empty, informational and duplicate-key rows have no stale or aliased actions', () => { test('empty, informational and duplicate-key rows have no stale or aliased actions', async (t) => {
const f = fixture(); const f = fixture(t);
render(f, [item('a'), item('a')]); await render(f, [item('a'), item('a')]);
const [first, second] = f.buttons(); const [first, second] = f.buttons();
assert.notEqual(first, second); assert.equal(first === second, false);
second.focus(); second.focus();
render(f, [item('a'), item('a')]); await render(f, [item('a'), item('a')]);
assert.deepEqual(f.buttons(), [first, second]); const again = f.buttons();
assert.equal(f.document.activeElement, second); assert.equal(again[0] === first && again[1] === second, true);
render(f, [item('status', { target: null, title: 'UNKNOWN' })]); assert.equal(document.activeElement === second, true);
await render(f, [item('status', { target: null, title: 'UNKNOWN' })]);
assert.equal(f.buttons().length, 0); assert.equal(f.buttons().length, 0);
assert.match(f.controller.signalList.textContent, /UNKNOWN/); assert.match(f.list.textContent, /UNKNOWN/);
assert.equal(f.document.activeElement, f.controller.briefTabs[0]); assert.equal(document.activeElement === f.signalTab(), true);
f.outside.focus(); f.outside.focus();
render(f, []); await render(f, []);
assert.equal(f.controller.signalList.children.length, 0); assert.equal(f.list.children.length, 0);
render(f, [item('a')]); await render(f, [item('a')]);
assert.notEqual(f.buttons()[0], first); assert.equal(f.buttons()[0] === first, false);
assert.equal(f.document.activeElement, f.outside); 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,
);
}); });
+2
View File
@@ -386,6 +386,8 @@ export class StyleManager extends ShellFacade {
useCockpitStore(applicationPinia()).publishRoute(view), useCockpitStore(applicationPinia()).publishRoute(view),
vision: (view) => vision: (view) =>
useCockpitStore(applicationPinia()).publishVision(view), useCockpitStore(applicationPinia()).publishVision(view),
signals: (rows) =>
useCockpitStore(applicationPinia()).publishSignals(rows),
}, },
_layoutRightPanels: (...args) => this._layoutRightPanels(...args), _layoutRightPanels: (...args) => this._layoutRightPanels(...args),
_setCockpitVision: (...args) => this._setCockpitVision(...args), _setCockpitVision: (...args) => this._setCockpitVision(...args),
-16
View File
@@ -240,22 +240,6 @@ test('an unchanged position publishes nothing new', (t) => {
assert.equal(views.length, 1); 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) => { test('the route card sits in the briefing card, hidden until a route', (t) => {
const { $ } = fixture(t); const { $ } = fixture(t);
const route = $('#cockpit-signal-stream > #cockpit-route'); const route = $('#cockpit-signal-stream > #cockpit-route');
+4 -3
View File
@@ -41,7 +41,7 @@ import {
} from './cockpitBriefing.js'; } from './cockpitBriefing.js';
import { import {
handleSignalClick, handleSignalClick,
renderCockpitSignals, publishCockpitSignals,
pushCockpitSignal, pushCockpitSignal,
updateCockpitSignals, updateCockpitSignals,
} from './cockpitSignals.js'; } from './cockpitSignals.js';
@@ -139,6 +139,7 @@ export class CockpitViewController {
this.publishInstruments = publish.instruments ?? (() => {}); this.publishInstruments = publish.instruments ?? (() => {});
this.publishRoute = publish.route ?? (() => {}); this.publishRoute = publish.route ?? (() => {});
this.publishVision = publish.vision ?? (() => {}); this.publishVision = publish.vision ?? (() => {});
this.publishSignals = publish.signals ?? (() => {});
this.instruments = createInstrumentState(); this.instruments = createInstrumentState();
this.routeView = createRouteState(); this.routeView = createRouteState();
this.routeSignature = null; this.routeSignature = null;
@@ -332,8 +333,8 @@ export class CockpitViewController {
renderRegionalBrief(payload, info) { renderRegionalBrief(payload, info) {
return renderRegionalBrief.call(this, payload, info); return renderRegionalBrief.call(this, payload, info);
} }
renderCockpitSignals() { publishCockpitSignals() {
return renderCockpitSignals.call(this); return publishCockpitSignals.call(this);
} }
pushCockpitSignal(key, tone, title, detail, target) { pushCockpitSignal(key, tone, title, detail, target) {
return pushCockpitSignal.call(this, key, tone, title, detail, target); return pushCockpitSignal.call(this, key, tone, title, detail, target);
+30 -96
View File
@@ -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 { t, tn } from '../i18n/index.js';
import { formatCockpitDistance } from './cockpitPresentation.js'; import { formatCockpitDistance } from './cockpitPresentation.js';
export function renderCockpitSignals() { /**
if (!this.signalList) return; * Publish the signal rows. A row's key includes its action target, so a
const existing = [...this.signalList.children]; * reused status key never turns a focused flight button into a different
const focusedEntry = existing.find((entry) => * selection; repeats of a key are numbered so each keeps its own row.
entry.contains(document.activeElement), */
); export function publishCockpitSignals() {
const entriesByKey = new Map(); const seen = new Map();
for (const entry of existing) { this.publishSignals(
const key = entry.dataset.signalKey; this.signalItems.map((item) => {
if (!entriesByKey.has(key)) entriesByKey.set(key, []); const identity = JSON.stringify([
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, item.key,
Boolean(item.target), Boolean(item.target),
item.target?.layerId || '', item.target?.layerId || '',
String(item.target?.id ?? ''), String(item.target?.id ?? ''),
]); ]);
let entry = entriesByKey.get(key)?.shift(); const repeat = seen.get(identity) ?? 0;
if (!entry) { seen.set(identity, repeat + 1);
entry = document.createElement('li'); return {
entry.dataset.signalKey = key; key: repeat ? `${identity}#${repeat}` : identity,
const time = document.createElement('time'); className: item.target ? `${item.tone} actionable` : item.tone,
const body = document.createElement('div'); time: new Date(item.timestamp).toISOString().slice(11, 19) + 'Z',
const heading = document.createElement(item.target ? 'button' : 'strong'); title: item.title,
if (item.target) { detail: item.detail,
heading.type = 'button'; target: item.target && {
heading.className = 'cockpit-signal-target'; layerId: item.target.layerId,
const label = document.createElement('span'); id: item.target.id,
label.className = 'cockpit-signal-target-label'; label: t('Select flight {title}', { title: 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);
}
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(); this.scheduleContextLayout();
} }
export function pushCockpitSignal(key, tone, title, detail, target = null) { 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 || ''}`; const signature = `${title}|${detail}|${target?.layerId || ''}|${target?.id || ''}`;
if (this.signalSignatures.get(key) === signature) return; if (this.signalSignatures.get(key) === signature) return;
this.signalSignatures.set(key, signature); this.signalSignatures.set(key, signature);
@@ -116,7 +50,7 @@ export function pushCockpitSignal(key, tone, title, detail, target = null) {
timestamp: Date.now(), timestamp: Date.now(),
}); });
this.signalItems = this.signalItems.slice(0, 5); this.signalItems = this.signalItems.slice(0, 5);
this.renderCockpitSignals(); this.publishCockpitSignals();
} }
export function updateCockpitSignals(snapshot, unknownCount) { export function updateCockpitSignals(snapshot, unknownCount) {
@@ -195,7 +129,7 @@ export function updateCockpitSignals(snapshot, unknownCount) {
} }
this.signalItems = nextItems; this.signalItems = nextItems;
this.signalSignatures.clear(); this.signalSignatures.clear();
this.renderCockpitSignals(); this.publishCockpitSignals();
} }
export function handleSignalClick(event) { export function handleSignalClick(event) {
+58
View File
@@ -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;
},
};
+12 -1
View File
@@ -5,7 +5,8 @@ import { markRaw, shallowRef } from 'vue';
* Cockpit content as its views render it. The Cockpit controller presents * Cockpit content as its views render it. The Cockpit controller presents
* the briefing card's pages, cycle toggle, regional news and local * the briefing card's pages, cycle toggle, regional news and local
* conditions, the Contact card's readouts and weather toggle, the * 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. * here; it keeps the controls' listeners.
*/ */
export const useCockpitStore = defineStore('gev-cockpit', () => { export const useCockpitStore = defineStore('gev-cockpit', () => {
@@ -49,6 +50,14 @@ export const useCockpitStore = defineStore('gev-cockpit', () => {
vision.value = next && markRaw(next); 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 { return {
brief, brief,
publishBrief, publishBrief,
@@ -60,5 +69,7 @@ export const useCockpitStore = defineStore('gev-cockpit', () => {
publishRoute, publishRoute,
vision, vision,
publishVision, publishVision,
signals,
publishSignals,
}; };
}); });
+16 -5
View File
@@ -1,15 +1,15 @@
<script setup> <script setup>
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { computed } from 'vue'; import { computed, useTemplateRef } from 'vue';
import { createBriefState } from '../../cockpitBriefPages.js'; import { createBriefState } from '../../cockpitBriefPages.js';
import { useCockpitStore } from '../../stores/cockpit.js'; import { useCockpitStore } from '../../stores/cockpit.js';
import CockpitRoute from './CockpitRoute.vue'; import CockpitRoute from './CockpitRoute.vue';
import CockpitSignalList from './CockpitSignalList.vue';
// The Cockpit briefing card. The Cockpit controller presents its pages, // The Cockpit briefing card. The Cockpit controller presents its pages,
// cycle toggle, regional news and local conditions; until it does, the card // cycle toggle, regional news and local conditions; until it does, the card
// shows the state it starts in. The controls keep the controller's // shows the state it starts in. The controls keep the controller's
// listeners. The route card renders itself; the signal list keeps its // listeners. The route card and the signal rows render themselves.
// owner's bindings, so nothing here binds it.
const { brief } = storeToRefs(useCockpitStore()); const { brief } = storeToRefs(useCockpitStore());
const initial = createBriefState(); const initial = createBriefState();
const b = computed(() => brief.value ?? initial); const b = computed(() => brief.value ?? initial);
@@ -18,10 +18,21 @@ const streamState = computed(() => ({
'data-brief-page': brief.value?.pageId, 'data-brief-page': brief.value?.pageId,
'data-regional-status': brief.value?.regionalStatus || undefined, 'data-regional-status': brief.value?.regionalStatus || undefined,
})); }));
// A departed contact cannot stay selectable. Its row hands focus to the
// shown page's tab, which stays, so keyboard traversal carries on from the
// card's footer instead of the top of the page.
const stream = useTemplateRef('stream');
function leaveSignals() {
const card = stream.value;
(
card?.querySelector(`[data-cockpit-brief-index="${b.value.pageIndex}"]`) ??
card?.querySelector('#cockpit-signal-toggle')
)?.focus({ preventScroll: true });
}
</script> </script>
<template> <template>
<aside id="cockpit-signal-stream" class="cockpit-signal-window" aria-label="Cockpit briefing carousel" hidden data-i18n-aria-label v-bind="streamState"> <aside id="cockpit-signal-stream" class="cockpit-signal-window" aria-label="Cockpit briefing carousel" hidden data-i18n-aria-label v-bind="streamState" ref="stream">
<CockpitRoute /> <CockpitRoute />
<header class="cockpit-signal-header"> <header class="cockpit-signal-header">
<div class="cockpit-brief-heading"> <div class="cockpit-brief-heading">
@@ -44,7 +55,7 @@ const streamState = computed(() => ({
</header> </header>
<div class="cockpit-brief-stage"> <div class="cockpit-brief-stage">
<section data-cockpit-brief-page="signals" aria-label="Live signals" data-i18n-aria-label :hidden="b.pageId !== 'signals'"> <section data-cockpit-brief-page="signals" aria-label="Live signals" data-i18n-aria-label :hidden="b.pageId !== 'signals'">
<ol id="cockpit-signal-list" class="cockpit-signal-list" aria-live="polite"></ol> <CockpitSignalList :leave="leaveSignals" />
</section> </section>
<section data-cockpit-brief-page="news" aria-label="Latest regional news" data-i18n-aria-label :hidden="b.pageId !== 'news'"> <section data-cockpit-brief-page="news" aria-label="Latest regional news" data-i18n-aria-label :hidden="b.pageId !== 'news'">
<div id="cockpit-news-status" class="cockpit-brief-status" :hidden="b.news.statusHidden" :data-state="b.news.state ?? undefined">{{ b.news.status }}</div> <div id="cockpit-news-status" class="cockpit-brief-status" :hidden="b.news.statusHidden" :data-state="b.news.state ?? undefined">{{ b.news.status }}</div>
+22
View File
@@ -0,0 +1,22 @@
<script setup>
import { storeToRefs } from 'pinia';
import { computed } from 'vue';
import { vFocusSafeChildren } from '../../rail/focusSafeChildren.js';
import { useCockpitStore } from '../../stores/cockpit.js';
// The Cockpit's live signal rows. The Cockpit controller presents them;
// until it does, the list is empty. Rows reorder several times a second,
// so the list keeps keyboard focus on its row while the others move, and
// moves focus to `leave` before the focused row goes. Selection stays with
// the controller's click listener. The template is one line: templates keep
// whitespace.
defineProps({
leave: { type: Function, required: true },
});
const { signals } = storeToRefs(useCockpitStore());
const rows = computed(() => signals.value ?? []);
</script>
<template>
<ol id="cockpit-signal-list" v-focus-safe-children="leave" class="cockpit-signal-list" aria-live="polite"><li v-for="row in rows" :key="row.key" :class="row.className" :data-signal-key="row.key"><time>{{ row.time }}</time><div><button v-if="row.target" type="button" class="cockpit-signal-target" :data-signal-layer="row.target.layerId" :data-signal-id="row.target.id" :aria-label="row.target.label"><span class="cockpit-signal-target-label">{{ row.title }}</span><span class="cockpit-signal-target-rule" aria-hidden="true"></span><span class="material-symbols-outlined cockpit-signal-target-chevron" aria-hidden="true">chevron_right</span></button><strong v-else>{{ row.title }}</strong><span>{{ row.detail }}</span></div></li></ol>
</template>