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 @@