mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
feat(ui): render the Contacts panel from the awareness layer's published view
The awareness layer presents its panel as a plain view and publishes it through subscribePanel() instead of writing markup into #military-awareness-panel. AwarenessPanel renders it from the gev-context store, calls the layer's navigation, and keeps focus on the same control by identity across live repaints, continuing at the note when that control leaves or is disabled.
This commit is contained in:
1 parent
749bfd5cd4
commit
1e4df63efe
16 files changed
+582
-380
No files matched your search
@@ -973,7 +973,8 @@ export class StyleManager extends ShellFacade {
|
||||
}
|
||||
|
||||
_initGlobalContextPanel() {
|
||||
const { radioLayer, militaryInstallationsLayer } = this.services;
|
||||
const { radioLayer, militaryInstallationsLayer, militaryAwarenessLayer } =
|
||||
this.services;
|
||||
const contextStore = useContextStore(applicationPinia());
|
||||
this._contextControls = new ContextControls({
|
||||
elements: {
|
||||
@@ -999,6 +1000,14 @@ export class StyleManager extends ShellFacade {
|
||||
},
|
||||
},
|
||||
});
|
||||
// The Contacts layer publishes its panel; the view calls its navigation.
|
||||
contextStore.connectAwareness(militaryAwarenessLayer);
|
||||
// A panel left by an earlier shell must not outlive its layer.
|
||||
this._awarenessPanelUnsubscribe?.();
|
||||
contextStore.publishAwareness(null);
|
||||
this._awarenessPanelUnsubscribe = militaryAwarenessLayer?.subscribePanel?.(
|
||||
(view) => contextStore.publishAwareness(view),
|
||||
);
|
||||
}
|
||||
|
||||
/** Wire the independent Radio companion controls. */
|
||||
@@ -1757,6 +1766,9 @@ export class StyleManager extends ShellFacade {
|
||||
this._streetLevelControls?.destroy();
|
||||
this._radioControls?.destroy();
|
||||
this._localSdrControls?.destroy();
|
||||
this._awarenessPanelUnsubscribe?.();
|
||||
this._awarenessPanelUnsubscribe = null;
|
||||
useContextStore(applicationPinia()).connectAwareness(null);
|
||||
this._cockpitCoordinator.stop();
|
||||
this._visualSettings.stop();
|
||||
this.shareLinkManager?.destroy();
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
// src/ui/awarenessPanel.test.mjs — the Contacts panel renders what the
|
||||
// Contacts layer publishes (`subscribePanel()`), over happy-dom.
|
||||
import '../testSupport/registerDom.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createPinia } from 'pinia';
|
||||
import { createApp, nextTick } from 'vue';
|
||||
import { useContextStore } from './stores/context.js';
|
||||
import AwarenessPanel from './views/panels/AwarenessPanel.vue';
|
||||
|
||||
function fixture(t) {
|
||||
document.body.replaceChildren();
|
||||
const host = document.body.appendChild(document.createElement('div'));
|
||||
const pinia = createPinia();
|
||||
const app = createApp(AwarenessPanel).use(pinia);
|
||||
app.mount(host);
|
||||
t.after(() => app.unmount());
|
||||
const store = useContextStore(pinia);
|
||||
const calls = [];
|
||||
store.connectAwareness({
|
||||
navigatePrevious: (options) => calls.push(['previous', options]),
|
||||
focusCurrent: (options) => calls.push(['focus', options]),
|
||||
navigateNext: (options) => calls.push(['next', options]),
|
||||
focusTarget: (layer, id, options) =>
|
||||
calls.push([`target:${layer}:${id}`, options]),
|
||||
});
|
||||
const panel = () => document.getElementById('military-awareness-panel');
|
||||
const publish = async (view) => {
|
||||
store.publishAwareness(view);
|
||||
await nextTick();
|
||||
};
|
||||
return { panel, publish, calls };
|
||||
}
|
||||
|
||||
const target = (layer, id, label, extra = {}) => ({
|
||||
key: `target:${layer}:${id}`,
|
||||
layer,
|
||||
targetId: id,
|
||||
label,
|
||||
distance: '4.6 km',
|
||||
ariaLabel: `Focus ${label}`,
|
||||
names: null,
|
||||
...extra,
|
||||
});
|
||||
|
||||
const results = ({ nearest = [], previousDisabled = true } = {}) => ({
|
||||
kind: 'results',
|
||||
subject: 'N123AB · 250 km FLIGHT / VESSEL WINDOW',
|
||||
subjectNames: null,
|
||||
navigation: {
|
||||
label: 'Global Context navigation',
|
||||
previous: {
|
||||
text: 'PREVIOUS',
|
||||
title: 'Previous',
|
||||
disabled: previousDisabled,
|
||||
},
|
||||
focus: { text: 'FOCUS' },
|
||||
next: { text: 'NEXT', title: 'Next', disabled: false },
|
||||
},
|
||||
rows: [
|
||||
{
|
||||
id: 'flights',
|
||||
relationship: 'observed',
|
||||
label: 'Flights',
|
||||
count: '2',
|
||||
detail: 'ADS-B · CURRENT VIEWPORT ONLY · nearest',
|
||||
nearest,
|
||||
},
|
||||
],
|
||||
note: 'Open-source mapped/observed context.',
|
||||
});
|
||||
|
||||
test('before the layer publishes, the panel shows its initial markup', (t) => {
|
||||
const { panel } = fixture(t);
|
||||
const standby = panel().querySelector('.military-awareness-standby');
|
||||
assert.equal(
|
||||
standby.querySelector('strong').textContent,
|
||||
'CONTACTS CONTEXT OFF',
|
||||
);
|
||||
assert.equal(standby.querySelector('strong').hasAttribute('data-i18n'), true);
|
||||
});
|
||||
|
||||
test('a standby view replaces the initial copy', async (t) => {
|
||||
const { panel, publish } = fixture(t);
|
||||
await publish({
|
||||
kind: 'standby',
|
||||
title: 'CONTEXT READY',
|
||||
text: 'SELECT A FLIGHT',
|
||||
});
|
||||
const standby = panel().querySelector('.military-awareness-standby');
|
||||
assert.equal(standby.querySelector('strong').textContent, 'CONTEXT READY');
|
||||
assert.equal(standby.querySelector('span').textContent, 'SELECT A FLIGHT');
|
||||
await publish({ kind: 'empty' });
|
||||
assert.equal(panel().children.length, 0);
|
||||
});
|
||||
|
||||
test('results render the rows, controls and names as text, and buttons call the layer', async (t) => {
|
||||
const { panel, publish, calls } = fixture(t);
|
||||
const view = results({
|
||||
nearest: [
|
||||
target('flights', 'abc123', 'ALPHA', {
|
||||
names: {
|
||||
summary: 'Named areas (2)',
|
||||
names: ['Area one', '<Area two>'],
|
||||
},
|
||||
}),
|
||||
{
|
||||
key: 'unavailable:1',
|
||||
label: '—',
|
||||
distance: '9 km',
|
||||
ariaLabel: 'Unavailable',
|
||||
},
|
||||
],
|
||||
});
|
||||
await publish(view);
|
||||
const root = panel();
|
||||
assert.equal(
|
||||
root.querySelector('.military-awareness-subject').textContent,
|
||||
view.subject,
|
||||
);
|
||||
const row = root.querySelector('.military-awareness-row');
|
||||
assert.equal(row.className, 'military-awareness-row observed');
|
||||
assert.equal(row.querySelector('b').getAttribute('aria-live'), 'polite');
|
||||
assert.equal(row.querySelector('b').textContent, '2');
|
||||
assert.equal(row.querySelector('small').textContent, view.rows[0].detail);
|
||||
const alpha = row.querySelector('button[data-awareness-id="abc123"]');
|
||||
assert.equal(alpha.dataset.awarenessLayer, 'flights');
|
||||
assert.equal(alpha.getAttribute('aria-label'), 'Focus ALPHA');
|
||||
assert.equal(alpha.textContent, 'ALPHA 4.6 km');
|
||||
const names = row.querySelector('.military-awareness-names div');
|
||||
assert.equal(
|
||||
names.innerHTML.replace(/<!--[^>]*-->/g, ''),
|
||||
'Area one<br><Area two>',
|
||||
);
|
||||
const unavailable = row.querySelector(
|
||||
'span.military-awareness-target.unavailable',
|
||||
);
|
||||
assert.equal(unavailable.getAttribute('aria-label'), 'Unavailable');
|
||||
|
||||
const [previous, focus, next] = root.querySelectorAll(
|
||||
'[data-awareness-action]',
|
||||
);
|
||||
assert.equal(previous.disabled, true);
|
||||
assert.equal(next.title, 'Next');
|
||||
focus.click();
|
||||
next.click();
|
||||
alpha.click();
|
||||
const user = { origin: 'user' };
|
||||
assert.deepEqual(calls, [
|
||||
['focus', user],
|
||||
['next', user],
|
||||
['target:flights:abc123', user],
|
||||
]);
|
||||
const note = root.querySelector('[data-awareness-focus-continuation]');
|
||||
assert.equal(note.tagName, 'P');
|
||||
assert.equal(note.getAttribute('tabindex'), '-1');
|
||||
});
|
||||
|
||||
test('a live repaint keeps a focused contact by identity and never retargets another', async (t) => {
|
||||
const { panel, publish } = fixture(t);
|
||||
const alpha = target('flights', 'abc123', 'ALPHA');
|
||||
const bravo = target('flights', 'def456', 'BRAVO');
|
||||
await publish(results({ nearest: [alpha, bravo] }));
|
||||
const alphaButton = panel().querySelector('[data-awareness-id="abc123"]');
|
||||
alphaButton.focus();
|
||||
|
||||
await publish(results({ nearest: [bravo, alpha] }));
|
||||
assert.equal(
|
||||
document.activeElement === alphaButton,
|
||||
true,
|
||||
'identity wins when rows reorder',
|
||||
);
|
||||
|
||||
const charlie = target('ais-live-vessels', '789', 'CHARLIE');
|
||||
await publish(results({ nearest: [charlie] }));
|
||||
const note = panel().querySelector('[data-awareness-focus-continuation]');
|
||||
assert.equal(
|
||||
document.activeElement === note,
|
||||
true,
|
||||
'a departed contact continues at the note',
|
||||
);
|
||||
|
||||
await publish(results({ nearest: [bravo] }));
|
||||
assert.equal(
|
||||
document.activeElement === note,
|
||||
true,
|
||||
'later repaints keep the continuation point',
|
||||
);
|
||||
});
|
||||
|
||||
test('a reorder that drops focus from a moved contact restores the same contact', async (t) => {
|
||||
const { panel, publish } = fixture(t);
|
||||
const alpha = target('flights', 'abc123', 'ALPHA');
|
||||
const bravo = target('flights', 'def456', 'BRAVO');
|
||||
await publish(results({ nearest: [alpha, bravo] }));
|
||||
// Browsers drop focus from a node that is moved; happy-dom does not, so
|
||||
// moving the focused node sends focus outside the panel here.
|
||||
const sink = document.body.appendChild(document.createElement('button'));
|
||||
const list = panel().querySelector('ul');
|
||||
const insertBefore = list.insertBefore.bind(list);
|
||||
list.insertBefore = (node, ref) => {
|
||||
const moved = node.contains(document.activeElement);
|
||||
const result = insertBefore(node, ref);
|
||||
if (moved) sink.focus();
|
||||
return result;
|
||||
};
|
||||
const focusNode = (id) =>
|
||||
panel().querySelector(`[data-awareness-id="${id}"]`);
|
||||
focusNode('def456').focus();
|
||||
await publish(results({ nearest: [bravo, alpha] }));
|
||||
focusNode('abc123').focus();
|
||||
await publish(results({ nearest: [alpha, bravo] }));
|
||||
assert.equal(
|
||||
document.activeElement === focusNode('abc123'),
|
||||
true,
|
||||
'the same contact keeps focus, not another one or the note',
|
||||
);
|
||||
});
|
||||
|
||||
test('a repaint that disables the focused control continues at the note', async (t) => {
|
||||
const { panel, publish } = fixture(t);
|
||||
await publish(results({ previousDisabled: false }));
|
||||
const previous = panel().querySelector('[data-awareness-action="previous"]');
|
||||
previous.focus();
|
||||
await publish(results({ previousDisabled: true }));
|
||||
assert.equal(previous.disabled, true);
|
||||
const note = panel().querySelector('[data-awareness-focus-continuation]');
|
||||
assert.equal(document.activeElement === note, true);
|
||||
});
|
||||
|
||||
test('a repaint that leaves focus outside the panel does not pull it back', async (t) => {
|
||||
const { panel, publish } = fixture(t);
|
||||
const outside = document.body.appendChild(document.createElement('button'));
|
||||
await publish(results({ nearest: [target('flights', 'abc123', 'ALPHA')] }));
|
||||
outside.focus();
|
||||
await publish(results({ nearest: [] }));
|
||||
assert.equal(document.activeElement === outside, true);
|
||||
assert.equal(
|
||||
panel().querySelector('ul'),
|
||||
null,
|
||||
'an empty cohort renders no list',
|
||||
);
|
||||
});
|
||||
@@ -5,7 +5,8 @@ import { markRaw, shallowRef } from 'vue';
|
||||
* The Context panel's mode switch as its view renders it. ContextControls
|
||||
* presents the selected mode, the transition busy state and the nearby
|
||||
* sites search (`_presentContext()`) and publishes them here; it
|
||||
* keeps its listeners and transactions.
|
||||
* keeps its listeners and transactions. The Contacts layer publishes its
|
||||
* panel beside it, and the app connects that layer's navigation.
|
||||
*/
|
||||
export const useContextStore = defineStore('gev-context', () => {
|
||||
/** The presented mode switch; null until the controls first render. */
|
||||
@@ -16,5 +17,31 @@ export const useContextStore = defineStore('gev-context', () => {
|
||||
view.value = markRaw(next);
|
||||
}
|
||||
|
||||
return { view, publish };
|
||||
/** The Contacts panel; null until the layer first publishes. */
|
||||
const awareness = shallowRef(null);
|
||||
|
||||
/** @param {object} next A fresh presentation of the Contacts panel. */
|
||||
function publishAwareness(next) {
|
||||
awareness.value = next && markRaw(next);
|
||||
}
|
||||
|
||||
/**
|
||||
* The Contacts layer whose navigation the panel calls: `navigatePrevious`,
|
||||
* `focusCurrent`, `navigateNext` and `focusTarget`.
|
||||
*/
|
||||
const awarenessLayer = shallowRef(null);
|
||||
|
||||
/** @param {?object} layer The Contacts layer, or null to disconnect. */
|
||||
function connectAwareness(layer) {
|
||||
awarenessLayer.value = layer && markRaw(layer);
|
||||
}
|
||||
|
||||
return {
|
||||
view,
|
||||
publish,
|
||||
awareness,
|
||||
publishAwareness,
|
||||
awarenessLayer,
|
||||
connectAwareness,
|
||||
};
|
||||
});
|
||||
@@ -2,6 +2,7 @@
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { vClassToggle } from '../rail/classToggle.js';
|
||||
import { useContextStore } from '../stores/context.js';
|
||||
import AwarenessPanel from './panels/AwarenessPanel.vue';
|
||||
import RadioPanel from './panels/RadioPanel.vue';
|
||||
|
||||
// ContextControls presents the mode switch and the nearby sites search and
|
||||
@@ -68,12 +69,7 @@ const { view: ctx } = storeToRefs(useContextStore());
|
||||
<button id="installations-search-btn" class="panel-layer-toggle" type="button" :aria-disabled="ctx && String(ctx.installationsBusy)" :aria-busy="ctx && String(ctx.installationsBusy)" data-i18n>SEARCH NEARBY SITES</button>
|
||||
<button id="tr3b-toggle" class="tr3b-toggle" type="button" aria-pressed="false" hidden aria-label="Reclassify tracked contact as TR-3B" title="Reclassify as TR-3B" data-i18n-aria-label data-i18n-title>🛸</button>
|
||||
</div>
|
||||
<aside id="military-awareness-panel">
|
||||
<div class="military-awareness-standby">
|
||||
<strong data-i18n>CONTACTS CONTEXT OFF</strong>
|
||||
<span data-i18n>SELECT CONTACTS TO LOAD OBSERVED / MAPPED PROXIMITY</span>
|
||||
</div>
|
||||
</aside>
|
||||
<AwarenessPanel />
|
||||
</section>
|
||||
<section id="context-missions-view" class="context-mode-view" role="tabpanel" aria-labelledby="global-context-missions-btn" :hidden="!ctx?.missionsActive">
|
||||
<div id="space-mission-panel-host">
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<script setup>
|
||||
// The named areas of a Contacts subject or contact, one per line.
|
||||
defineProps({ names: { type: Object, required: true } });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<details class="military-awareness-names"><summary>{{ names.summary }}</summary><div><template v-for="(name, index) in names.names" :key="index"><br v-if="index">{{ name }}</template></div></details>
|
||||
</template>
|
||||
@@ -0,0 +1,74 @@
|
||||
<script setup>
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { onBeforeUpdate, onUpdated, ref } from 'vue';
|
||||
import { useContextStore } from '../../stores/context.js';
|
||||
import AwarenessNames from './AwarenessNames.vue';
|
||||
|
||||
// The Contacts panel inside Context. The Contacts layer publishes its view
|
||||
// (standby, results or empty); until it does, the panel shows its initial
|
||||
// markup. Buttons call the layer's navigation as the person's own.
|
||||
const { awareness: view, awarenessLayer: layer } = storeToRefs(useContextStore());
|
||||
const user = { origin: 'user' };
|
||||
const root = ref(null);
|
||||
|
||||
// A live repaint keeps focus on the same control by identity: browsers drop
|
||||
// focus from a node Vue moves, removes or disables, so the control is found
|
||||
// again by its key. When it is gone or disabled, focus continues at the note
|
||||
// beyond the list rather than jumping to another contact.
|
||||
const CONTINUATION = '[data-awareness-focus-continuation]';
|
||||
const controlKey = (el) => {
|
||||
if (el?.matches?.(CONTINUATION)) return 'continuation';
|
||||
const { awarenessAction: action, awarenessLayer: layer, awarenessId: id } = el?.dataset ?? {};
|
||||
if (action) return `action:${action}`;
|
||||
return layer && id ? `target:${layer}:${id}` : null;
|
||||
};
|
||||
let focusedKey = null;
|
||||
onBeforeUpdate(() => {
|
||||
const active = document.activeElement;
|
||||
focusedKey = root.value?.contains(active) ? controlKey(active) ?? 'continuation' : null;
|
||||
});
|
||||
onUpdated(() => {
|
||||
const key = focusedKey;
|
||||
focusedKey = null;
|
||||
const panel = root.value;
|
||||
// A disabled control may keep focus until the browser's next frame.
|
||||
const active = document.activeElement;
|
||||
if (!key || !panel || (panel.contains(active) && !active.disabled)) return;
|
||||
const retained = key === 'continuation' ? null : [...panel.querySelectorAll('button[data-awareness-action], button[data-awareness-id]')].find((control) => !control.disabled && controlKey(control) === key);
|
||||
(retained || panel.querySelector(CONTINUATION))?.focus({ preventScroll: true });
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside id="military-awareness-panel" ref="root">
|
||||
<div v-if="!view" class="military-awareness-standby">
|
||||
<strong data-i18n>CONTACTS CONTEXT OFF</strong>
|
||||
<span data-i18n>SELECT CONTACTS TO LOAD OBSERVED / MAPPED PROXIMITY</span>
|
||||
</div>
|
||||
<div v-else-if="view.kind === 'standby'" class="military-awareness-standby">
|
||||
<strong>{{ view.title }}</strong>
|
||||
<span>{{ view.text }}</span>
|
||||
</div>
|
||||
<template v-else-if="view.kind === 'results'">
|
||||
<div class="military-awareness-subject">{{ view.subject }}</div>
|
||||
<AwarenessNames v-if="view.subjectNames" :names="view.subjectNames" />
|
||||
<div class="military-awareness-controls" role="group" :aria-label="view.navigation.label">
|
||||
<button type="button" data-awareness-action="previous" :title="view.navigation.previous.title" :disabled="view.navigation.previous.disabled" @click="layer?.navigatePrevious(user)">{{ view.navigation.previous.text }}</button>
|
||||
<button type="button" data-awareness-action="focus" @click="layer?.focusCurrent(user)">{{ view.navigation.focus.text }}</button>
|
||||
<button type="button" data-awareness-action="next" :title="view.navigation.next.title" :disabled="view.navigation.next.disabled" @click="layer?.navigateNext(user)">{{ view.navigation.next.text }}</button>
|
||||
</div>
|
||||
<section v-for="row in view.rows" :key="row.id" :class="['military-awareness-row', row.relationship]">
|
||||
<div><strong>{{ row.label }}</strong><b aria-live="polite">{{ row.count }}</b></div>
|
||||
<small>{{ row.detail }}</small>
|
||||
<ul v-if="row.nearest.length">
|
||||
<li v-for="item in row.nearest" :key="item.key">
|
||||
<button v-if="item.targetId" type="button" class="military-awareness-target" :data-awareness-layer="item.layer" :data-awareness-id="item.targetId" :aria-label="item.ariaLabel" @click="layer?.focusTarget(item.layer, item.targetId, user)">{{ item.label }} <span>{{ item.distance }}</span></button>
|
||||
<span v-else class="military-awareness-target unavailable" :aria-label="item.ariaLabel">{{ item.label }} <span>{{ item.distance }}</span></span>
|
||||
<AwarenessNames v-if="item.names" :names="item.names" />
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
<p class="military-awareness-note" tabindex="-1" data-awareness-focus-continuation>{{ view.note }}</p>
|
||||
</template>
|
||||
</aside>
|
||||
</template>
|
||||
Reference in new issue
Block a user