From 58504e370c475d440b990d635222d72731ef0854 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 31 Oct 2026 16:39:08 -0700 Subject: [PATCH] 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. --- docs/VUE-MIGRATION.md | 17 +- scripts/package-boundaries.json | 5 + scripts/qa-cockpit-utility.mjs | 23 +- src/cockpitSignalFocus.test.mjs | 507 +++++++++++----------- src/ui/applicationShell.js | 2 + src/ui/cockpitBriefing.test.mjs | 16 - src/ui/cockpitController.js | 7 +- src/ui/cockpitSignals.js | 134 ++---- src/ui/rail/focusSafeChildren.js | 58 +++ src/ui/stores/cockpit.js | 13 +- src/ui/views/panels/CockpitBriefing.vue | 21 +- src/ui/views/panels/CockpitSignalList.vue | 22 + 12 files changed, 438 insertions(+), 387 deletions(-) create mode 100644 src/ui/rail/focusSafeChildren.js create mode 100644 src/ui/views/panels/CockpitSignalList.vue 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 @@