mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
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:
1 parent
a2bca0fb22
commit
58504e370c
12 files changed
+438
-387
No files matched your search
@@ -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),
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
+34
-100
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
@@ -1,15 +1,15 @@
|
||||
<script setup>
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { computed } from 'vue';
|
||||
import { computed, useTemplateRef } from 'vue';
|
||||
import { createBriefState } from '../../cockpitBriefPages.js';
|
||||
import { useCockpitStore } from '../../stores/cockpit.js';
|
||||
import CockpitRoute from './CockpitRoute.vue';
|
||||
import CockpitSignalList from './CockpitSignalList.vue';
|
||||
|
||||
// The Cockpit briefing card. The Cockpit controller presents its pages,
|
||||
// cycle toggle, regional news and local conditions; until it does, the card
|
||||
// shows the state it starts in. The controls keep the controller's
|
||||
// listeners. The route card renders itself; the signal list keeps its
|
||||
// owner's bindings, so nothing here binds it.
|
||||
// listeners. The route card and the signal rows render themselves.
|
||||
const { brief } = storeToRefs(useCockpitStore());
|
||||
const initial = createBriefState();
|
||||
const b = computed(() => brief.value ?? initial);
|
||||
@@ -18,10 +18,21 @@ const streamState = computed(() => ({
|
||||
'data-brief-page': brief.value?.pageId,
|
||||
'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>
|
||||
|
||||
<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 />
|
||||
<header class="cockpit-signal-header">
|
||||
<div class="cockpit-brief-heading">
|
||||
@@ -44,7 +55,7 @@ const streamState = computed(() => ({
|
||||
</header>
|
||||
<div class="cockpit-brief-stage">
|
||||
<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 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>
|
||||
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user