diff --git a/CHANGELOG.md b/CHANGELOG.md index 22f5257d4..2a4b64f4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,14 @@ publish the panel's state to a store that the CCTV and Radio views render, instead of writing the panel's markup themselves (samehkhamis). +- `./layers/awareness` no longer writes the Contacts panel into + `#military-awareness-panel`: the layer publishes a plain panel view through + `subscribePanel(listener)` and Core's Contacts view renders it, calling the + layer's `navigatePrevious`, `focusCurrent`, `navigateNext` and `focusTarget`. + The `awarenessPanelControlKey`, `captureAwarenessPanelFocus` and + `restoreAwarenessPanelFocus` helpers are gone; the view keeps focus itself + (samehkhamis). + ## [0.3.0] — 2026-10-27 — Street Level, On Your Phone, in Your Language - Show a Street Level view facing 359.5° or more as 0°. The viewer caption diff --git a/docs/VUE-MIGRATION.md b/docs/VUE-MIGRATION.md index 72b33c1b7..89b1fcdd5 100644 --- a/docs/VUE-MIGRATION.md +++ b/docs/VUE-MIGRATION.md @@ -110,8 +110,10 @@ They contain no application state. the tuner and the Cockpit radio controls keep their owners' element bindings; `ContextView` renders the mode switch and the nearby sites search from the `gev-context` store, which ContextControls reaches only - through its `publish` option, so `./ui/context` needs no Vue), the - command dock, + through its `publish` option, so `./ui/context` needs no Vue; + `AwarenessPanel` renders the Contacts panel that the awareness layer + publishes through `subscribePanel()`, the first layer to hand its rail + content to a view), the command dock, the phone shell, then Cockpit. Layers that write into the right rail directly gain a slot instead. diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index dc216dd7b..013f6e0b8 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -26,6 +26,8 @@ "src/ui/views/ContextView.vue", "src/ui/views/panels/RadioPanel.vue", "src/ui/views/panels/SdrCard.vue", + "src/ui/views/panels/AwarenessPanel.vue", + "src/ui/views/panels/AwarenessNames.vue", "src/ui/views/DisplayControlsView.vue", "src/ui/views/LayerPanelsView.vue", "src/ui/views/ProviderSettingsView.vue", diff --git a/src/data/militaryAwareness.js b/src/data/militaryAwareness.js index 756f142d2..d1907fd2b 100644 --- a/src/data/militaryAwareness.js +++ b/src/data/militaryAwareness.js @@ -25,12 +25,11 @@ export const collectAircraftProximityWindow = layer.collectAircraftProximityWindow; export const _getAwarenessNavigationStateForTest = layer._getAwarenessNavigationStateForTest; +export const _getAwarenessPanelRendersForTest = + layer._getAwarenessPanelRendersForTest; export const canNavigateAwarenessNext = layer.canNavigateAwarenessNext; export const historySubjectSnapshot = layer.historySubjectSnapshot; export const findCompatibleHistoryIndex = layer.findCompatibleHistoryIndex; -export const awarenessPanelControlKey = layer.awarenessPanelControlKey; -export const captureAwarenessPanelFocus = layer.captureAwarenessPanelFocus; -export const restoreAwarenessPanelFocus = layer.restoreAwarenessPanelFocus; export const awarenessResultsAreLive = layer.awarenessResultsAreLive; export const awarenessNeedsContinuousRender = layer.awarenessNeedsContinuousRender; diff --git a/src/data/militaryAwareness.test.mjs b/src/data/militaryAwareness.test.mjs index ccb5739f2..ea63280dc 100644 --- a/src/data/militaryAwareness.test.mjs +++ b/src/data/militaryAwareness.test.mjs @@ -1,26 +1,25 @@ +import { readFileSync } from 'node:fs'; import { readLayerSource } from '../testSupport/readLayerSource.mjs'; import { test } from 'node:test'; import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; import militaryAwarenessLayer, { _getAwarenessNavigationStateForTest, + _getAwarenessPanelRendersForTest, awarenessClearMatchesSubject, awarenessNeedsContinuousRender, awarenessRefreshDecision, awarenessRefreshIntervalMs, awarenessRefreshRequired, awarenessResultsAreLive, - awarenessPanelControlKey, buildAwarenessContextSnapshot, canNavigateAwarenessNext, contactsWindowFromSnapshot, contextTargetFlyToAllowed, - captureAwarenessPanelFocus, summarizeInstallationViewport, findCompatibleHistoryIndex, AWARENESS_QUERY_LIMIT, historySubjectSnapshot, - restoreAwarenessPanelFocus, } from './militaryAwareness.js'; import flightsLayer, { _setTrackedFlightRefreshStateForTest, @@ -52,6 +51,10 @@ const militaryAwarenessSource = readLayerSource( new URL('./militaryAwareness.js', import.meta.url), 'utf8', ); +const awarenessPanelSource = readFileSync( + new URL('../ui/views/panels/AwarenessPanel.vue', import.meta.url), + 'utf8', +); const AWARENESS_DEPENDENCIES = [ 'flights', @@ -98,27 +101,12 @@ function installAwarenessRuntime({ const listeners = new Map(); let nextTimer = 1; let preRenderListener = null; - const createdElements = []; class FakeElement { constructor() { this.classList = { contains: () => false }; this.style = { setProperty() {} }; - this._hidden = false; - // renderResults() writes `hidden = false` on EVERY call, ahead of its - // markup diff, so counting these writes observes "was the panel - // repaint path entered?" independently of whether the markup changed. - this.hiddenWrites = 0; - this.innerHTML = ''; - } - - get hidden() { - return this._hidden; - } - - set hidden(value) { - this._hidden = value; - this.hiddenWrites += 1; + this.hidden = false; } setAttribute() {} @@ -213,9 +201,7 @@ function installAwarenessRuntime({ return null; }, createElement() { - const element = new FakeElement(); - createdElements.push(element); - return element; + return new FakeElement(); }, addEventListener() {}, removeEventListener() {}, @@ -229,6 +215,10 @@ function installAwarenessRuntime({ }); militaryAwarenessLayer.setParams({ passive: true }); militaryAwarenessLayer.init(viewer); + const panelViews = []; + const unsubscribePanel = militaryAwarenessLayer.subscribePanel((view) => + panelViews.push(view), + ); militaryAwarenessLayer.enable(); return { @@ -248,15 +238,16 @@ function installAwarenessRuntime({ ); return taken; }, - /** The standalone Context panel element renderResults() paints into. */ + /** The Contacts panel as the layer publishes it to the view. */ panel() { - return ( - createdElements.find( - (element) => element.id === 'military-awareness-panel', - ) || null - ); + return { + view: panelViews.at(-1) ?? null, + publishes: panelViews.length, + renders: _getAwarenessPanelRendersForTest(), + }; }, restore() { + unsubscribePanel(); militaryAwarenessLayer.destroy(); globalThis.window = originalWindow; globalThis.document = originalDocument; @@ -264,144 +255,24 @@ function installAwarenessRuntime({ }; } -function awarenessControl(dataset, { disabled = false } = {}) { - return { - dataset, - disabled, - focusCalls: 0, - closest(selector) { - if (selector === '[data-awareness-focus-continuation]') { - return this.isContinuation ? this : null; - } - return selector.includes('button') && !this.isContinuation ? this : null; - }, - matches(selector) { - return ( - selector === '[data-awareness-focus-continuation]' && - this.isContinuation - ); - }, - focus(options) { - this.focusCalls += 1; - this.focusOptions = options; - }, - }; +/** The count a published Contacts panel shows for one cohort row. */ +function panelCount(runtime, label) { + return runtime.panel().view?.rows?.find((row) => row.label === label)?.count; } -function awarenessPanel(controls) { - const continuation = awarenessControl({}); - continuation.isContinuation = true; - return { - controls, - continuation, - contains(element) { - return controls.includes(element) || element === continuation; - }, - querySelectorAll() { - return this.controls; - }, - querySelector(selector) { - return selector === '[data-awareness-focus-continuation]' - ? continuation - : null; - }, - }; -} - -test('Contacts live repaint restores only stable identity and never retargets another contact', () => { - const previous = awarenessControl({ awarenessAction: 'previous' }); - const focus = awarenessControl({ awarenessAction: 'focus' }); - const next = awarenessControl({ awarenessAction: 'next' }); - const alpha = awarenessControl({ - awarenessLayer: 'flights', - awarenessId: 'abc123', - }); - const bravo = awarenessControl({ - awarenessLayer: 'military', - awarenessId: 'def456', - }); - const before = awarenessPanel([previous, focus, next, alpha, bravo]); - const alphaSnapshot = captureAwarenessPanelFocus(before, alpha); - - assert.deepEqual(alphaSnapshot, { key: 'target:flights:abc123' }); - assert.equal(awarenessPanelControlKey(next), 'action:next'); - - const retainedAlpha = awarenessControl({ - awarenessLayer: 'flights', - awarenessId: 'abc123', - }); - const retained = awarenessPanel([ - previous, - focus, - next, - bravo, - retainedAlpha, - ]); - assert.equal( - restoreAwarenessPanelFocus(retained, alphaSnapshot), - retainedAlpha, - ); - assert.equal( - retainedAlpha.focusCalls, - 1, - 'identity wins even when live distance reorders rows', - ); - assert.deepEqual(retainedAlpha.focusOptions, { preventScroll: true }); - - const forward = awarenessControl({ - awarenessLayer: 'ais-live-vessels', - awarenessId: '789', - }); - const departed = awarenessPanel([previous, focus, next, forward]); - assert.equal( - restoreAwarenessPanelFocus(departed, alphaSnapshot), - departed.continuation, - ); - assert.equal( - forward.focusCalls, - 0, - 'automatic paging must not transfer focus to another contact', - ); - assert.equal(departed.continuation.focusCalls, 1); - - const bravoSnapshot = captureAwarenessPanelFocus(before, bravo); - const noRemainingRow = awarenessPanel([previous, focus, next, alpha]); - assert.equal( - restoreAwarenessPanelFocus(noRemainingRow, bravoSnapshot), - noRemainingRow.continuation, - ); - assert.equal( - noRemainingRow.continuation.focusCalls, - 1, - 'the end sentinel continues Tab beyond the list', - ); - - const continuationSnapshot = captureAwarenessPanelFocus( - departed, - departed.continuation, - ); - assert.deepEqual(continuationSnapshot, { key: 'continuation' }); - const repaintedAgain = awarenessPanel([previous, focus, next, forward]); - assert.equal( - restoreAwarenessPanelFocus(repaintedAgain, continuationSnapshot), - repaintedAgain.continuation, - ); - assert.equal( - repaintedAgain.continuation.focusCalls, - 1, - 'later live repaints keep focus on the continuation point until the user tabs onward', - ); -}); - test('Contacts continuation target is the visible explanatory note', () => { - const source = readLayerSource( - new URL('./militaryAwareness.js', import.meta.url), + assert.match( + awarenessPanelSource, + /
\{\{ view\.note \}\}<\/p>/,
+ );
+ assert.doesNotMatch(
+ awarenessPanelSource,
+ /]*data-awareness-focus-continuation/,
);
assert.match(
- source,
- / \$\{t\('Open-source mapped\/observed context\.[^']*'\)\}<\/p>/,
+ militaryAwarenessSource,
+ /'Open-source mapped\/observed context\./,
);
- assert.doesNotMatch(source, /]*data-awareness-focus-continuation/);
});
test('awareness disable settles every owned dependency release before resolving', async () => {
@@ -599,9 +470,9 @@ test('pending mapped installations render as unknown instead of a false zero', a
'loading is not an observed empty cohort',
);
assert.match(installations?.reason || '', /unavailable/i);
- assert.match(
- runtime.panel()?.innerHTML || '',
- /Mapped installations<\/strong>\?<\/b>/,
+ assert.equal(
+ panelCount(runtime, 'Mapped installations'),
+ '?',
'the operator sees ? rather than an all-clear 0 while installations load',
);
} finally {
@@ -741,15 +612,10 @@ test('a vessel feed still connecting after the lifecycle settles reads as unknow
'a source that has never answered has told us nothing — 0 would be a fabricated all-clear',
);
assert.match(vessels?.reason || '', /unavailable/i);
- assert.match(
- runtime.panel()?.innerHTML || '',
- /AIS vessels<\/strong>\?<\/b>/,
- 'the panel prints ? for the whole settled-but-connecting window',
- );
- assert.doesNotMatch(
- runtime.panel()?.innerHTML || '',
- /AIS vessels<\/strong>0<\/b>/,
- 'the panel must never print an all-clear 0 before the feed has answered once',
+ assert.equal(
+ panelCount(runtime, 'AIS vessels'),
+ '?',
+ 'the panel prints ? for the whole settled-but-connecting window, never an all-clear 0',
);
} finally {
restoreCollections();
@@ -789,9 +655,9 @@ test('a settled vessel feed reporting a real empty viewport recovers to 0', asyn
0,
'an answered empty viewport is a real observation',
);
- assert.match(
- runtime.panel()?.innerHTML || '',
- /AIS vessels<\/strong>0<\/b>/,
+ assert.equal(
+ panelCount(runtime, 'AIS vessels'),
+ '0',
'the operator sees the real count once the feed answers',
);
} finally {
@@ -1254,17 +1120,23 @@ test('a cached Context subject re-reads its label when enrichment lands after se
id,
);
const panel = runtime.panel();
- assert.ok(panel, 'the Context panel must exist');
- assert.match(panel.innerHTML, /ae1fa4/, 'panel starts on the hex');
+ assert.equal(panel.view?.kind, 'results', 'the Context panel must exist');
+ assert.match(panel.view.subject, /ae1fa4/, 'panel starts on the hex');
// A refresh that changes NOTHING must not repaint (no per-refresh storm).
- const settledWrites = panel.hiddenWrites;
+ const settledRenders = runtime.panel().renders;
+ const settledPublishes = runtime.panel().publishes;
await militaryAwarenessLayer.update();
assert.equal(
- panel.hiddenWrites,
- settledWrites,
+ runtime.panel().renders,
+ settledRenders,
'an unchanged label must not trigger a panel repaint',
);
+ assert.equal(
+ runtime.panel().publishes,
+ settledPublishes,
+ 'an unchanged refresh publishes no new panel, results or standby',
+ );
// Enrichment answers. The contact has NOT moved and no source revision
// changed, so the label is the only thing making this render-worthy.
@@ -1283,17 +1155,18 @@ test('a cached Context subject re-reads its label when enrichment lands after se
'subject identity must stay the ICAO hex',
);
// The RENDERED panel is the surface the operator actually reads.
+ const repainted = runtime.panel();
assert.ok(
- panel.hiddenWrites > settledWrites,
+ repainted.publishes > settledPublishes,
'a changed label must repaint the standalone Context panel',
);
assert.match(
- panel.innerHTML,
+ repainted.view.subject,
/N123AB/,
'the rendered Context panel must show the registration without a position change',
);
assert.doesNotMatch(
- panel.innerHTML,
+ JSON.stringify(repainted.view),
/ae1fa4/,
'the hex must be gone from the panel',
);
@@ -2240,9 +2113,10 @@ test('expanded Context results omit the redundant status heading', () => {
militaryAwarenessSource,
/GLOBAL CONTEXT CONTEXT ONLY<\/span>/,
);
- assert.match(militaryAwarenessSource, /military-awareness-subject/);
+ assert.doesNotMatch(awarenessPanelSource, /military-awareness-heading/);
+ assert.match(awarenessPanelSource, /military-awareness-subject/);
assert.match(militaryAwarenessSource, /FLIGHT \/ VESSEL WINDOW/);
- assert.match(militaryAwarenessSource, /military-awareness-controls/);
+ assert.match(awarenessPanelSource, /military-awareness-controls/);
});
test('Context entry tracks a nearer civilian aircraft over a farther military aircraft', async () => {
@@ -4229,7 +4103,7 @@ test('a moving camera refreshes Contacts more than once inside one parked interv
}
});
-test('a selected installation card keeps every member name and escapes source text', () => {
+test('a selected installation card keeps every member name as text', () => {
const runtime = installAwarenessRuntime();
const restores = [];
try {
@@ -4252,16 +4126,15 @@ test('a selected installation card keeps every member name and escapes source te
},
});
militaryAwarenessLayer.update();
- const html = runtime.panel().innerHTML;
- assert.match(html, /Named areas \(4\)/);
- for (const name of [
+ // The view renders names as text; ui/awarenessPanel.test.mjs covers it.
+ const names = runtime.panel().view?.subjectNames;
+ assert.equal(names?.summary, 'Named areas (4)');
+ assert.deepEqual(names?.names, [
'Area one',
'Area two',
'Area three',
- '<Area four>',
- ])
- assert.ok(html.includes(name));
- assert.ok(!html.includes(''));
+ '',
+ ]);
} finally {
restores.reverse().forEach((restore) => restore());
runtime.restore();
diff --git a/src/layers/awareness/index.js b/src/layers/awareness/index.js
index cd01e0cfc..1998ca426 100644
--- a/src/layers/awareness/index.js
+++ b/src/layers/awareness/index.js
@@ -51,12 +51,12 @@ export function createAwarenessLayer({ services, source }) {
parts.queries.collectAircraftProximityWindow,
_getAwarenessNavigationStateForTest:
parts.testing._getAwarenessNavigationStateForTest,
+ _getAwarenessPanelRendersForTest:
+ parts.testing._getAwarenessPanelRendersForTest,
canNavigateAwarenessNext: parts.navigation.canNavigateAwarenessNext,
historySubjectSnapshot: parts.history.historySubjectSnapshot,
findCompatibleHistoryIndex: parts.history.findCompatibleHistoryIndex,
- awarenessPanelControlKey: parts.panel.awarenessPanelControlKey,
- captureAwarenessPanelFocus: parts.panel.captureAwarenessPanelFocus,
- restoreAwarenessPanelFocus: parts.panel.restoreAwarenessPanelFocus,
+ subscribePanel: parts.panel.subscribePanel,
awarenessResultsAreLive: parts.panel.awarenessResultsAreLive,
awarenessNeedsContinuousRender:
parts.model.awarenessNeedsContinuousRender,
diff --git a/src/layers/awareness/lifecycle.js b/src/layers/awareness/lifecycle.js
index e72e041b2..34ee5a2f2 100644
--- a/src/layers/awareness/lifecycle.js
+++ b/src/layers/awareness/lifecycle.js
@@ -229,22 +229,13 @@ export function createLifecycle({
destroy() {
this.disable();
detachRuntimeListeners();
- layerState.panel?.removeEventListener(
- 'click',
- layerState.panelClickListener,
- );
- layerState.panelClickListener = null;
- if (layerState.panelOwned) layerState.panel?.remove();
- else if (layerState.panel) layerState.panel.replaceChildren();
- layerState.panel = null;
- layerState.panelOwned = false;
+ parts.panel.clearPanel();
layerState.directionRoot?.remove();
layerState.directionRoot = null;
layerState.compassRing = null;
layerState.compassLabels = [];
layerState.compassHeading = null;
layerState.directionMarkers = [];
- layerState.panelMarkup = '';
layerState.subjectListener = null;
layerState.contextListener = null;
layerState.clearListener = null;
diff --git a/src/layers/awareness/panel.js b/src/layers/awareness/panel.js
index 120e91b2c..d5711b194 100644
--- a/src/layers/awareness/panel.js
+++ b/src/layers/awareness/panel.js
@@ -43,76 +43,87 @@ function presentAwarenessReason(summary = {}) {
: translateKnown(summary.reason || '');
}
-export function createPanel({ state: layerState, services, parts, source }) {
+/**
+ * The Contacts panel as a view renders it. The layer presents a plain view
+ * object (`standby`, `results` or `empty`) and publishes it to subscribers;
+ * Core's AwarenessPanel component renders it and calls the layer's
+ * navigation methods. Text arrives in the UI language.
+ */
+export function createPanel({ state: layerState, parts }) {
+ /** Publish a fresh view unless it is the one already shown. */
+ function publish(view) {
+ const signature = JSON.stringify(view);
+ if (layerState.panelSignature === signature) return;
+ layerState.panelSignature = signature;
+ layerState.panelView = view;
+ for (const listener of [...layerState.panelListeners]) listener(view);
+ }
+
+ /**
+ * Receive every published view, starting with the current one.
+ * @param {(view: object) => void} listener
+ * @returns {() => void} Unsubscribe.
+ */
+ function subscribePanel(listener) {
+ layerState.panelListeners.add(listener);
+ if (layerState.panelView) listener(layerState.panelView);
+ return () => layerState.panelListeners.delete(listener);
+ }
+
+ /** The panel shows layer views from the first enable or result onwards. */
function ensurePanel() {
- if (layerState.panel) return layerState.panel;
- const existing = document.getElementById('military-awareness-panel');
- const panel = existing || document.createElement('aside');
- layerState.panelOwned = !existing;
- if (!existing) {
- panel.id = 'military-awareness-panel';
- }
- layerState.panelClickListener = (event) => {
- const action = event.target.closest('button[data-awareness-action]');
- if (action) {
- event.preventDefault();
- if (action.dataset.awarenessAction === 'previous')
- parts.history.navigateHistory(-1, { origin: 'user' });
- if (action.dataset.awarenessAction === 'next')
- parts.history.navigateHistory(1, { origin: 'user' });
- if (action.dataset.awarenessAction === 'focus')
- parts.focus.focusCurrentSubject({ origin: 'user' });
- return;
- }
- const target = event.target.closest(
- 'button[data-awareness-layer][data-awareness-id]',
- );
- if (!target) return;
- event.preventDefault();
- parts.focus.requestFocus(
- target.dataset.awarenessLayer,
- target.dataset.awarenessId,
- false,
- { origin: 'user' },
- );
- };
- panel.addEventListener('click', layerState.panelClickListener);
- if (!existing) document.body.appendChild(panel);
- layerState.panel = panel;
- return panel;
+ layerState.panelShown = true;
}
function hidePanel() {
- if (layerState.panel) {
- const markup = ` ${t('Open-source mapped/observed context. Missing broadcasts, unloaded map areas, or unmapped sites are not evidence of absence.')}${nearest}
` : ''}
- ${escapeHtml(t('Named areas ({n})', { n: formatNumber(names.length) }))}
')}
<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',
+ );
+});
diff --git a/src/ui/stores/context.js b/src/ui/stores/context.js
index f41daecd2..74162a367 100644
--- a/src/ui/stores/context.js
+++ b/src/ui/stores/context.js
@@ -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,
+ };
});
diff --git a/src/ui/views/ContextView.vue b/src/ui/views/ContextView.vue
index 0636d86ef..38efa30fe 100644
--- a/src/ui/views/ContextView.vue
+++ b/src/ui/views/ContextView.vue
@@ -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());
-
+ {{ names.summary }}
{{ name }}