mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
feat(ui): render the data layer rows through Vue from presented row state
This commit is contained in:
1 parent
ce343c1c6d
commit
d63f4d697b
16 files changed
+1417
-1535
No files matched your search
@@ -297,13 +297,13 @@ compactStatus, blocks }` in place. Their full-width native button headers expose
|
|||||||
adapter owns accordion state. Generic cards do not select data or enable layers.
|
adapter owns accordion state. Generic cards do not select data or enable layers.
|
||||||
|
|
||||||
`blocks` is an ordered array of loose, optional `{ id, type, ... }` descriptors,
|
`blocks` is an ordered array of loose, optional `{ id, type, ... }` descriptors,
|
||||||
rendered by `railCardBlocks.js`. There are no per-section disclosures:
|
rendered by `rail/RailCardBlocks.vue`. There are no per-section disclosures:
|
||||||
|
|
||||||
- `summary`: `text`, a single summary line.
|
- `summary`: `text`, a single summary line.
|
||||||
- `list`: `list: { ariaLabel, items }`, reconciled by `syncRowList` in `rowList.js`.
|
- `list`: `list: { ariaLabel, items }`, keyed by `rail/RailRowList.vue`.
|
||||||
- `lines`: keyed `lines: [{ id, text, muted? }]` for details and status.
|
- `lines`: keyed `lines: [{ id, text, muted? }]` for details and status.
|
||||||
- `settings`: `settings: [{ id, label, chips }]`; each labelled row has a 56 px
|
- `settings`: `settings: [{ id, label, chips }]`; each labelled row has a 56 px
|
||||||
label column and a wrapping `syncChipGroup` from `chipGroup.js`.
|
label column and wrapping keyed chips from `rail/RailChips.vue`.
|
||||||
- `legend`: `legend: { colors, labels, units?, zeroIndex?, categorical? }` for a
|
- `legend`: `legend: { colors, labels, units?, zeroIndex?, categorical? }` for a
|
||||||
ramp/scale (including a physical freezing anchor) or separate keyed swatches.
|
ramp/scale (including a physical freezing anchor) or separate keyed swatches.
|
||||||
- `actions`: keyed `actions: [{ id, label, params?, href?, onClick?, disabled?,
|
- `actions`: keyed `actions: [{ id, label, params?, href?, onClick?, disabled?,
|
||||||
|
|||||||
@@ -90,7 +90,10 @@ They contain no application state.
|
|||||||
the whole panel from the `gev-cctv` store, including the camera picker,
|
the whole panel from the `gev-cctv` store, including the camera picker,
|
||||||
the inline pose editor, the frame preview, source badge and typed
|
the inline pose editor, the frame preview, source badge and typed
|
||||||
summary; `CctvControls` keeps its listeners, the frame requests and the
|
summary; `CctvControls` keeps its listeners, the frame requests and the
|
||||||
live video surface), Layers, Display, Context and Radio, the command dock,
|
live video surface), Layers (done: `LayerPanel` presents each row and
|
||||||
|
renders `LayerRows` synchronously; keyed chips and list items keep focus
|
||||||
|
through the refresh their own click triggers, and clicks re-read the live
|
||||||
|
descriptor before acting), Display, Context and Radio, the command dock,
|
||||||
the phone shell, then Cockpit. Layers that write into the right rail
|
the phone shell, then Cockpit. Layers that write into the right rail
|
||||||
directly gain a slot instead.
|
directly gain a slot instead.
|
||||||
|
|
||||||
|
|||||||
@@ -548,8 +548,9 @@
|
|||||||
"src/ui/railCards.js",
|
"src/ui/railCards.js",
|
||||||
"src/ui/railCardBlocks.js",
|
"src/ui/railCardBlocks.js",
|
||||||
"src/ui/slider.js",
|
"src/ui/slider.js",
|
||||||
"src/ui/chipGroup.js",
|
"src/ui/layerRowText.js",
|
||||||
"src/ui/rowList.js",
|
"src/ui/layerPanel/LayerRows.vue",
|
||||||
|
"src/ui/layerPanel/RowText.vue",
|
||||||
"src/ui/railTimeline.js",
|
"src/ui/railTimeline.js",
|
||||||
"src/ui/clearLayersControl.js",
|
"src/ui/clearLayersControl.js",
|
||||||
"src/loadingFeedback.js",
|
"src/loadingFeedback.js",
|
||||||
@@ -852,8 +853,9 @@
|
|||||||
"src/ui/weatherPanel.js",
|
"src/ui/weatherPanel.js",
|
||||||
"src/ui/railCards.js",
|
"src/ui/railCards.js",
|
||||||
"src/ui/railCardBlocks.js",
|
"src/ui/railCardBlocks.js",
|
||||||
"src/ui/chipGroup.js",
|
"src/ui/layerRowText.js",
|
||||||
"src/ui/rowList.js",
|
"src/ui/layerPanel/LayerRows.vue",
|
||||||
|
"src/ui/layerPanel/RowText.vue",
|
||||||
"src/ui/railTimeline.js",
|
"src/ui/railTimeline.js",
|
||||||
"src/ui/layers.js",
|
"src/ui/layers.js",
|
||||||
"src/ui/leftPanelRail.js",
|
"src/ui/leftPanelRail.js",
|
||||||
@@ -2547,8 +2549,9 @@
|
|||||||
"src/ui/weatherPanel.js",
|
"src/ui/weatherPanel.js",
|
||||||
"src/ui/railCards.js",
|
"src/ui/railCards.js",
|
||||||
"src/ui/railCardBlocks.js",
|
"src/ui/railCardBlocks.js",
|
||||||
"src/ui/chipGroup.js",
|
"src/ui/layerRowText.js",
|
||||||
"src/ui/rowList.js",
|
"src/ui/layerPanel/LayerRows.vue",
|
||||||
|
"src/ui/layerPanel/RowText.vue",
|
||||||
"src/ui/railTimeline.js",
|
"src/ui/railTimeline.js",
|
||||||
"src/ui/layers.js",
|
"src/ui/layers.js",
|
||||||
"src/ui/leftPanelRail.js",
|
"src/ui/leftPanelRail.js",
|
||||||
|
|||||||
@@ -28,9 +28,6 @@ export class DataLayerManager extends LayerLifecycle {
|
|||||||
_buildMetaText(layer) {
|
_buildMetaText(layer) {
|
||||||
return this._presentation.panel._buildMetaText(layer);
|
return this._presentation.panel._buildMetaText(layer);
|
||||||
}
|
}
|
||||||
_syncToggleButton(button, layer) {
|
|
||||||
return this._presentation.panel._syncToggleButton(button, layer);
|
|
||||||
}
|
|
||||||
get _layerPanel() {
|
get _layerPanel() {
|
||||||
return this._presentation._panel;
|
return this._presentation._panel;
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-824
@@ -12,6 +12,7 @@
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { DataLayerManager, layerFeedState } from './manager.js';
|
import { DataLayerManager, layerFeedState } from './manager.js';
|
||||||
|
import { presentLayerToggle } from '../ui/layerPanel.js';
|
||||||
import {
|
import {
|
||||||
contextSnapshotLayerIds,
|
contextSnapshotLayerIds,
|
||||||
shouldCaptureContextSession,
|
shouldCaptureContextSession,
|
||||||
@@ -90,68 +91,6 @@ test('adopts settled visibility without re-running lifecycle work', async () =>
|
|||||||
assert.equal(manager.adoptLayerVisibility('flights', false, { origin: 'user' }), false);
|
assert.equal(manager.adoptLayerVisibility('flights', false, { origin: 'user' }), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('renders ordinary layer rows without recreating a panel-hidden coordinator', async () => {
|
|
||||||
const originalDocument = globalThis.document;
|
|
||||||
const makeElement = () => {
|
|
||||||
const element = {
|
|
||||||
children: [],
|
|
||||||
className: '',
|
|
||||||
dataset: {},
|
|
||||||
textContent: '',
|
|
||||||
disabled: false,
|
|
||||||
attributes: {},
|
|
||||||
classList: {
|
|
||||||
toggle() {},
|
|
||||||
},
|
|
||||||
appendChild(child) { this.children.push(child); return child; },
|
|
||||||
addEventListener() {},
|
|
||||||
removeEventListener() {},
|
|
||||||
setAttribute(name, value) { this.attributes[name] = String(value); },
|
|
||||||
querySelector(selector) {
|
|
||||||
if (selector.startsWith('[data-layer-id="')) {
|
|
||||||
const id = selector.slice(16, -2);
|
|
||||||
return this.children.find((child) => child.dataset.layerId === id) || null;
|
|
||||||
}
|
|
||||||
const className = selector.startsWith('.') ? selector.slice(1) : '';
|
|
||||||
const visit = (node) => {
|
|
||||||
if (String(node.className).split(/\s+/).includes(className)) return node;
|
|
||||||
for (const child of node.children || []) {
|
|
||||||
const found = visit(child);
|
|
||||||
if (found) return found;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
return visit(this);
|
|
||||||
},
|
|
||||||
set innerHTML(value) { if (value === '') this.children = []; },
|
|
||||||
get innerHTML() { return ''; },
|
|
||||||
};
|
|
||||||
return element;
|
|
||||||
};
|
|
||||||
globalThis.document = { createElement: makeElement };
|
|
||||||
const mgr = new DataLayerManager({});
|
|
||||||
const ordinary = makeSlowLayer('flights', { updateInterval: -1 });
|
|
||||||
const coordinator = makeSlowLayer('military-awareness', { updateInterval: -1 });
|
|
||||||
coordinator.module.showInTogglePanel = false;
|
|
||||||
mgr.register(ordinary.module);
|
|
||||||
mgr.register(coordinator.module);
|
|
||||||
const container = makeElement();
|
|
||||||
|
|
||||||
try {
|
|
||||||
mgr.buildTogglePanel(container);
|
|
||||||
assert.ok(container.querySelector('[data-layer-id="flights"]'));
|
|
||||||
assert.equal(container.querySelector('[data-layer-id="military-awareness"]'), null);
|
|
||||||
assert.equal(await mgr.setEnabled('military-awareness', true), true);
|
|
||||||
mgr._refreshTogglePanel();
|
|
||||||
assert.equal(container.querySelector('[data-layer-id="military-awareness"]'), null);
|
|
||||||
assert.equal(mgr.getAll().find(({ id }) => id === 'military-awareness').enabled, true);
|
|
||||||
} finally {
|
|
||||||
await mgr.destroyAll();
|
|
||||||
if (originalDocument === undefined) delete globalThis.document;
|
|
||||||
else globalThis.document = originalDocument;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('clearSelectedLayers includes hidden coordinators and preserves newer dependency restoration', async () => {
|
test('clearSelectedLayers includes hidden coordinators and preserves newer dependency restoration', async () => {
|
||||||
const mgr = new DataLayerManager({});
|
const mgr = new DataLayerManager({});
|
||||||
const order = [];
|
const order = [];
|
||||||
@@ -1861,17 +1800,6 @@ test('layer metadata names degraded state instead of presenting an ordinary age'
|
|||||||
|
|
||||||
test('uncertain lifecycle state overrides ordinary feed status without disabling reconciliation', () => {
|
test('uncertain lifecycle state overrides ordinary feed status without disabling reconciliation', () => {
|
||||||
const mgr = new DataLayerManager({});
|
const mgr = new DataLayerManager({});
|
||||||
const classes = new Map();
|
|
||||||
const attributes = new Map();
|
|
||||||
const button = {
|
|
||||||
classList: {
|
|
||||||
toggle(name, active) { classes.set(name, Boolean(active)); },
|
|
||||||
},
|
|
||||||
dataset: {},
|
|
||||||
disabled: false,
|
|
||||||
textContent: '',
|
|
||||||
setAttribute(name, value) { attributes.set(name, String(value)); },
|
|
||||||
};
|
|
||||||
const layer = {
|
const layer = {
|
||||||
name: 'Radio',
|
name: 'Radio',
|
||||||
source: 'Radio Browser',
|
source: 'Radio Browser',
|
||||||
@@ -1881,14 +1809,14 @@ test('uncertain lifecycle state overrides ordinary feed status without disabling
|
|||||||
stats: { count: 750, lastUpdate: Date.now() },
|
stats: { count: 750, lastUpdate: Date.now() },
|
||||||
};
|
};
|
||||||
|
|
||||||
mgr._syncToggleButton(button, layer);
|
const toggle = presentLayerToggle(layer);
|
||||||
|
|
||||||
assert.equal(button.textContent, 'UNCERTAIN');
|
assert.equal(toggle.text, 'UNCERTAIN');
|
||||||
assert.equal(button.dataset.feedState, 'uncertain');
|
assert.equal(toggle.feedState, 'uncertain');
|
||||||
assert.equal(button.disabled, false, 'the lifecycle toggle remains available to reconcile authority');
|
assert.equal(toggle.ariaDisabled, false, 'the lifecycle toggle remains available to reconcile authority');
|
||||||
assert.equal(attributes.get('aria-label'), 'Radio: UNCERTAIN');
|
assert.equal(toggle.ariaLabel, 'Radio: UNCERTAIN');
|
||||||
assert.equal(classes.get('lifecycle-uncertain'), true);
|
assert.equal(toggle.classes['lifecycle-uncertain'], true);
|
||||||
assert.equal(classes.get('feed-nominal'), false);
|
assert.equal(toggle.classes['feed-nominal'], false);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
mgr._buildMetaText(layer),
|
mgr._buildMetaText(layer),
|
||||||
'UNCERTAIN · Radio Browser · lifecycle state requires reconciliation',
|
'UNCERTAIN · Radio Browser · lifecycle state requires reconciliation',
|
||||||
@@ -2729,549 +2657,6 @@ test('destroy settles an explicit refresh waiting behind invalidated periodic wo
|
|||||||
assert.equal(await destroy, true);
|
assert.equal(await destroy, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Per-layer row controls (chips + color legend) ───────────────────────────
|
|
||||||
// The satellites layer is the first consumer: a DENSE catalog chip and a class
|
|
||||||
// legend rendered under its row. The manager owns the DOM and the param write;
|
|
||||||
// the layer only declares what it wants, so the chip can never disagree with
|
|
||||||
// the layer's real state.
|
|
||||||
|
|
||||||
/** DOM double rich enough for the row-controls render path. */
|
|
||||||
function makeControlElement() {
|
|
||||||
const element = {
|
|
||||||
children: [],
|
|
||||||
className: '',
|
|
||||||
dataset: {},
|
|
||||||
style: {},
|
|
||||||
attributes: {},
|
|
||||||
listeners: {},
|
|
||||||
textContent: '',
|
|
||||||
hidden: false,
|
|
||||||
disabled: false,
|
|
||||||
title: '',
|
|
||||||
type: '',
|
|
||||||
classList: { toggle() {} },
|
|
||||||
appendChild(child) { child.parent = this; this.children.push(child); return child; },
|
|
||||||
append(...nodes) { for (const n of nodes) n.parent = this; this.children.push(...nodes); },
|
|
||||||
insertBefore(child, anchor) {
|
|
||||||
child.parent = this;
|
|
||||||
const index = anchor ? this.children.indexOf(anchor) : this.children.length;
|
|
||||||
this.children.splice(index, 0, child);
|
|
||||||
return child;
|
|
||||||
},
|
|
||||||
replaceChildren(...nodes) { this.children = [...nodes]; },
|
|
||||||
remove() {
|
|
||||||
const siblings = this.parent?.children;
|
|
||||||
if (siblings) this.parent.children = siblings.filter((n) => n !== this);
|
|
||||||
// Browsers blur a node the moment it leaves the document. Modelling that
|
|
||||||
// is the whole point: it is exactly what in-place chip reconciliation
|
|
||||||
// exists to avoid, so a regression to rebuild-everything must fail here.
|
|
||||||
if (globalThis.document?.activeElement === this) globalThis.document.activeElement = null;
|
|
||||||
},
|
|
||||||
focus() { if (globalThis.document) globalThis.document.activeElement = this; },
|
|
||||||
addEventListener(name, handler) { this.listeners[name] = handler; },
|
|
||||||
removeEventListener(name, handler) { if (this.listeners[name] === handler) delete this.listeners[name]; },
|
|
||||||
setAttribute(name, value) { this.attributes[name] = String(value); },
|
|
||||||
getAttribute(name) { return this.attributes[name] ?? null; },
|
|
||||||
closest(selector) {
|
|
||||||
const className = selector.slice(1);
|
|
||||||
return String(this.className).split(/\s+/).includes(className) ? this : null;
|
|
||||||
},
|
|
||||||
querySelector(selector) {
|
|
||||||
if (selector.startsWith('[data-layer-id="')) {
|
|
||||||
const id = selector.slice(16, -2);
|
|
||||||
return this.children.find((child) => child.dataset.layerId === id) || null;
|
|
||||||
}
|
|
||||||
const className = selector.startsWith('.') ? selector.slice(1) : '';
|
|
||||||
const visit = (node) => {
|
|
||||||
if (String(node.className).split(/\s+/).includes(className)) return node;
|
|
||||||
for (const child of node.children || []) {
|
|
||||||
const found = visit(child);
|
|
||||||
if (found) return found;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
return visit(this);
|
|
||||||
},
|
|
||||||
set innerHTML(value) { if (value === '') this.children = []; },
|
|
||||||
get innerHTML() { return ''; },
|
|
||||||
};
|
|
||||||
return element;
|
|
||||||
}
|
|
||||||
|
|
||||||
test('keyboard focus survives Data Layer enabling and disabling transitions', async () => {
|
|
||||||
const originalDocument = globalThis.document;
|
|
||||||
globalThis.document = { createElement: makeControlElement, activeElement: null };
|
|
||||||
const mgr = new DataLayerManager({});
|
|
||||||
let releaseEnable;
|
|
||||||
let releaseDisable;
|
|
||||||
const calls = { enable: 0, disable: 0 };
|
|
||||||
mgr.register({
|
|
||||||
id: 'focus-layer',
|
|
||||||
name: 'Focus layer',
|
|
||||||
icon: '',
|
|
||||||
source: 'Local focus fixture',
|
|
||||||
updateInterval: -1,
|
|
||||||
init() {},
|
|
||||||
enable() {
|
|
||||||
calls.enable += 1;
|
|
||||||
return new Promise((resolve) => { releaseEnable = resolve; });
|
|
||||||
},
|
|
||||||
disable() {
|
|
||||||
calls.disable += 1;
|
|
||||||
return new Promise((resolve) => { releaseDisable = resolve; });
|
|
||||||
},
|
|
||||||
update() {},
|
|
||||||
destroy() {},
|
|
||||||
getStats() { return { count: 1, lastUpdate: Date.now() }; },
|
|
||||||
});
|
|
||||||
const container = makeControlElement();
|
|
||||||
|
|
||||||
const waitFor = async (predicate, label) => {
|
|
||||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
||||||
if (predicate()) return;
|
|
||||||
await Promise.resolve();
|
|
||||||
}
|
|
||||||
assert.fail(`timed out waiting for ${label}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
mgr.buildTogglePanel(container);
|
|
||||||
const row = container.querySelector('[data-layer-id="focus-layer"]');
|
|
||||||
const button = row.querySelector('.data-toggle-btn');
|
|
||||||
button.focus();
|
|
||||||
|
|
||||||
const enablingClick = button.listeners.click();
|
|
||||||
await waitFor(() => typeof releaseEnable === 'function', 'enable transition');
|
|
||||||
assert.equal(globalThis.document.activeElement, button);
|
|
||||||
assert.equal(button.disabled, false, 'ENABLING never uses native disabled');
|
|
||||||
assert.equal(button.getAttribute('aria-disabled'), 'true');
|
|
||||||
assert.equal(button.getAttribute('aria-busy'), 'true');
|
|
||||||
assert.equal(button.textContent, 'ENABLING');
|
|
||||||
await button.listeners.click();
|
|
||||||
assert.equal(calls.enable, 1, 'repeat activation is inert while enabling');
|
|
||||||
assert.equal(mgr.layers.get('focus-layer').visibilityIntentEpoch, 1);
|
|
||||||
|
|
||||||
releaseEnable();
|
|
||||||
await enablingClick;
|
|
||||||
assert.equal(globalThis.document.activeElement, button);
|
|
||||||
assert.equal(button.disabled, false);
|
|
||||||
assert.equal(button.getAttribute('aria-disabled'), 'false');
|
|
||||||
assert.equal(button.getAttribute('aria-busy'), 'false');
|
|
||||||
assert.equal(mgr.isEnabled('focus-layer'), true);
|
|
||||||
|
|
||||||
const disablingClick = button.listeners.click();
|
|
||||||
await waitFor(() => typeof releaseDisable === 'function', 'disable transition');
|
|
||||||
assert.equal(globalThis.document.activeElement, button);
|
|
||||||
assert.equal(button.disabled, false, 'DISABLING never uses native disabled');
|
|
||||||
assert.equal(button.getAttribute('aria-disabled'), 'true');
|
|
||||||
assert.equal(button.getAttribute('aria-busy'), 'true');
|
|
||||||
assert.equal(button.textContent, 'DISABLING');
|
|
||||||
await button.listeners.click();
|
|
||||||
assert.equal(calls.disable, 1, 'repeat activation is inert while disabling');
|
|
||||||
assert.equal(mgr.layers.get('focus-layer').visibilityIntentEpoch, 2);
|
|
||||||
|
|
||||||
releaseDisable();
|
|
||||||
await disablingClick;
|
|
||||||
assert.equal(globalThis.document.activeElement, button);
|
|
||||||
assert.equal(button.disabled, false);
|
|
||||||
assert.equal(button.getAttribute('aria-disabled'), 'false');
|
|
||||||
assert.equal(button.getAttribute('aria-busy'), 'false');
|
|
||||||
assert.equal(mgr.isEnabled('focus-layer'), false);
|
|
||||||
} finally {
|
|
||||||
releaseEnable?.();
|
|
||||||
releaseDisable?.();
|
|
||||||
await mgr.destroyAll();
|
|
||||||
if (originalDocument === undefined) delete globalThis.document;
|
|
||||||
else globalThis.document = originalDocument;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a failed Data Layer transition clears busy state without losing keyboard focus', async () => {
|
|
||||||
const originalDocument = globalThis.document;
|
|
||||||
const originalWarn = console.warn;
|
|
||||||
globalThis.document = { createElement: makeControlElement, activeElement: null };
|
|
||||||
console.warn = () => {};
|
|
||||||
const mgr = new DataLayerManager({});
|
|
||||||
let rejectEnable;
|
|
||||||
let enableCalls = 0;
|
|
||||||
mgr.register({
|
|
||||||
id: 'focus-failure-layer',
|
|
||||||
name: 'Focus failure layer',
|
|
||||||
icon: '',
|
|
||||||
source: 'Local focus fixture',
|
|
||||||
updateInterval: -1,
|
|
||||||
init() {},
|
|
||||||
enable() {
|
|
||||||
enableCalls += 1;
|
|
||||||
if (enableCalls > 1) return true;
|
|
||||||
return new Promise((resolve, reject) => { rejectEnable = reject; });
|
|
||||||
},
|
|
||||||
disable() { return true; },
|
|
||||||
update() {},
|
|
||||||
destroy() {},
|
|
||||||
getStats() { return { count: 0, lastUpdate: null }; },
|
|
||||||
});
|
|
||||||
const container = makeControlElement();
|
|
||||||
|
|
||||||
try {
|
|
||||||
mgr.buildTogglePanel(container);
|
|
||||||
const button = container
|
|
||||||
.querySelector('[data-layer-id="focus-failure-layer"]')
|
|
||||||
.querySelector('.data-toggle-btn');
|
|
||||||
button.focus();
|
|
||||||
|
|
||||||
const failedClick = button.listeners.click();
|
|
||||||
for (let attempt = 0; attempt < 20 && typeof rejectEnable !== 'function'; attempt += 1) {
|
|
||||||
await Promise.resolve();
|
|
||||||
}
|
|
||||||
assert.equal(typeof rejectEnable, 'function');
|
|
||||||
assert.equal(globalThis.document.activeElement, button);
|
|
||||||
assert.equal(button.getAttribute('aria-disabled'), 'true');
|
|
||||||
assert.equal(button.getAttribute('aria-busy'), 'true');
|
|
||||||
|
|
||||||
rejectEnable(new Error('QA enable rejected'));
|
|
||||||
await failedClick;
|
|
||||||
assert.equal(globalThis.document.activeElement, button, 'failure settlement preserves focus');
|
|
||||||
assert.equal(button.disabled, false);
|
|
||||||
assert.equal(button.getAttribute('aria-disabled'), 'false');
|
|
||||||
assert.equal(button.getAttribute('aria-busy'), 'false');
|
|
||||||
assert.equal(button.textContent, 'OFF');
|
|
||||||
assert.deepEqual(mgr.getLayerLifecycleState('focus-failure-layer'), {
|
|
||||||
enabled: false,
|
|
||||||
lifecycleState: 'disabled',
|
|
||||||
uncertain: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
await button.listeners.click();
|
|
||||||
assert.equal(enableCalls, 2, 'the settled control accepts a later retry');
|
|
||||||
assert.equal(globalThis.document.activeElement, button);
|
|
||||||
assert.equal(mgr.isEnabled('focus-failure-layer'), true);
|
|
||||||
} finally {
|
|
||||||
rejectEnable?.(new Error('test cleanup'));
|
|
||||||
console.warn = originalWarn;
|
|
||||||
await mgr.destroyAll();
|
|
||||||
if (originalDocument === undefined) delete globalThis.document;
|
|
||||||
else globalThis.document = originalDocument;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Collect every node in a rendered subtree carrying `className`. */
|
|
||||||
function collectByClass(node, className) {
|
|
||||||
const found = [];
|
|
||||||
const visit = (current) => {
|
|
||||||
if (String(current.className).split(/\s+/).includes(className)) found.push(current);
|
|
||||||
for (const child of current.children || []) visit(child);
|
|
||||||
};
|
|
||||||
visit(node);
|
|
||||||
return found;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A layer that declares a two-state chip plus a legend, like satellites. */
|
|
||||||
function makeRowControlLayer() {
|
|
||||||
let mode = 'core';
|
|
||||||
return {
|
|
||||||
get mode() { return mode; },
|
|
||||||
module: {
|
|
||||||
id: 'satellites',
|
|
||||||
name: 'Satellites',
|
|
||||||
icon: '',
|
|
||||||
source: 'CelesTrak',
|
|
||||||
updateInterval: -1,
|
|
||||||
async init() {},
|
|
||||||
enable() {},
|
|
||||||
disable() {},
|
|
||||||
async update() {},
|
|
||||||
getStats() { return { count: 3, lastUpdate: Date.now() }; },
|
|
||||||
setParams(params) { if (params.catalog) mode = params.catalog; },
|
|
||||||
getParams() { return { catalog: mode }; },
|
|
||||||
getRowControls() {
|
|
||||||
const dense = mode === 'dense';
|
|
||||||
return {
|
|
||||||
chips: [{
|
|
||||||
id: 'catalog',
|
|
||||||
label: 'DENSE',
|
|
||||||
active: dense,
|
|
||||||
title: 'toggle the dense catalog',
|
|
||||||
params: { catalog: dense ? 'core' : 'dense' },
|
|
||||||
}],
|
|
||||||
legend: [
|
|
||||||
{ klass: 'nav', label: 'NAV', color: '#4fd8ff', blurb: 'GNSS', count: 2 },
|
|
||||||
{ klass: 'geo', label: 'GEO', color: '#c89bff', blurb: 'belt', count: 5 },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
test('a layer that declares row controls renders its chips and color legend', async () => {
|
|
||||||
const originalDocument = globalThis.document;
|
|
||||||
globalThis.document = { createElement: makeControlElement };
|
|
||||||
const mgr = new DataLayerManager({});
|
|
||||||
const layer = makeRowControlLayer();
|
|
||||||
mgr.register(layer.module);
|
|
||||||
const container = makeControlElement();
|
|
||||||
|
|
||||||
try {
|
|
||||||
mgr.buildTogglePanel(container);
|
|
||||||
const row = container.querySelector('[data-layer-id="satellites"]');
|
|
||||||
const controls = row.querySelector('.data-toggle-controls');
|
|
||||||
assert.ok(controls, 'the row gained a controls block');
|
|
||||||
// Disabled layers stay quiet — no chip, no legend.
|
|
||||||
assert.equal(controls.hidden, true);
|
|
||||||
|
|
||||||
assert.equal(await mgr.setEnabled('satellites', true), true);
|
|
||||||
mgr._refreshTogglePanel();
|
|
||||||
assert.equal(controls.hidden, false);
|
|
||||||
|
|
||||||
const chips = collectByClass(controls, 'data-toggle-chip');
|
|
||||||
assert.equal(chips.length, 1);
|
|
||||||
assert.equal(chips[0].textContent, 'DENSE');
|
|
||||||
assert.equal(chips[0].dataset.chipId, 'catalog');
|
|
||||||
assert.equal(chips[0].attributes['aria-pressed'], 'false');
|
|
||||||
assert.equal(chips[0].title, 'toggle the dense catalog');
|
|
||||||
|
|
||||||
const swatches = collectByClass(controls, 'data-toggle-legend-swatch');
|
|
||||||
assert.deepEqual(swatches.map((s) => s.style.background), ['#4fd8ff', '#c89bff'],
|
|
||||||
'each legend swatch is painted the exact class color');
|
|
||||||
const items = collectByClass(controls, 'data-toggle-legend-item');
|
|
||||||
assert.deepEqual(items.map((i) => i.title), ['GNSS', 'belt']);
|
|
||||||
assert.equal(items.length, 2);
|
|
||||||
} finally {
|
|
||||||
await mgr.destroyAll();
|
|
||||||
if (originalDocument === undefined) delete globalThis.document;
|
|
||||||
else globalThis.document = originalDocument;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('clicking a row chip applies the params it declared and re-renders', async () => {
|
|
||||||
const originalDocument = globalThis.document;
|
|
||||||
globalThis.document = { createElement: makeControlElement };
|
|
||||||
const mgr = new DataLayerManager({});
|
|
||||||
const layer = makeRowControlLayer();
|
|
||||||
mgr.register(layer.module);
|
|
||||||
const container = makeControlElement();
|
|
||||||
|
|
||||||
try {
|
|
||||||
mgr.buildTogglePanel(container);
|
|
||||||
assert.equal(await mgr.setEnabled('satellites', true), true);
|
|
||||||
const row = container.querySelector('[data-layer-id="satellites"]');
|
|
||||||
const controls = row.querySelector('.data-toggle-controls');
|
|
||||||
const chip = collectByClass(controls, 'data-toggle-chip')[0];
|
|
||||||
|
|
||||||
controls.listeners.click({ target: chip });
|
|
||||||
assert.equal(layer.mode, 'dense', 'the chip wrote the params it declared');
|
|
||||||
// setLayerParams refreshes the panel, so the chip already reflects the flip.
|
|
||||||
const afterOn = collectByClass(controls, 'data-toggle-chip')[0];
|
|
||||||
assert.equal(afterOn.attributes['aria-pressed'], 'true');
|
|
||||||
assert.equal(afterOn.className.includes('active'), true);
|
|
||||||
|
|
||||||
controls.listeners.click({ target: afterOn });
|
|
||||||
assert.equal(layer.mode, 'core', 'the chip toggles back rather than latching');
|
|
||||||
assert.equal(collectByClass(controls, 'data-toggle-chip')[0].attributes['aria-pressed'], 'false');
|
|
||||||
} finally {
|
|
||||||
await mgr.destroyAll();
|
|
||||||
if (originalDocument === undefined) delete globalThis.document;
|
|
||||||
else globalThis.document = originalDocument;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a click outside a chip is inert, and a throwing layer cannot blank the panel', async () => {
|
|
||||||
const originalDocument = globalThis.document;
|
|
||||||
globalThis.document = { createElement: makeControlElement };
|
|
||||||
const warn = console.warn;
|
|
||||||
console.warn = () => {};
|
|
||||||
const mgr = new DataLayerManager({});
|
|
||||||
const layer = makeRowControlLayer();
|
|
||||||
mgr.register(layer.module);
|
|
||||||
const container = makeControlElement();
|
|
||||||
|
|
||||||
try {
|
|
||||||
mgr.buildTogglePanel(container);
|
|
||||||
assert.equal(await mgr.setEnabled('satellites', true), true);
|
|
||||||
const row = container.querySelector('[data-layer-id="satellites"]');
|
|
||||||
const controls = row.querySelector('.data-toggle-controls');
|
|
||||||
|
|
||||||
const legendItem = collectByClass(controls, 'data-toggle-legend-item')[0];
|
|
||||||
controls.listeners.click({ target: legendItem });
|
|
||||||
assert.equal(layer.mode, 'core', 'the legend is not a control');
|
|
||||||
controls.listeners.click({ target: { closest: () => null } });
|
|
||||||
assert.equal(layer.mode, 'core');
|
|
||||||
|
|
||||||
layer.module.getRowControls = () => { throw new Error('boom'); };
|
|
||||||
mgr._refreshTogglePanel();
|
|
||||||
assert.equal(controls.hidden, true, 'a throwing layer collapses to an empty block');
|
|
||||||
assert.ok(container.querySelector('[data-layer-id="satellites"]'), 'the row itself survives');
|
|
||||||
} finally {
|
|
||||||
console.warn = warn;
|
|
||||||
await mgr.destroyAll();
|
|
||||||
if (originalDocument === undefined) delete globalThis.document;
|
|
||||||
else globalThis.document = originalDocument;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('keyboard focus on a chip survives the refresh its own click triggers', async () => {
|
|
||||||
// _syncRowControls runs on EVERY panel refresh, including the one the chip's
|
|
||||||
// own click triggers. Rebuilding the button would blur it every time, so a
|
|
||||||
// keyboard user loses their place on activation.
|
|
||||||
const originalDocument = globalThis.document;
|
|
||||||
globalThis.document = { createElement: makeControlElement, activeElement: null };
|
|
||||||
const mgr = new DataLayerManager({});
|
|
||||||
const layer = makeRowControlLayer();
|
|
||||||
mgr.register(layer.module);
|
|
||||||
const container = makeControlElement();
|
|
||||||
|
|
||||||
try {
|
|
||||||
mgr.buildTogglePanel(container);
|
|
||||||
assert.equal(await mgr.setEnabled('satellites', true), true);
|
|
||||||
const controls = container
|
|
||||||
.querySelector('[data-layer-id="satellites"]')
|
|
||||||
.querySelector('.data-toggle-controls');
|
|
||||||
const chip = collectByClass(controls, 'data-toggle-chip')[0];
|
|
||||||
|
|
||||||
chip.focus();
|
|
||||||
assert.equal(globalThis.document.activeElement, chip, 'the chip starts focused');
|
|
||||||
|
|
||||||
controls.listeners.click({ target: chip });
|
|
||||||
assert.equal(globalThis.document.activeElement, chip,
|
|
||||||
'activating the chip does not blur it');
|
|
||||||
assert.equal(collectByClass(controls, 'data-toggle-chip')[0], chip,
|
|
||||||
'the SAME button node is reused across the click-driven refresh');
|
|
||||||
|
|
||||||
mgr._refreshTogglePanel();
|
|
||||||
mgr._refreshTogglePanel();
|
|
||||||
assert.equal(globalThis.document.activeElement, chip,
|
|
||||||
'repeated refreshes never steal focus');
|
|
||||||
// Legend entries hold no focus, so they may be replaced — never duplicated.
|
|
||||||
assert.equal(collectByClass(controls, 'data-toggle-legend-item').length, 2);
|
|
||||||
|
|
||||||
// ...and a chip that genuinely goes away still releases focus.
|
|
||||||
layer.module.getRowControls = () => ({ chips: [], legend: [] });
|
|
||||||
mgr._refreshTogglePanel();
|
|
||||||
assert.equal(globalThis.document.activeElement, null);
|
|
||||||
} finally {
|
|
||||||
await mgr.destroyAll();
|
|
||||||
if (originalDocument === undefined) delete globalThis.document;
|
|
||||||
else globalThis.document = originalDocument;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('an async layer pushes its own row refresh, and a busy chip refuses clicks', async () => {
|
|
||||||
const originalDocument = globalThis.document;
|
|
||||||
globalThis.document = { createElement: makeControlElement };
|
|
||||||
const mgr = new DataLayerManager({});
|
|
||||||
|
|
||||||
let settled = false;
|
|
||||||
let writes = 0;
|
|
||||||
const module = {
|
|
||||||
id: 'satellites',
|
|
||||||
name: 'Satellites',
|
|
||||||
icon: '',
|
|
||||||
source: 'CelesTrak',
|
|
||||||
updateInterval: -1,
|
|
||||||
async init() {},
|
|
||||||
enable() {},
|
|
||||||
disable() {},
|
|
||||||
async update() {},
|
|
||||||
getStats() { return { count: 1, lastUpdate: Date.now() }; },
|
|
||||||
setParams() { writes += 1; },
|
|
||||||
getRowControls() {
|
|
||||||
return {
|
|
||||||
chips: [{
|
|
||||||
id: 'catalog',
|
|
||||||
label: settled ? 'DENSE' : 'DENSE ···',
|
|
||||||
active: settled,
|
|
||||||
busy: !settled,
|
|
||||||
disabled: !settled,
|
|
||||||
state: settled ? 'active' : 'loading',
|
|
||||||
title: 'x',
|
|
||||||
params: { catalog: 'core' },
|
|
||||||
}],
|
|
||||||
legend: [],
|
|
||||||
};
|
|
||||||
},
|
|
||||||
setRowControlsListener(fn) { module._listener = fn; },
|
|
||||||
};
|
|
||||||
mgr.register(module);
|
|
||||||
const container = makeControlElement();
|
|
||||||
|
|
||||||
try {
|
|
||||||
mgr.buildTogglePanel(container);
|
|
||||||
assert.equal(await mgr.setEnabled('satellites', true), true);
|
|
||||||
assert.equal(typeof module._listener, 'function', 'the manager installed its listener');
|
|
||||||
|
|
||||||
const controls = container
|
|
||||||
.querySelector('[data-layer-id="satellites"]')
|
|
||||||
.querySelector('.data-toggle-controls');
|
|
||||||
const chip = collectByClass(controls, 'data-toggle-chip')[0];
|
|
||||||
assert.equal(chip.textContent, 'DENSE ···');
|
|
||||||
assert.equal(chip.disabled, true);
|
|
||||||
assert.equal(chip.attributes['aria-busy'], 'true');
|
|
||||||
assert.equal(chip.attributes['aria-pressed'], 'false', 'busy is never reported as active');
|
|
||||||
assert.equal(chip.className.includes('chip-loading'), true);
|
|
||||||
|
|
||||||
controls.listeners.click({ target: chip });
|
|
||||||
assert.equal(writes, 0, 'a disabled chip is inert');
|
|
||||||
|
|
||||||
// The layer settles and pushes its own refresh — no panel poll involved.
|
|
||||||
settled = true;
|
|
||||||
module._listener();
|
|
||||||
mgr._layerPanel._flushRowControlsRefresh();
|
|
||||||
assert.equal(chip.textContent, 'DENSE');
|
|
||||||
assert.equal(chip.disabled, false);
|
|
||||||
assert.equal(chip.attributes['aria-pressed'], 'true');
|
|
||||||
assert.equal(chip.attributes['aria-busy'], 'false');
|
|
||||||
|
|
||||||
controls.listeners.click({ target: chip });
|
|
||||||
assert.equal(writes, 1, 'the settled chip writes again');
|
|
||||||
} finally {
|
|
||||||
await mgr.destroyAll();
|
|
||||||
if (originalDocument === undefined) delete globalThis.document;
|
|
||||||
else globalThis.document = originalDocument;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a layer that surrenders its row controls hides the block entirely', async () => {
|
|
||||||
// Space Missions borrows the satellite layer with showPoints:false; the row
|
|
||||||
// must not advertise a legend for an empty sky or offer a chip whose write
|
|
||||||
// the dependency owner will silently revert.
|
|
||||||
const originalDocument = globalThis.document;
|
|
||||||
globalThis.document = { createElement: makeControlElement };
|
|
||||||
const mgr = new DataLayerManager({});
|
|
||||||
let surrendered = false;
|
|
||||||
const layer = makeRowControlLayer();
|
|
||||||
const inner = layer.module.getRowControls;
|
|
||||||
layer.module.getRowControls = () => (surrendered ? { chips: [], legend: [] } : inner());
|
|
||||||
mgr.register(layer.module);
|
|
||||||
const container = makeControlElement();
|
|
||||||
|
|
||||||
try {
|
|
||||||
mgr.buildTogglePanel(container);
|
|
||||||
assert.equal(await mgr.setEnabled('satellites', true), true);
|
|
||||||
const controls = container
|
|
||||||
.querySelector('[data-layer-id="satellites"]')
|
|
||||||
.querySelector('.data-toggle-controls');
|
|
||||||
assert.equal(controls.hidden, false);
|
|
||||||
assert.equal(collectByClass(controls, 'data-toggle-chip').length, 1);
|
|
||||||
|
|
||||||
surrendered = true;
|
|
||||||
mgr._refreshTogglePanel();
|
|
||||||
assert.equal(controls.hidden, true);
|
|
||||||
assert.equal(collectByClass(controls, 'data-toggle-chip').length, 0,
|
|
||||||
'the chip is removed, not merely hidden behind a style');
|
|
||||||
assert.equal(collectByClass(controls, 'data-toggle-legend-item').length, 0);
|
|
||||||
|
|
||||||
surrendered = false;
|
|
||||||
mgr._refreshTogglePanel();
|
|
||||||
assert.equal(controls.hidden, false, 'the row returns when the owner releases it');
|
|
||||||
assert.equal(collectByClass(controls, 'data-toggle-chip').length, 1);
|
|
||||||
} finally {
|
|
||||||
await mgr.destroyAll();
|
|
||||||
if (originalDocument === undefined) delete globalThis.document;
|
|
||||||
else globalThis.document = originalDocument;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('layer metadata shows a guidance prompt without reporting it as a fault', () => {
|
test('layer metadata shows a guidance prompt without reporting it as a fault', () => {
|
||||||
const mgr = new DataLayerManager({});
|
const mgr = new DataLayerManager({});
|
||||||
const text = mgr._buildMetaText({
|
const text = mgr._buildMetaText({
|
||||||
@@ -3283,207 +2668,6 @@ test('layer metadata shows a guidance prompt without reporting it as a fault', (
|
|||||||
assert.doesNotMatch(text, /UNAVAILABLE|DEGRADED/);
|
assert.doesNotMatch(text, /UNAVAILABLE|DEGRADED/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
test('panel remount releases old listeners and destruction revokes retained controls', async () => {
|
|
||||||
const originalDocument = globalThis.document;
|
|
||||||
globalThis.document = { createElement: makeControlElement, activeElement: null };
|
|
||||||
const manager = new DataLayerManager({});
|
|
||||||
const layer = makeSlowLayer('lifecycle-panel', { updateInterval: -1 });
|
|
||||||
let rowListener;
|
|
||||||
layer.module.getRowControls = () => ({ chips: [] });
|
|
||||||
layer.module.setRowControlsListener = (listener) => { rowListener = listener; };
|
|
||||||
manager.register(layer.module);
|
|
||||||
try {
|
|
||||||
const first = makeControlElement();
|
|
||||||
manager.buildTogglePanel(first);
|
|
||||||
const oldButton = first.querySelector('.data-toggle-btn');
|
|
||||||
const retainedClick = oldButton.listeners.click;
|
|
||||||
const second = makeControlElement();
|
|
||||||
manager.buildTogglePanel(second);
|
|
||||||
assert.equal(oldButton.listeners.click, undefined);
|
|
||||||
await retainedClick();
|
|
||||||
assert.equal(layer.calls.enable, 0, 'a revoked generation cannot issue actions');
|
|
||||||
const currentButton = second.querySelector('.data-toggle-btn');
|
|
||||||
assert.equal(typeof currentButton.listeners.click, 'function');
|
|
||||||
assert.equal(typeof rowListener, 'function');
|
|
||||||
await manager.destroyAll();
|
|
||||||
assert.equal(currentButton.listeners.click, undefined);
|
|
||||||
assert.equal(rowListener, null);
|
|
||||||
} finally {
|
|
||||||
await manager.destroyAll();
|
|
||||||
if (originalDocument === undefined) delete globalThis.document;
|
|
||||||
else globalThis.document = originalDocument;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('row action chips use live disabled state and descriptive counts without writing parameters', async () => {
|
|
||||||
const originalDocument = globalThis.document;
|
|
||||||
globalThis.document = { createElement: makeControlElement };
|
|
||||||
const mgr = new DataLayerManager({});
|
|
||||||
const layer = makeRowControlLayer();
|
|
||||||
let calls = 0, blocked = false;
|
|
||||||
layer.module.getStats = () => ({ count: 3, countLabel: '3 nearby' });
|
|
||||||
layer.module.getRowControls = () => ({ chips: [{ id: 'find', label: 'SHOW NEAREST', disabled: blocked, onClick: () => calls++ }] });
|
|
||||||
layer.module.setParams = () => { throw new Error('navigation must not write persistent params'); };
|
|
||||||
mgr.register(layer.module);
|
|
||||||
const container = makeControlElement();
|
|
||||||
try {
|
|
||||||
mgr.buildTogglePanel(container);
|
|
||||||
await mgr.setEnabled('satellites', true);
|
|
||||||
const row = container.querySelector('[data-layer-id="satellites"]');
|
|
||||||
assert.equal(row.querySelector('.data-count').textContent, '3 nearby');
|
|
||||||
const controls = row.querySelector('.data-toggle-controls');
|
|
||||||
const chip = collectByClass(controls, 'data-toggle-chip')[0];
|
|
||||||
controls.listeners.click({ target: chip });
|
|
||||||
assert.equal(calls, 1);
|
|
||||||
blocked = true;
|
|
||||||
controls.listeners.click({ target: chip });
|
|
||||||
assert.equal(calls, 1, 'live disabled descriptor wins before row repaint');
|
|
||||||
blocked = false;
|
|
||||||
await mgr.setEnabled('satellites', false);
|
|
||||||
controls.listeners.click({ target: chip });
|
|
||||||
assert.equal(calls, 1, 'disabled layer cannot navigate');
|
|
||||||
} finally {
|
|
||||||
await mgr.destroyAll();
|
|
||||||
if (originalDocument === undefined) delete globalThis.document;
|
|
||||||
else globalThis.document = originalDocument;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const scheduler of ['animation frame', 'timeout']) {
|
|
||||||
test(`row notifications coalesce with ${scheduler} and stop after destroy`, async (t) => {
|
|
||||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
|
||||||
const frames = new Map();
|
|
||||||
let nextFrame = 0;
|
|
||||||
const originalRaf = globalThis.requestAnimationFrame;
|
|
||||||
const originalCancel = globalThis.cancelAnimationFrame;
|
|
||||||
globalThis.requestAnimationFrame = scheduler === 'animation frame'
|
|
||||||
? (callback) => { frames.set(++nextFrame, callback); return nextFrame; }
|
|
||||||
: undefined;
|
|
||||||
globalThis.cancelAnimationFrame = (id) => frames.delete(id);
|
|
||||||
const originalDocument = globalThis.document;
|
|
||||||
globalThis.document = { createElement: makeControlElement };
|
|
||||||
const manager = new DataLayerManager({});
|
|
||||||
const layer = makeSlowLayer('wind', { updateInterval: -1 });
|
|
||||||
let notify;
|
|
||||||
layer.module.getRowControls = () => ({ chips: [] });
|
|
||||||
layer.module.setRowControlsListener = (listener) => { if (listener) notify = listener; };
|
|
||||||
manager.register(layer.module);
|
|
||||||
try {
|
|
||||||
manager.buildTogglePanel(makeControlElement());
|
|
||||||
const panel = manager._layerPanel;
|
|
||||||
let refreshes = 0;
|
|
||||||
const refresh = panel._refreshTogglePanel.bind(panel);
|
|
||||||
panel._refreshTogglePanel = () => { refreshes++; refresh(); };
|
|
||||||
for (let i = 0; i < 10; i++) notify();
|
|
||||||
assert.equal(refreshes, 0, 'notifications defer refresh');
|
|
||||||
if (scheduler === 'animation frame') assert.equal(frames.size, 1);
|
|
||||||
panel._flushRowControlsRefresh();
|
|
||||||
assert.equal(refreshes, 1);
|
|
||||||
panel._flushRowControlsRefresh();
|
|
||||||
t.mock.timers.tick(0);
|
|
||||||
assert.equal(frames.size, 0, 'flush cancels the pending frame');
|
|
||||||
assert.equal(refreshes, 1, 'flush does not leave duplicate work');
|
|
||||||
notify();
|
|
||||||
notify();
|
|
||||||
if (scheduler === 'animation frame') {
|
|
||||||
const [id, callback] = frames.entries().next().value;
|
|
||||||
frames.delete(id);
|
|
||||||
callback();
|
|
||||||
} else t.mock.timers.tick(0);
|
|
||||||
assert.equal(refreshes, 2, 'scheduled callback refreshes once');
|
|
||||||
notify();
|
|
||||||
panel.destroy();
|
|
||||||
assert.equal(frames.size, 0, 'destroy cancels pending work');
|
|
||||||
notify();
|
|
||||||
panel._flushRowControlsRefresh();
|
|
||||||
t.mock.timers.tick(0);
|
|
||||||
assert.equal(refreshes, 2, 'retained notifications after destroy are inert');
|
|
||||||
} finally {
|
|
||||||
await manager.destroyAll();
|
|
||||||
if (originalDocument === undefined) delete globalThis.document;
|
|
||||||
else globalThis.document = originalDocument;
|
|
||||||
if (originalRaf === undefined) delete globalThis.requestAnimationFrame;
|
|
||||||
else globalThis.requestAnimationFrame = originalRaf;
|
|
||||||
if (originalCancel === undefined) delete globalThis.cancelAnimationFrame;
|
|
||||||
else globalThis.cancelAnimationFrame = originalCancel;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
test('row info and legends reconcile in place while chips retain focus and order', async () => {
|
|
||||||
const originalDocument = globalThis.document;
|
|
||||||
globalThis.document = { createElement: makeControlElement, activeElement: null };
|
|
||||||
const manager = new DataLayerManager({});
|
|
||||||
const layer = makeSlowLayer('wind', { updateInterval: -1 });
|
|
||||||
let controls = {
|
|
||||||
chips: [{ id: 'one', label: 'One' }],
|
|
||||||
legend: [{ label: 'Low', color: '#0000ff', count: 1, blurb: 'Low values' }],
|
|
||||||
info: 'Forecast',
|
|
||||||
infoTitle: 'Source time',
|
|
||||||
};
|
|
||||||
layer.module.getRowControls = () => controls;
|
|
||||||
manager.register(layer.module);
|
|
||||||
try {
|
|
||||||
const container = makeControlElement();
|
|
||||||
manager.buildTogglePanel(container);
|
|
||||||
await manager.setEnabled('wind', true);
|
|
||||||
const row = container.querySelector('.data-toggle-controls');
|
|
||||||
const chip = row.children[0];
|
|
||||||
let legend = row.children[1];
|
|
||||||
const info = row.children[2];
|
|
||||||
chip.focus();
|
|
||||||
let writes = 0;
|
|
||||||
for (const key of ['textContent', 'title']) {
|
|
||||||
let value = info[key];
|
|
||||||
Object.defineProperty(info, key, {
|
|
||||||
get: () => value,
|
|
||||||
set: (next) => { writes++; value = next; },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
controls = structuredClone(controls);
|
|
||||||
manager._refreshTogglePanel();
|
|
||||||
assert.equal(row.children[1], legend);
|
|
||||||
assert.equal(row.children[2], info);
|
|
||||||
assert.equal(writes, 0);
|
|
||||||
for (const change of [{ label: 'High' }, { color: '#ff0000' }, { count: 2 }, { blurb: 'High values' }]) {
|
|
||||||
Object.assign(controls.legend[0], change);
|
|
||||||
manager._refreshTogglePanel();
|
|
||||||
assert.notEqual(row.children[1], legend);
|
|
||||||
legend = row.children[1];
|
|
||||||
assert.equal(row.children[2], info);
|
|
||||||
assert.equal(globalThis.document.activeElement, chip);
|
|
||||||
}
|
|
||||||
controls.info = '<img onerror=alert(1)>';
|
|
||||||
controls.infoTitle = '';
|
|
||||||
controls.chips.push({ id: 'two', label: 'Two' });
|
|
||||||
manager._refreshTogglePanel();
|
|
||||||
assert.deepEqual(row.children.map(node => node.className), [
|
|
||||||
'data-toggle-chip chip-idle', 'data-toggle-chip chip-idle',
|
|
||||||
'data-toggle-legend-item', 'data-toggle-controls-info',
|
|
||||||
]);
|
|
||||||
assert.equal(row.children[2], legend);
|
|
||||||
assert.equal(row.children[3], info);
|
|
||||||
assert.equal(info.textContent, controls.info);
|
|
||||||
assert.equal(info.title, '');
|
|
||||||
assert.equal(info.children.length, 0, 'info remains plain text');
|
|
||||||
controls = { chips: [], legend: [] };
|
|
||||||
manager._refreshTogglePanel();
|
|
||||||
assert.equal(row.hidden, true);
|
|
||||||
assert.equal(info.hidden, true);
|
|
||||||
assert.deepEqual(row.children, [info]);
|
|
||||||
controls = { info: 'Restored', legend: [{ label: 'New', color: '#ffffff' }] };
|
|
||||||
manager._refreshTogglePanel();
|
|
||||||
assert.equal(row.children.at(-1), info);
|
|
||||||
assert.equal(info.hidden, false);
|
|
||||||
assert.equal(info.textContent, 'Restored');
|
|
||||||
} finally {
|
|
||||||
await manager.destroyAll();
|
|
||||||
if (originalDocument === undefined) delete globalThis.document;
|
|
||||||
else globalThis.document = originalDocument;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('refreshLayerStats reaches presentation through the bare lifecycle', async () => {
|
test('refreshLayerStats reaches presentation through the bare lifecycle', async () => {
|
||||||
const { LayerLifecycle } = await import('./lifecycle.js');
|
const { LayerLifecycle } = await import('./lifecycle.js');
|
||||||
const lifecycle = new LayerLifecycle({});
|
const lifecycle = new LayerLifecycle({});
|
||||||
|
|||||||
@@ -0,0 +1,931 @@
|
|||||||
|
// src/data/managerPanel.test.mjs — the manager's layer rows, rendered into a
|
||||||
|
// real DOM: toggles keep keyboard focus through transitions, row chips write
|
||||||
|
// the params they declare, and legends, info lines and lists follow the layer.
|
||||||
|
import '../testSupport/registerDom.mjs';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { DataLayerManager } from './manager.js';
|
||||||
|
import { assertSameNode } from '../testSupport/domAssert.mjs';
|
||||||
|
|
||||||
|
/** Build a mock layer whose init/update resolve on the next microtask. */
|
||||||
|
function makeSlowLayer(id, { updateInterval = 1000 } = {}) {
|
||||||
|
const calls = { enable: 0, disable: 0, update: 0, init: 0 };
|
||||||
|
return {
|
||||||
|
calls,
|
||||||
|
module: {
|
||||||
|
id,
|
||||||
|
name: id,
|
||||||
|
icon: '',
|
||||||
|
source: 'test',
|
||||||
|
updateInterval,
|
||||||
|
async init() {
|
||||||
|
calls.init++;
|
||||||
|
await Promise.resolve();
|
||||||
|
},
|
||||||
|
enable() {
|
||||||
|
calls.enable++;
|
||||||
|
},
|
||||||
|
disable() {
|
||||||
|
calls.disable++;
|
||||||
|
},
|
||||||
|
async update() {
|
||||||
|
calls.update++;
|
||||||
|
await Promise.resolve();
|
||||||
|
},
|
||||||
|
getStats() {
|
||||||
|
return { count: 0, lastUpdate: null };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A layer that declares a two-state chip plus a legend, like satellites. */
|
||||||
|
function makeRowControlLayer() {
|
||||||
|
let mode = 'core';
|
||||||
|
return {
|
||||||
|
get mode() {
|
||||||
|
return mode;
|
||||||
|
},
|
||||||
|
module: {
|
||||||
|
id: 'satellites',
|
||||||
|
name: 'Satellites',
|
||||||
|
icon: '',
|
||||||
|
source: 'CelesTrak',
|
||||||
|
updateInterval: -1,
|
||||||
|
async init() {},
|
||||||
|
enable() {},
|
||||||
|
disable() {},
|
||||||
|
async update() {},
|
||||||
|
getStats() {
|
||||||
|
return { count: 3, lastUpdate: Date.now() };
|
||||||
|
},
|
||||||
|
setParams(params) {
|
||||||
|
if (params.catalog) mode = params.catalog;
|
||||||
|
},
|
||||||
|
getParams() {
|
||||||
|
return { catalog: mode };
|
||||||
|
},
|
||||||
|
getRowControls() {
|
||||||
|
const dense = mode === 'dense';
|
||||||
|
return {
|
||||||
|
chips: [
|
||||||
|
{
|
||||||
|
id: 'catalog',
|
||||||
|
label: 'DENSE',
|
||||||
|
active: dense,
|
||||||
|
title: 'toggle the dense catalog',
|
||||||
|
params: { catalog: dense ? 'core' : 'dense' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
legend: [
|
||||||
|
{
|
||||||
|
klass: 'nav',
|
||||||
|
label: 'NAV',
|
||||||
|
color: '#4fd8ff',
|
||||||
|
blurb: 'GNSS',
|
||||||
|
count: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
klass: 'geo',
|
||||||
|
label: 'GEO',
|
||||||
|
color: '#c89bff',
|
||||||
|
blurb: 'belt',
|
||||||
|
count: 5,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function panelHost(t) {
|
||||||
|
document.body.replaceChildren();
|
||||||
|
const container = document.body.appendChild(document.createElement('div'));
|
||||||
|
container.id = 'data-toggles';
|
||||||
|
t.after(() => document.body.replaceChildren());
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = (container, id) =>
|
||||||
|
container.querySelector(`[data-layer-id="${id}"]`);
|
||||||
|
const chipsIn = (node) => [...node.querySelectorAll('.data-toggle-chip')];
|
||||||
|
const macrotask = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
async function waitFor(predicate, label) {
|
||||||
|
for (let attempt = 0; attempt < 50; attempt += 1) {
|
||||||
|
if (predicate()) return;
|
||||||
|
await macrotask();
|
||||||
|
}
|
||||||
|
assert.fail(`timed out waiting for ${label}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('renders ordinary layer rows without recreating a panel-hidden coordinator', async (t) => {
|
||||||
|
const container = panelHost(t);
|
||||||
|
const mgr = new DataLayerManager({});
|
||||||
|
const ordinary = makeSlowLayer('flights', { updateInterval: -1 });
|
||||||
|
const coordinator = makeSlowLayer('military-awareness', {
|
||||||
|
updateInterval: -1,
|
||||||
|
});
|
||||||
|
coordinator.module.showInTogglePanel = false;
|
||||||
|
mgr.register(ordinary.module);
|
||||||
|
mgr.register(coordinator.module);
|
||||||
|
try {
|
||||||
|
mgr.buildTogglePanel(container);
|
||||||
|
assert.ok(row(container, 'flights'));
|
||||||
|
assert.ok(!row(container, 'military-awareness'));
|
||||||
|
assert.equal(await mgr.setEnabled('military-awareness', true), true);
|
||||||
|
mgr._refreshTogglePanel();
|
||||||
|
assert.ok(!row(container, 'military-awareness'));
|
||||||
|
assert.equal(
|
||||||
|
mgr.getAll().find(({ id }) => id === 'military-awareness').enabled,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await mgr.destroyAll();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keyboard focus survives Data Layer enabling and disabling transitions', async (t) => {
|
||||||
|
const container = panelHost(t);
|
||||||
|
const mgr = new DataLayerManager({});
|
||||||
|
let releaseEnable;
|
||||||
|
let releaseDisable;
|
||||||
|
const calls = { enable: 0, disable: 0 };
|
||||||
|
mgr.register({
|
||||||
|
id: 'focus-layer',
|
||||||
|
name: 'Focus layer',
|
||||||
|
icon: '',
|
||||||
|
source: 'Local focus fixture',
|
||||||
|
updateInterval: -1,
|
||||||
|
init() {},
|
||||||
|
enable() {
|
||||||
|
calls.enable += 1;
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
releaseEnable = resolve;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
disable() {
|
||||||
|
calls.disable += 1;
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
releaseDisable = resolve;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
update() {},
|
||||||
|
destroy() {},
|
||||||
|
getStats() {
|
||||||
|
return { count: 1, lastUpdate: Date.now() };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
mgr.buildTogglePanel(container);
|
||||||
|
const button = row(container, 'focus-layer').querySelector(
|
||||||
|
'.data-toggle-btn',
|
||||||
|
);
|
||||||
|
button.focus();
|
||||||
|
|
||||||
|
button.click();
|
||||||
|
await waitFor(
|
||||||
|
() => typeof releaseEnable === 'function',
|
||||||
|
'enable transition',
|
||||||
|
);
|
||||||
|
assertSameNode(document.activeElement, button);
|
||||||
|
assert.equal(button.disabled, false, 'ENABLING never uses native disabled');
|
||||||
|
assert.equal(button.getAttribute('aria-disabled'), 'true');
|
||||||
|
assert.equal(button.getAttribute('aria-busy'), 'true');
|
||||||
|
assert.equal(button.textContent, 'ENABLING');
|
||||||
|
button.click();
|
||||||
|
await macrotask();
|
||||||
|
assert.equal(calls.enable, 1, 'repeat activation is inert while enabling');
|
||||||
|
assert.equal(mgr.layers.get('focus-layer').visibilityIntentEpoch, 1);
|
||||||
|
|
||||||
|
releaseEnable();
|
||||||
|
await waitFor(
|
||||||
|
() =>
|
||||||
|
mgr.isEnabled('focus-layer') &&
|
||||||
|
button.getAttribute('aria-busy') === 'false',
|
||||||
|
'enabled',
|
||||||
|
);
|
||||||
|
assertSameNode(document.activeElement, button);
|
||||||
|
assertSameNode(
|
||||||
|
row(container, 'focus-layer').querySelector('.data-toggle-btn'),
|
||||||
|
button,
|
||||||
|
'the SAME button node is reused across the transition',
|
||||||
|
);
|
||||||
|
assert.equal(button.disabled, false);
|
||||||
|
assert.equal(button.getAttribute('aria-disabled'), 'false');
|
||||||
|
|
||||||
|
button.click();
|
||||||
|
await waitFor(
|
||||||
|
() => typeof releaseDisable === 'function',
|
||||||
|
'disable transition',
|
||||||
|
);
|
||||||
|
assertSameNode(document.activeElement, button);
|
||||||
|
assert.equal(
|
||||||
|
button.disabled,
|
||||||
|
false,
|
||||||
|
'DISABLING never uses native disabled',
|
||||||
|
);
|
||||||
|
assert.equal(button.getAttribute('aria-disabled'), 'true');
|
||||||
|
assert.equal(button.getAttribute('aria-busy'), 'true');
|
||||||
|
assert.equal(button.textContent, 'DISABLING');
|
||||||
|
button.click();
|
||||||
|
await macrotask();
|
||||||
|
assert.equal(
|
||||||
|
calls.disable,
|
||||||
|
1,
|
||||||
|
'repeat activation is inert while disabling',
|
||||||
|
);
|
||||||
|
assert.equal(mgr.layers.get('focus-layer').visibilityIntentEpoch, 2);
|
||||||
|
|
||||||
|
releaseDisable();
|
||||||
|
await waitFor(
|
||||||
|
() =>
|
||||||
|
!mgr.isEnabled('focus-layer') &&
|
||||||
|
button.getAttribute('aria-busy') === 'false',
|
||||||
|
'disabled',
|
||||||
|
);
|
||||||
|
assertSameNode(document.activeElement, button);
|
||||||
|
assert.equal(button.disabled, false);
|
||||||
|
assert.equal(button.getAttribute('aria-disabled'), 'false');
|
||||||
|
} finally {
|
||||||
|
releaseEnable?.();
|
||||||
|
releaseDisable?.();
|
||||||
|
await mgr.destroyAll();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a failed Data Layer transition clears busy state without losing keyboard focus', async (t) => {
|
||||||
|
const container = panelHost(t);
|
||||||
|
const originalWarn = console.warn;
|
||||||
|
console.warn = () => {};
|
||||||
|
const mgr = new DataLayerManager({});
|
||||||
|
let rejectEnable;
|
||||||
|
let enableCalls = 0;
|
||||||
|
mgr.register({
|
||||||
|
id: 'focus-failure-layer',
|
||||||
|
name: 'Focus failure layer',
|
||||||
|
icon: '',
|
||||||
|
source: 'Local focus fixture',
|
||||||
|
updateInterval: -1,
|
||||||
|
init() {},
|
||||||
|
enable() {
|
||||||
|
enableCalls += 1;
|
||||||
|
if (enableCalls > 1) return true;
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
rejectEnable = reject;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
disable() {
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
update() {},
|
||||||
|
destroy() {},
|
||||||
|
getStats() {
|
||||||
|
return { count: 0, lastUpdate: null };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
mgr.buildTogglePanel(container);
|
||||||
|
const button = row(container, 'focus-failure-layer').querySelector(
|
||||||
|
'.data-toggle-btn',
|
||||||
|
);
|
||||||
|
button.focus();
|
||||||
|
|
||||||
|
button.click();
|
||||||
|
await waitFor(
|
||||||
|
() => typeof rejectEnable === 'function',
|
||||||
|
'enable transition',
|
||||||
|
);
|
||||||
|
assertSameNode(document.activeElement, button);
|
||||||
|
assert.equal(button.getAttribute('aria-disabled'), 'true');
|
||||||
|
assert.equal(button.getAttribute('aria-busy'), 'true');
|
||||||
|
|
||||||
|
rejectEnable(new Error('QA enable rejected'));
|
||||||
|
await waitFor(
|
||||||
|
() => button.getAttribute('aria-busy') === 'false',
|
||||||
|
'failure settlement',
|
||||||
|
);
|
||||||
|
assertSameNode(
|
||||||
|
document.activeElement,
|
||||||
|
button,
|
||||||
|
'failure settlement preserves focus',
|
||||||
|
);
|
||||||
|
assert.equal(button.disabled, false);
|
||||||
|
assert.equal(button.getAttribute('aria-disabled'), 'false');
|
||||||
|
assert.equal(button.textContent, 'OFF');
|
||||||
|
assert.deepEqual(mgr.getLayerLifecycleState('focus-failure-layer'), {
|
||||||
|
enabled: false,
|
||||||
|
lifecycleState: 'disabled',
|
||||||
|
uncertain: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
button.click();
|
||||||
|
await waitFor(() => mgr.isEnabled('focus-failure-layer'), 'retry');
|
||||||
|
assert.equal(enableCalls, 2, 'the settled control accepts a later retry');
|
||||||
|
assertSameNode(document.activeElement, button);
|
||||||
|
} finally {
|
||||||
|
rejectEnable?.(new Error('test cleanup'));
|
||||||
|
console.warn = originalWarn;
|
||||||
|
await mgr.destroyAll();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a layer that declares row controls renders its chips and color legend', async (t) => {
|
||||||
|
const container = panelHost(t);
|
||||||
|
const mgr = new DataLayerManager({});
|
||||||
|
const layer = makeRowControlLayer();
|
||||||
|
mgr.register(layer.module);
|
||||||
|
try {
|
||||||
|
mgr.buildTogglePanel(container);
|
||||||
|
const controls = row(container, 'satellites').querySelector(
|
||||||
|
'.data-toggle-controls',
|
||||||
|
);
|
||||||
|
assert.ok(controls, 'the row gained a controls block');
|
||||||
|
// Disabled layers stay quiet — no chip, no legend.
|
||||||
|
assert.equal(controls.hidden, true);
|
||||||
|
|
||||||
|
assert.equal(await mgr.setEnabled('satellites', true), true);
|
||||||
|
mgr._refreshTogglePanel();
|
||||||
|
assert.equal(controls.hidden, false);
|
||||||
|
|
||||||
|
const chips = chipsIn(controls);
|
||||||
|
assert.equal(chips.length, 1);
|
||||||
|
assert.equal(chips[0].textContent, 'DENSE');
|
||||||
|
assert.equal(chips[0].dataset.chipId, 'catalog');
|
||||||
|
assert.equal(chips[0].getAttribute('aria-pressed'), 'false');
|
||||||
|
assert.equal(chips[0].title, 'toggle the dense catalog');
|
||||||
|
|
||||||
|
const swatches = [
|
||||||
|
...controls.querySelectorAll('.data-toggle-legend-swatch'),
|
||||||
|
];
|
||||||
|
assert.deepEqual(
|
||||||
|
swatches.map((s) => s.style.background),
|
||||||
|
['#4fd8ff', '#c89bff'],
|
||||||
|
'each legend swatch is painted the exact class color',
|
||||||
|
);
|
||||||
|
const items = [...controls.querySelectorAll('.data-toggle-legend-item')];
|
||||||
|
assert.deepEqual(
|
||||||
|
items.map((i) => i.title),
|
||||||
|
['GNSS', 'belt'],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
items.map((i) => i.textContent),
|
||||||
|
['NAV 2', 'GEO 5'],
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await mgr.destroyAll();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clicking a row chip applies the params it declared and re-renders', async (t) => {
|
||||||
|
const container = panelHost(t);
|
||||||
|
const mgr = new DataLayerManager({});
|
||||||
|
const layer = makeRowControlLayer();
|
||||||
|
mgr.register(layer.module);
|
||||||
|
try {
|
||||||
|
mgr.buildTogglePanel(container);
|
||||||
|
assert.equal(await mgr.setEnabled('satellites', true), true);
|
||||||
|
const controls = row(container, 'satellites').querySelector(
|
||||||
|
'.data-toggle-controls',
|
||||||
|
);
|
||||||
|
const chip = chipsIn(controls)[0];
|
||||||
|
|
||||||
|
chip.click();
|
||||||
|
assert.equal(layer.mode, 'dense', 'the chip wrote the params it declared');
|
||||||
|
// setLayerParams refreshes the panel, so the chip already reflects the flip.
|
||||||
|
assert.equal(chip.getAttribute('aria-pressed'), 'true');
|
||||||
|
assert.equal(chip.classList.contains('active'), true);
|
||||||
|
|
||||||
|
chip.click();
|
||||||
|
assert.equal(
|
||||||
|
layer.mode,
|
||||||
|
'core',
|
||||||
|
'the chip toggles back rather than latching',
|
||||||
|
);
|
||||||
|
assert.equal(chip.getAttribute('aria-pressed'), 'false');
|
||||||
|
} finally {
|
||||||
|
await mgr.destroyAll();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a click outside a chip is inert, and a throwing layer cannot blank the panel', async (t) => {
|
||||||
|
const container = panelHost(t);
|
||||||
|
const warn = console.warn;
|
||||||
|
console.warn = () => {};
|
||||||
|
const mgr = new DataLayerManager({});
|
||||||
|
const layer = makeRowControlLayer();
|
||||||
|
mgr.register(layer.module);
|
||||||
|
try {
|
||||||
|
mgr.buildTogglePanel(container);
|
||||||
|
assert.equal(await mgr.setEnabled('satellites', true), true);
|
||||||
|
const controls = row(container, 'satellites').querySelector(
|
||||||
|
'.data-toggle-controls',
|
||||||
|
);
|
||||||
|
|
||||||
|
controls.querySelector('.data-toggle-legend-item').click();
|
||||||
|
assert.equal(layer.mode, 'core', 'the legend is not a control');
|
||||||
|
controls.click();
|
||||||
|
assert.equal(layer.mode, 'core');
|
||||||
|
|
||||||
|
layer.module.getRowControls = () => {
|
||||||
|
throw new Error('boom');
|
||||||
|
};
|
||||||
|
mgr._refreshTogglePanel();
|
||||||
|
assert.equal(
|
||||||
|
controls.hidden,
|
||||||
|
true,
|
||||||
|
'a throwing layer collapses to an empty block',
|
||||||
|
);
|
||||||
|
assert.ok(row(container, 'satellites'), 'the row itself survives');
|
||||||
|
} finally {
|
||||||
|
console.warn = warn;
|
||||||
|
await mgr.destroyAll();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keyboard focus on a chip survives the refresh its own click triggers', async (t) => {
|
||||||
|
// The panel repaints on EVERY refresh, including the one the chip's own
|
||||||
|
// click triggers. Rebuilding the button would blur it every time, so a
|
||||||
|
// keyboard user loses their place on activation.
|
||||||
|
const container = panelHost(t);
|
||||||
|
const mgr = new DataLayerManager({});
|
||||||
|
const layer = makeRowControlLayer();
|
||||||
|
mgr.register(layer.module);
|
||||||
|
try {
|
||||||
|
mgr.buildTogglePanel(container);
|
||||||
|
assert.equal(await mgr.setEnabled('satellites', true), true);
|
||||||
|
const controls = row(container, 'satellites').querySelector(
|
||||||
|
'.data-toggle-controls',
|
||||||
|
);
|
||||||
|
const chip = chipsIn(controls)[0];
|
||||||
|
|
||||||
|
chip.focus();
|
||||||
|
assertSameNode(document.activeElement, chip, 'the chip starts focused');
|
||||||
|
|
||||||
|
chip.click();
|
||||||
|
assertSameNode(
|
||||||
|
document.activeElement,
|
||||||
|
chip,
|
||||||
|
'activating the chip does not blur it',
|
||||||
|
);
|
||||||
|
assertSameNode(
|
||||||
|
chipsIn(controls)[0],
|
||||||
|
chip,
|
||||||
|
'the SAME button node is reused across the click-driven refresh',
|
||||||
|
);
|
||||||
|
|
||||||
|
mgr._refreshTogglePanel();
|
||||||
|
mgr._refreshTogglePanel();
|
||||||
|
assertSameNode(
|
||||||
|
document.activeElement,
|
||||||
|
chip,
|
||||||
|
'repeated refreshes never steal focus',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
controls.querySelectorAll('.data-toggle-legend-item').length,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ...and a chip that genuinely goes away is removed from the row.
|
||||||
|
layer.module.getRowControls = () => ({ chips: [], legend: [] });
|
||||||
|
mgr._refreshTogglePanel();
|
||||||
|
assert.equal(chip.isConnected, false);
|
||||||
|
} finally {
|
||||||
|
await mgr.destroyAll();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an async layer pushes its own row refresh, and a busy chip refuses clicks', async (t) => {
|
||||||
|
const container = panelHost(t);
|
||||||
|
const mgr = new DataLayerManager({});
|
||||||
|
let settled = false;
|
||||||
|
let writes = 0;
|
||||||
|
const module = {
|
||||||
|
id: 'satellites',
|
||||||
|
name: 'Satellites',
|
||||||
|
icon: '',
|
||||||
|
source: 'CelesTrak',
|
||||||
|
updateInterval: -1,
|
||||||
|
async init() {},
|
||||||
|
enable() {},
|
||||||
|
disable() {},
|
||||||
|
async update() {},
|
||||||
|
getStats() {
|
||||||
|
return { count: 1, lastUpdate: Date.now() };
|
||||||
|
},
|
||||||
|
setParams() {
|
||||||
|
writes += 1;
|
||||||
|
},
|
||||||
|
getRowControls() {
|
||||||
|
return {
|
||||||
|
chips: [
|
||||||
|
{
|
||||||
|
id: 'catalog',
|
||||||
|
label: settled ? 'DENSE' : 'DENSE ···',
|
||||||
|
active: settled,
|
||||||
|
busy: !settled,
|
||||||
|
disabled: !settled,
|
||||||
|
state: settled ? 'active' : 'loading',
|
||||||
|
title: 'x',
|
||||||
|
params: { catalog: 'core' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
legend: [],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
setRowControlsListener(fn) {
|
||||||
|
module._listener = fn;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
mgr.register(module);
|
||||||
|
try {
|
||||||
|
mgr.buildTogglePanel(container);
|
||||||
|
assert.equal(await mgr.setEnabled('satellites', true), true);
|
||||||
|
assert.equal(
|
||||||
|
typeof module._listener,
|
||||||
|
'function',
|
||||||
|
'the manager installed its listener',
|
||||||
|
);
|
||||||
|
|
||||||
|
const controls = row(container, 'satellites').querySelector(
|
||||||
|
'.data-toggle-controls',
|
||||||
|
);
|
||||||
|
const chip = chipsIn(controls)[0];
|
||||||
|
assert.equal(chip.textContent, 'DENSE ···');
|
||||||
|
assert.equal(chip.disabled, true);
|
||||||
|
assert.equal(chip.getAttribute('aria-busy'), 'true');
|
||||||
|
assert.equal(
|
||||||
|
chip.getAttribute('aria-pressed'),
|
||||||
|
'false',
|
||||||
|
'busy is never reported as active',
|
||||||
|
);
|
||||||
|
assert.equal(chip.classList.contains('chip-loading'), true);
|
||||||
|
|
||||||
|
chip.click();
|
||||||
|
assert.equal(writes, 0, 'a disabled chip is inert');
|
||||||
|
|
||||||
|
// The layer settles and pushes its own refresh — no panel poll involved.
|
||||||
|
settled = true;
|
||||||
|
module._listener();
|
||||||
|
mgr._layerPanel._flushRowControlsRefresh();
|
||||||
|
assert.equal(chip.textContent, 'DENSE');
|
||||||
|
assert.equal(chip.disabled, false);
|
||||||
|
assert.equal(chip.getAttribute('aria-pressed'), 'true');
|
||||||
|
assert.equal(chip.getAttribute('aria-busy'), 'false');
|
||||||
|
|
||||||
|
chip.click();
|
||||||
|
assert.equal(writes, 1, 'the settled chip writes again');
|
||||||
|
} finally {
|
||||||
|
await mgr.destroyAll();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a layer that surrenders its row controls hides the block entirely', async (t) => {
|
||||||
|
// Space Missions borrows the satellite layer with showPoints:false; the row
|
||||||
|
// must not advertise a legend for an empty sky or offer a chip whose write
|
||||||
|
// the dependency owner will silently revert.
|
||||||
|
const container = panelHost(t);
|
||||||
|
const mgr = new DataLayerManager({});
|
||||||
|
let surrendered = false;
|
||||||
|
const layer = makeRowControlLayer();
|
||||||
|
const inner = layer.module.getRowControls;
|
||||||
|
layer.module.getRowControls = () =>
|
||||||
|
surrendered ? { chips: [], legend: [] } : inner();
|
||||||
|
mgr.register(layer.module);
|
||||||
|
try {
|
||||||
|
mgr.buildTogglePanel(container);
|
||||||
|
assert.equal(await mgr.setEnabled('satellites', true), true);
|
||||||
|
const controls = row(container, 'satellites').querySelector(
|
||||||
|
'.data-toggle-controls',
|
||||||
|
);
|
||||||
|
assert.equal(controls.hidden, false);
|
||||||
|
assert.equal(chipsIn(controls).length, 1);
|
||||||
|
|
||||||
|
surrendered = true;
|
||||||
|
mgr._refreshTogglePanel();
|
||||||
|
assert.equal(controls.hidden, true);
|
||||||
|
assert.equal(
|
||||||
|
chipsIn(controls).length,
|
||||||
|
0,
|
||||||
|
'the chip is removed, not merely hidden behind a style',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
controls.querySelectorAll('.data-toggle-legend-item').length,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
surrendered = false;
|
||||||
|
mgr._refreshTogglePanel();
|
||||||
|
assert.equal(
|
||||||
|
controls.hidden,
|
||||||
|
false,
|
||||||
|
'the row returns when the owner releases it',
|
||||||
|
);
|
||||||
|
assert.equal(chipsIn(controls).length, 1);
|
||||||
|
} finally {
|
||||||
|
await mgr.destroyAll();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('panel remount releases old listeners and destruction revokes retained controls', async (t) => {
|
||||||
|
panelHost(t);
|
||||||
|
const manager = new DataLayerManager({});
|
||||||
|
const layer = makeSlowLayer('lifecycle-panel', { updateInterval: -1 });
|
||||||
|
let rowListener;
|
||||||
|
layer.module.getRowControls = () => ({ chips: [] });
|
||||||
|
layer.module.setRowControlsListener = (listener) => {
|
||||||
|
rowListener = listener;
|
||||||
|
};
|
||||||
|
manager.register(layer.module);
|
||||||
|
try {
|
||||||
|
const first = document.body.appendChild(document.createElement('div'));
|
||||||
|
manager.buildTogglePanel(first);
|
||||||
|
const oldButton = first.querySelector('.data-toggle-btn');
|
||||||
|
const second = document.body.appendChild(document.createElement('div'));
|
||||||
|
manager.buildTogglePanel(second);
|
||||||
|
assert.equal(first.children.length, 0, 'the old container is emptied');
|
||||||
|
oldButton.click();
|
||||||
|
await macrotask();
|
||||||
|
assert.equal(
|
||||||
|
layer.calls.enable,
|
||||||
|
0,
|
||||||
|
'a revoked generation cannot issue actions',
|
||||||
|
);
|
||||||
|
const currentButton = second.querySelector('.data-toggle-btn');
|
||||||
|
assert.ok(currentButton);
|
||||||
|
assert.equal(typeof rowListener, 'function');
|
||||||
|
await manager.destroyAll();
|
||||||
|
assert.equal(second.children.length, 0, 'destruction removes the rows');
|
||||||
|
currentButton.click();
|
||||||
|
await macrotask();
|
||||||
|
assert.equal(
|
||||||
|
layer.calls.enable,
|
||||||
|
0,
|
||||||
|
'a retained control cannot act after destruction',
|
||||||
|
);
|
||||||
|
assert.equal(rowListener, null);
|
||||||
|
} finally {
|
||||||
|
await manager.destroyAll();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a retained chip from a remounted or destroyed panel writes nothing', async (t) => {
|
||||||
|
panelHost(t);
|
||||||
|
const mgr = new DataLayerManager({});
|
||||||
|
const layer = makeRowControlLayer();
|
||||||
|
mgr.register(layer.module);
|
||||||
|
try {
|
||||||
|
const first = document.body.appendChild(document.createElement('div'));
|
||||||
|
mgr.buildTogglePanel(first);
|
||||||
|
assert.equal(await mgr.setEnabled('satellites', true), true);
|
||||||
|
const oldChip = chipsIn(first)[0];
|
||||||
|
const second = document.body.appendChild(document.createElement('div'));
|
||||||
|
mgr.buildTogglePanel(second);
|
||||||
|
oldChip.click();
|
||||||
|
assert.equal(
|
||||||
|
layer.mode,
|
||||||
|
'core',
|
||||||
|
'a released generation cannot write params',
|
||||||
|
);
|
||||||
|
const chip = chipsIn(second)[0];
|
||||||
|
mgr._layerPanel.destroy();
|
||||||
|
chip.click();
|
||||||
|
assert.equal(layer.mode, 'core', 'a destroyed panel cannot write params');
|
||||||
|
} finally {
|
||||||
|
await mgr.destroyAll();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a toggle that settles after a remount repaints the new row idle', async (t) => {
|
||||||
|
panelHost(t);
|
||||||
|
const mgr = new DataLayerManager({});
|
||||||
|
let releaseEnable;
|
||||||
|
mgr.register({
|
||||||
|
id: 'remount-layer',
|
||||||
|
name: 'Remount layer',
|
||||||
|
icon: '',
|
||||||
|
source: 'Local fixture',
|
||||||
|
updateInterval: -1,
|
||||||
|
init() {},
|
||||||
|
enable() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
releaseEnable = resolve;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
disable() {},
|
||||||
|
update() {},
|
||||||
|
destroy() {},
|
||||||
|
getStats() {
|
||||||
|
return { count: 1, lastUpdate: Date.now() };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const first = document.body.appendChild(document.createElement('div'));
|
||||||
|
mgr.buildTogglePanel(first);
|
||||||
|
first.querySelector('.data-toggle-btn').click();
|
||||||
|
await waitFor(
|
||||||
|
() => typeof releaseEnable === 'function',
|
||||||
|
'enable transition',
|
||||||
|
);
|
||||||
|
const second = document.body.appendChild(document.createElement('div'));
|
||||||
|
mgr.buildTogglePanel(second);
|
||||||
|
releaseEnable();
|
||||||
|
await waitFor(() => mgr.isEnabled('remount-layer'), 'enabled');
|
||||||
|
await macrotask();
|
||||||
|
const button = second.querySelector('.data-toggle-btn');
|
||||||
|
assert.equal(button.getAttribute('aria-busy'), 'false');
|
||||||
|
assert.equal(button.getAttribute('aria-disabled'), 'false');
|
||||||
|
assert.equal(button.textContent, 'ON');
|
||||||
|
} finally {
|
||||||
|
releaseEnable?.();
|
||||||
|
await mgr.destroyAll();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('row action chips use live disabled state and descriptive counts without writing parameters', async (t) => {
|
||||||
|
const container = panelHost(t);
|
||||||
|
const mgr = new DataLayerManager({});
|
||||||
|
const layer = makeRowControlLayer();
|
||||||
|
let calls = 0,
|
||||||
|
blocked = false;
|
||||||
|
layer.module.getStats = () => ({ count: 3, countLabel: '3 nearby' });
|
||||||
|
layer.module.getRowControls = () => ({
|
||||||
|
chips: [
|
||||||
|
{
|
||||||
|
id: 'find',
|
||||||
|
label: 'SHOW NEAREST',
|
||||||
|
disabled: blocked,
|
||||||
|
onClick: () => calls++,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
layer.module.setParams = () => {
|
||||||
|
throw new Error('navigation must not write persistent params');
|
||||||
|
};
|
||||||
|
mgr.register(layer.module);
|
||||||
|
try {
|
||||||
|
mgr.buildTogglePanel(container);
|
||||||
|
await mgr.setEnabled('satellites', true);
|
||||||
|
const layerRow = row(container, 'satellites');
|
||||||
|
assert.equal(layerRow.querySelector('.data-count').textContent, '3 nearby');
|
||||||
|
const chip = chipsIn(layerRow)[0];
|
||||||
|
chip.click();
|
||||||
|
assert.equal(calls, 1);
|
||||||
|
blocked = true;
|
||||||
|
chip.click();
|
||||||
|
assert.equal(calls, 1, 'live disabled descriptor wins before row repaint');
|
||||||
|
blocked = false;
|
||||||
|
await mgr.setEnabled('satellites', false);
|
||||||
|
assert.equal(chip.isConnected, false, 'the off row drops its chips');
|
||||||
|
mgr._layerPanel._selectChip('satellites', 'find');
|
||||||
|
assert.equal(calls, 1, 'disabled layer cannot navigate');
|
||||||
|
} finally {
|
||||||
|
await mgr.destroyAll();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const scheduler of ['animation frame', 'timeout']) {
|
||||||
|
test(`row notifications coalesce with ${scheduler} and stop after destroy`, async (t) => {
|
||||||
|
const container = panelHost(t);
|
||||||
|
const frames = new Map();
|
||||||
|
let nextFrame = 0;
|
||||||
|
const originalRaf = globalThis.requestAnimationFrame;
|
||||||
|
const originalCancel = globalThis.cancelAnimationFrame;
|
||||||
|
globalThis.requestAnimationFrame =
|
||||||
|
scheduler === 'animation frame'
|
||||||
|
? (callback) => {
|
||||||
|
frames.set(++nextFrame, callback);
|
||||||
|
return nextFrame;
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
globalThis.cancelAnimationFrame = (id) => frames.delete(id);
|
||||||
|
const manager = new DataLayerManager({});
|
||||||
|
const layer = makeSlowLayer('wind', { updateInterval: -1 });
|
||||||
|
let notify;
|
||||||
|
layer.module.getRowControls = () => ({ chips: [] });
|
||||||
|
layer.module.setRowControlsListener = (listener) => {
|
||||||
|
if (listener) notify = listener;
|
||||||
|
};
|
||||||
|
manager.register(layer.module);
|
||||||
|
try {
|
||||||
|
manager.buildTogglePanel(container);
|
||||||
|
const panel = manager._layerPanel;
|
||||||
|
let refreshes = 0;
|
||||||
|
const refresh = panel._refreshTogglePanel.bind(panel);
|
||||||
|
panel._refreshTogglePanel = () => {
|
||||||
|
refreshes++;
|
||||||
|
refresh();
|
||||||
|
};
|
||||||
|
for (let i = 0; i < 10; i++) notify();
|
||||||
|
assert.equal(refreshes, 0, 'notifications defer refresh');
|
||||||
|
if (scheduler === 'animation frame') assert.equal(frames.size, 1);
|
||||||
|
panel._flushRowControlsRefresh();
|
||||||
|
assert.equal(refreshes, 1);
|
||||||
|
panel._flushRowControlsRefresh();
|
||||||
|
await macrotask();
|
||||||
|
assert.equal(frames.size, 0, 'flush cancels the pending frame');
|
||||||
|
assert.equal(refreshes, 1, 'flush does not leave duplicate work');
|
||||||
|
notify();
|
||||||
|
notify();
|
||||||
|
if (scheduler === 'animation frame') {
|
||||||
|
const [id, callback] = frames.entries().next().value;
|
||||||
|
frames.delete(id);
|
||||||
|
callback();
|
||||||
|
} else await macrotask();
|
||||||
|
assert.equal(refreshes, 2, 'scheduled callback refreshes once');
|
||||||
|
notify();
|
||||||
|
panel.destroy();
|
||||||
|
assert.equal(frames.size, 0, 'destroy cancels pending work');
|
||||||
|
notify();
|
||||||
|
panel._flushRowControlsRefresh();
|
||||||
|
await macrotask();
|
||||||
|
assert.equal(
|
||||||
|
refreshes,
|
||||||
|
2,
|
||||||
|
'retained notifications after destroy are inert',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await manager.destroyAll();
|
||||||
|
globalThis.requestAnimationFrame = originalRaf;
|
||||||
|
globalThis.cancelAnimationFrame = originalCancel;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('row info and legends reconcile in place while chips retain focus and order', async (t) => {
|
||||||
|
const container = panelHost(t);
|
||||||
|
const manager = new DataLayerManager({});
|
||||||
|
const layer = makeSlowLayer('wind', { updateInterval: -1 });
|
||||||
|
let controls = {
|
||||||
|
chips: [{ id: 'one', label: 'One' }],
|
||||||
|
legend: [{ label: 'Low', color: '#0000ff', count: 1, blurb: 'Low values' }],
|
||||||
|
info: 'Forecast',
|
||||||
|
infoTitle: 'Source time',
|
||||||
|
};
|
||||||
|
layer.module.getRowControls = () => controls;
|
||||||
|
manager.register(layer.module);
|
||||||
|
try {
|
||||||
|
manager.buildTogglePanel(container);
|
||||||
|
await manager.setEnabled('wind', true);
|
||||||
|
const block = container.querySelector('.data-toggle-controls');
|
||||||
|
const chip = block.children[0];
|
||||||
|
const info = block.children[2];
|
||||||
|
assert.equal(info.className, 'data-toggle-controls-info');
|
||||||
|
assert.equal(info.textContent, 'Forecast');
|
||||||
|
assert.equal(info.title, 'Source time');
|
||||||
|
chip.focus();
|
||||||
|
for (const change of [
|
||||||
|
{ label: 'High' },
|
||||||
|
{ color: '#ff0000' },
|
||||||
|
{ count: 2 },
|
||||||
|
{ blurb: 'High values' },
|
||||||
|
]) {
|
||||||
|
controls = structuredClone(controls);
|
||||||
|
Object.assign(controls.legend[0], change);
|
||||||
|
manager._refreshTogglePanel();
|
||||||
|
assertSameNode(block.children[2], info);
|
||||||
|
assertSameNode(document.activeElement, chip);
|
||||||
|
}
|
||||||
|
const legend = block.children[1];
|
||||||
|
assert.equal(legend.title, 'High values');
|
||||||
|
assert.equal(legend.textContent, 'High 2');
|
||||||
|
assert.equal(legend.firstElementChild.style.background, '#ff0000');
|
||||||
|
controls.info = '<img onerror=alert(1)>';
|
||||||
|
controls.infoTitle = '';
|
||||||
|
controls.chips.push({ id: 'two', label: 'Two' });
|
||||||
|
manager._refreshTogglePanel();
|
||||||
|
assert.deepEqual(
|
||||||
|
[...block.children].map((node) => node.className),
|
||||||
|
[
|
||||||
|
'data-toggle-chip chip-idle',
|
||||||
|
'data-toggle-chip chip-idle',
|
||||||
|
'data-toggle-legend-item',
|
||||||
|
'data-toggle-controls-info',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assertSameNode(block.children[0], chip);
|
||||||
|
assertSameNode(block.children[3], info);
|
||||||
|
assert.equal(info.textContent, controls.info);
|
||||||
|
assert.equal(info.title, '');
|
||||||
|
assert.equal(info.children.length, 0, 'info remains plain text');
|
||||||
|
controls = { chips: [], legend: [] };
|
||||||
|
manager._refreshTogglePanel();
|
||||||
|
assert.equal(block.hidden, true);
|
||||||
|
assert.equal(info.hidden, true);
|
||||||
|
assert.equal(block.children.length, 1);
|
||||||
|
assertSameNode(block.children[0], info);
|
||||||
|
controls = {
|
||||||
|
info: 'Restored',
|
||||||
|
legend: [{ label: 'New', color: '#ffffff' }],
|
||||||
|
};
|
||||||
|
manager._refreshTogglePanel();
|
||||||
|
assertSameNode(block.lastElementChild, info);
|
||||||
|
assert.equal(info.hidden, false);
|
||||||
|
assert.equal(info.textContent, 'Restored');
|
||||||
|
} finally {
|
||||||
|
await manager.destroyAll();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
const set = (node, key, value) => {
|
|
||||||
if (node[key] !== value) node[key] = value;
|
|
||||||
};
|
|
||||||
const attribute = (node, key, value) => {
|
|
||||||
if (node.getAttribute(key) !== value) node.setAttribute(key, value);
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Keyed chips shared by layer rows and rail cards; the caller owns dispatch. */
|
|
||||||
export function syncChipGroup(container, chips = [], { before = null } = {}) {
|
|
||||||
if (!container) return;
|
|
||||||
const document = container.ownerDocument ?? globalThis.document;
|
|
||||||
const stale = new Map();
|
|
||||||
for (const node of [...container.children])
|
|
||||||
if (node.dataset?.chipId) stale.set(node.dataset.chipId, node);
|
|
||||||
const ordered = [];
|
|
||||||
for (const chip of chips) {
|
|
||||||
let button = stale.get(chip.id);
|
|
||||||
stale.delete(chip.id);
|
|
||||||
if (!button) {
|
|
||||||
button = document.createElement('button');
|
|
||||||
button.type = 'button';
|
|
||||||
button.dataset.chipId = chip.id;
|
|
||||||
}
|
|
||||||
const state = chip.state || (chip.active ? 'active' : 'idle');
|
|
||||||
set(
|
|
||||||
button,
|
|
||||||
'className',
|
|
||||||
`data-toggle-chip chip-${state}${chip.active ? ' active' : ''}`,
|
|
||||||
);
|
|
||||||
set(button, 'textContent', chip.label);
|
|
||||||
// Optional per-chip colour; CSS decides how it is used.
|
|
||||||
const color = chip.color || '';
|
|
||||||
if ((button.dataset.chipColor || '') !== color) {
|
|
||||||
if (color) {
|
|
||||||
button.style.setProperty('--chip-color', color);
|
|
||||||
button.dataset.chipColor = color;
|
|
||||||
} else {
|
|
||||||
button.style.removeProperty('--chip-color');
|
|
||||||
delete button.dataset.chipColor;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
set(button, 'title', chip.title || '');
|
|
||||||
set(button, 'disabled', Boolean(chip.disabled));
|
|
||||||
attribute(button, 'aria-pressed', String(Boolean(chip.active)));
|
|
||||||
attribute(button, 'aria-busy', String(Boolean(chip.busy)));
|
|
||||||
ordered.push(button);
|
|
||||||
}
|
|
||||||
for (const node of stale.values()) node.remove();
|
|
||||||
ordered.forEach((node, index) => {
|
|
||||||
if (container.children[index] !== node)
|
|
||||||
container.insertBefore(node, container.children[index] || before);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
import test from 'node:test';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import { syncChipGroup } from './chipGroup.js';
|
|
||||||
import { railFixture } from './railTestFixture.mjs';
|
|
||||||
|
|
||||||
test('keyed chips retain focus, reflect state, reorder and remove without redundant writes', () => {
|
|
||||||
let writes = 0;
|
|
||||||
const f = railFixture(() => writes++);
|
|
||||||
const a = { id: 'a', label: 'GFS', active: true };
|
|
||||||
const b = { id: 'b', label: 'ECMWF' };
|
|
||||||
const legend = f.document.createElement('span');
|
|
||||||
f.container.appendChild(legend);
|
|
||||||
syncChipGroup(f.container, [a, b], { before: legend });
|
|
||||||
const first = f.container.children[0];
|
|
||||||
first.focus();
|
|
||||||
const next = [{ ...a, label: 'Changed', busy: true, disabled: true }, b];
|
|
||||||
syncChipGroup(f.container, next);
|
|
||||||
assert.equal(f.container.children[0], first);
|
|
||||||
assert.equal(f.document.activeElement, first);
|
|
||||||
assert.equal(first.disabled, true);
|
|
||||||
assert.equal(first.getAttribute('aria-busy'), 'true');
|
|
||||||
assert.equal(first.getAttribute('aria-pressed'), 'true');
|
|
||||||
writes = 0;
|
|
||||||
syncChipGroup(f.container, next);
|
|
||||||
assert.equal(writes, 0);
|
|
||||||
syncChipGroup(f.container, [b, a]);
|
|
||||||
assert.equal(f.container.children[1], first);
|
|
||||||
assert.equal(f.container.children[2], legend);
|
|
||||||
syncChipGroup(f.container, [a]);
|
|
||||||
assert.deepEqual(f.container.children, [first, legend]);
|
|
||||||
syncChipGroup(f.container, []);
|
|
||||||
assert.deepEqual(f.container.children, [legend]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a chip colour is set once, changed, and cleared through a CSS variable', () => {
|
|
||||||
const f = railFixture(() => {});
|
|
||||||
const calls = [];
|
|
||||||
syncChipGroup(f.container, [{ id: 'm', label: 'M' }]);
|
|
||||||
const chip = f.container.children[0];
|
|
||||||
chip.style.setProperty = (name, value) => calls.push(['set', name, value]);
|
|
||||||
chip.style.removeProperty = (name) => calls.push(['remove', name]);
|
|
||||||
syncChipGroup(f.container, [{ id: 'm', label: 'M' }]);
|
|
||||||
assert.deepEqual(calls, [], 'a chip without a colour never touches style');
|
|
||||||
syncChipGroup(f.container, [{ id: 'm', label: 'M', color: '#05cb63' }]);
|
|
||||||
syncChipGroup(f.container, [{ id: 'm', label: 'M', color: '#05cb63' }]);
|
|
||||||
syncChipGroup(f.container, [{ id: 'm', label: 'M', color: '#a66bff' }]);
|
|
||||||
syncChipGroup(f.container, [{ id: 'm', label: 'M' }]);
|
|
||||||
assert.deepEqual(calls, [
|
|
||||||
['set', '--chip-color', '#05cb63'],
|
|
||||||
['set', '--chip-color', '#a66bff'],
|
|
||||||
['remove', '--chip-color'],
|
|
||||||
]);
|
|
||||||
assert.equal(chip.dataset.chipColor, undefined);
|
|
||||||
});
|
|
||||||
+251
-400
@@ -1,5 +1,5 @@
|
|||||||
import { syncChipGroup } from './chipGroup.js';
|
import { h, render as renderVue } from 'vue';
|
||||||
import { syncRowList } from './rowList.js';
|
import LayerRows from './layerPanel/LayerRows.vue';
|
||||||
import { layerFeedState } from '../data/feedState.js';
|
import { layerFeedState } from '../data/feedState.js';
|
||||||
export { layerFeedState } from '../data/feedState.js';
|
export { layerFeedState } from '../data/feedState.js';
|
||||||
import { GUIDANCE_STATUSES } from '../loadingFeedback.js';
|
import { GUIDANCE_STATUSES } from '../loadingFeedback.js';
|
||||||
@@ -134,65 +134,108 @@ export function layerKeyRequirementTooltip(layer = {}) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ROW_TEXT_SEPARATOR = ' · ';
|
function formatCount(n) {
|
||||||
|
if (n >= 1000)
|
||||||
|
return `${formatNumber(n / 1000, {
|
||||||
|
minimumFractionDigits: 1,
|
||||||
|
maximumFractionDigits: 1,
|
||||||
|
useGrouping: false,
|
||||||
|
})}K`;
|
||||||
|
return String(n);
|
||||||
|
}
|
||||||
|
|
||||||
// A short Latin code joined by hyphens: every part after a hyphen is capitals
|
/** Qualify a loaded count when it does not mean items currently on screen. */
|
||||||
// or digits (ADS-B, RTL-SDR, GTFS-RT). A compound such as "ALPR-Kameras" is a
|
function layerCountText(stats) {
|
||||||
// word, not a code, and may break at its hyphen. Longer runs (a URL, a token
|
if (typeof stats.countLabel === 'string' && stats.countLabel.trim())
|
||||||
// in an error) keep their break opportunities so they cannot overflow the rail.
|
return stats.countLabel;
|
||||||
const HYPHENATED_CODE = /\b[A-Za-z0-9]+(?:-[A-Z0-9]+)+(?![A-Za-z0-9])/g;
|
return stats.count ? formatCount(stats.count) : '—';
|
||||||
const HYPHENATED_CODE_MAX = 16;
|
|
||||||
|
|
||||||
/** A span for one run of row text; `keep` stops the browser breaking inside it. */
|
|
||||||
function rowTextSpan(text, keep) {
|
|
||||||
const span = document.createElement('span');
|
|
||||||
if (keep) span.className = 'data-keep';
|
|
||||||
span.textContent = text;
|
|
||||||
return span;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set a row's name, count or meta line so that a narrow column wraps it well
|
* A row's count. Outside English a count phrase ("24 nearby · 2 on screen")
|
||||||
* in every language: a Latin code with hyphens (ADS-B, RTL-SDR, GTFS-RT) never
|
* gets its own line (`countLine`), or it squeezes a longer translated name to
|
||||||
* splits at its hyphen, and a " · " separator stays at the end of the line it
|
* a word per line. "—" is marked `empty` so a wide status chip can replace it
|
||||||
* closes, so no line starts with "·". The element's textContent is still the
|
* (radio.css).
|
||||||
* plain string; text with neither feature is set directly.
|
* @param {object} stats Layer stats.
|
||||||
*
|
* @returns {{text: string, empty: boolean, countLine: boolean}}
|
||||||
* @param {HTMLElement} element
|
|
||||||
* @param {string} text
|
|
||||||
*/
|
*/
|
||||||
export function setRowText(element, text) {
|
export function presentLayerCount(stats = {}) {
|
||||||
const value = String(text ?? '');
|
const text = layerCountText(stats);
|
||||||
// Refreshes run every second; leave unchanged text (and its spans) alone.
|
return {
|
||||||
if (element.textContent === value) return;
|
text,
|
||||||
const codes = [...value.matchAll(HYPHENATED_CODE)].filter(
|
empty: text === '—',
|
||||||
(match) => match[0].length <= HYPHENATED_CODE_MAX,
|
countLine:
|
||||||
);
|
getLocale() !== 'en' &&
|
||||||
if (!codes.length && !value.includes(ROW_TEXT_SEPARATOR)) {
|
typeof stats.countLabel === 'string' &&
|
||||||
element.textContent = value;
|
/\d/.test(stats.countLabel),
|
||||||
return;
|
|
||||||
}
|
|
||||||
const spans = [];
|
|
||||||
const pushPlain = (from, to) => {
|
|
||||||
if (to <= from) return;
|
|
||||||
const pieces = value.slice(from, to).split(ROW_TEXT_SEPARATOR);
|
|
||||||
pieces.forEach((piece, index) => {
|
|
||||||
// The space before the dot belongs to a no-wrap span, so the only break
|
|
||||||
// opportunity around a separator is the space after it.
|
|
||||||
if (index > 0) spans.push(rowTextSpan(' ·', true));
|
|
||||||
const textPiece = index > 0 ? ` ${piece}` : piece;
|
|
||||||
if (textPiece) spans.push(rowTextSpan(textPiece, false));
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
let cursor = 0;
|
|
||||||
for (const match of codes) {
|
|
||||||
pushPlain(cursor, match.index);
|
|
||||||
spans.push(rowTextSpan(match[0], true));
|
|
||||||
cursor = match.index + match[0].length;
|
|
||||||
}
|
}
|
||||||
pushPlain(cursor, value.length);
|
|
||||||
element.textContent = '';
|
/**
|
||||||
for (const span of spans) element.appendChild(span);
|
* A row's lifecycle toggle: its classes, feed state, label and accessible
|
||||||
|
* name. A busy or unavailable toggle is `ariaDisabled` and stays focusable;
|
||||||
|
* native `disabled` would evict keyboard focus in Chromium.
|
||||||
|
* @param {object} layer Row from the layer manager's getAll().
|
||||||
|
*/
|
||||||
|
export function presentLayerToggle(layer) {
|
||||||
|
const unavailable = layer.stats?.sourceUnavailable === true;
|
||||||
|
const feedState = unavailable
|
||||||
|
? 'unavailable'
|
||||||
|
: layer.enabled
|
||||||
|
? layerFeedState(layer.stats)
|
||||||
|
: 'off';
|
||||||
|
const transitioning =
|
||||||
|
layer.lifecycleState === 'enabling' || layer.lifecycleState === 'disabling';
|
||||||
|
const uncertain = Boolean(layer.lifecycleUncertain);
|
||||||
|
const classes = {
|
||||||
|
active: Boolean(layer.enabled),
|
||||||
|
transitioning,
|
||||||
|
enabling: layer.lifecycleState === 'enabling',
|
||||||
|
disabling: layer.lifecycleState === 'disabling',
|
||||||
|
'lifecycle-uncertain': uncertain,
|
||||||
|
};
|
||||||
|
for (const state of Object.keys(FEED_STATE_LABEL_KEYS))
|
||||||
|
classes[`feed-${state}`] =
|
||||||
|
(layer.enabled || unavailable) && !uncertain && feedState === state;
|
||||||
|
const showsFeedState =
|
||||||
|
!transitioning && !uncertain && (layer.enabled || unavailable);
|
||||||
|
const stateText = transitioning
|
||||||
|
? lifecycleStateLabel(layer.lifecycleState)
|
||||||
|
: uncertain
|
||||||
|
? t('UNCERTAIN')
|
||||||
|
: showsFeedState
|
||||||
|
? t(FEED_STATE_LABEL_KEYS[feedState])
|
||||||
|
: t('OFF');
|
||||||
|
// Name the missing key on the control itself: a row reading KEY REQUIRED
|
||||||
|
// without saying WHICH key leaves a dead control and no next step. Empty
|
||||||
|
// when the layer needs no key, or already has one.
|
||||||
|
const keyGuidance = unavailable
|
||||||
|
? String(
|
||||||
|
translateFeedError(layer.stats.error) || t('Data source unavailable'),
|
||||||
|
)
|
||||||
|
: layerKeyRequirementTooltip(layer);
|
||||||
|
const ariaLabel = t('{name}: {state}', {
|
||||||
|
name: panelLabel(layer),
|
||||||
|
state: stateText,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
classes,
|
||||||
|
feedState: transitioning
|
||||||
|
? layer.lifecycleState
|
||||||
|
: uncertain
|
||||||
|
? 'uncertain'
|
||||||
|
: feedState,
|
||||||
|
ariaDisabled: transitioning || (unavailable && !layer.enabled),
|
||||||
|
ariaBusy: transitioning,
|
||||||
|
// The chip may abbreviate where a language's word is wider than the
|
||||||
|
// status column; the meta line and the accessible name keep the full word.
|
||||||
|
text:
|
||||||
|
showsFeedState && feedState === 'unavailable'
|
||||||
|
? t('UNAVAILABLE', { context: 'status chip' })
|
||||||
|
: stateText,
|
||||||
|
title: keyGuidance,
|
||||||
|
ariaLabel: keyGuidance ? `${ariaLabel}. ${keyGuidance}` : ariaLabel,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Layer row presentation over supplied state and actions; no layer imports. */
|
/** Layer row presentation over supplied state and actions; no layer imports. */
|
||||||
@@ -223,10 +266,17 @@ export class LayerPanel {
|
|||||||
this._cancelRowControlsRefresh = null;
|
this._cancelRowControlsRefresh = null;
|
||||||
this._recentImageryFactory = null;
|
this._recentImageryFactory = null;
|
||||||
this._recentImageryPanel = null;
|
this._recentImageryPanel = null;
|
||||||
|
this._rows = [];
|
||||||
|
this._rowViews = new Map();
|
||||||
|
this._pending = new Set();
|
||||||
}
|
}
|
||||||
mount(container) {
|
mount(container) {
|
||||||
if (this._destroyed) return;
|
if (this._destroyed) return;
|
||||||
this._releaseBindings();
|
this._releaseBindings();
|
||||||
|
if (this._toggleContainer && this._toggleContainer !== container)
|
||||||
|
renderVue(null, this._toggleContainer);
|
||||||
|
// The rows replace whatever the container held before.
|
||||||
|
if (container && !container._vnode) container.replaceChildren();
|
||||||
this._toggleContainer = container;
|
this._toggleContainer = container;
|
||||||
this._weatherPanel?.destroy();
|
this._weatherPanel?.destroy();
|
||||||
this._weatherPanel = createWeatherPanel({
|
this._weatherPanel = createWeatherPanel({
|
||||||
@@ -258,10 +308,6 @@ export class LayerPanel {
|
|||||||
if (container && this._recentImageryFactory)
|
if (container && this._recentImageryFactory)
|
||||||
this._recentImageryPanel = this._recentImageryFactory(container) || null;
|
this._recentImageryPanel = this._recentImageryFactory(container) || null;
|
||||||
}
|
}
|
||||||
_bind(element, type, listener) {
|
|
||||||
element.addEventListener(type, listener);
|
|
||||||
this._removers.push(() => element.removeEventListener(type, listener));
|
|
||||||
}
|
|
||||||
_releaseBindings() {
|
_releaseBindings() {
|
||||||
this._generation++;
|
this._generation++;
|
||||||
this._cancelRowControlsRefresh?.();
|
this._cancelRowControlsRefresh?.();
|
||||||
@@ -277,14 +323,15 @@ export class LayerPanel {
|
|||||||
this._recentImageryPanel?.destroy();
|
this._recentImageryPanel?.destroy();
|
||||||
this._recentImageryPanel = null;
|
this._recentImageryPanel = null;
|
||||||
this._recentImageryFactory = null;
|
this._recentImageryFactory = null;
|
||||||
|
if (this._toggleContainer) renderVue(null, this._toggleContainer);
|
||||||
this._toggleContainer = null;
|
this._toggleContainer = null;
|
||||||
}
|
}
|
||||||
_renderToggles() {
|
_renderToggles() {
|
||||||
if (this._destroyed || !this._toggleContainer) return;
|
if (this._destroyed || !this._toggleContainer) return;
|
||||||
this._releaseBindings();
|
this._releaseBindings();
|
||||||
this._toggleContainer.innerHTML = '';
|
// The rows are fixed here, in panel order under their group headings;
|
||||||
|
// refreshes repaint them. A layer whose row is a rail readout keeps only
|
||||||
const generation = this._generation;
|
// its toggle and metadata.
|
||||||
const layers = this.getAll()
|
const layers = this.getAll()
|
||||||
.slice()
|
.slice()
|
||||||
.sort(
|
.sort(
|
||||||
@@ -292,93 +339,20 @@ export class LayerPanel {
|
|||||||
(PANEL_POSITIONS.get(a.id) ?? PANEL_ORDER.length) -
|
(PANEL_POSITIONS.get(a.id) ?? PANEL_ORDER.length) -
|
||||||
(PANEL_POSITIONS.get(b.id) ?? PANEL_ORDER.length),
|
(PANEL_POSITIONS.get(b.id) ?? PANEL_ORDER.length),
|
||||||
);
|
);
|
||||||
|
this._rows = [];
|
||||||
|
this._rowViews = new Map();
|
||||||
let previousGroup = '';
|
let previousGroup = '';
|
||||||
for (const layer of layers) {
|
for (const layer of layers) {
|
||||||
if (!layer.showInTogglePanel) continue;
|
if (!layer.showInTogglePanel) continue;
|
||||||
const group =
|
const group =
|
||||||
PANEL_ORDER[PANEL_POSITIONS.get(layer.id)]?.label ??
|
PANEL_ORDER[PANEL_POSITIONS.get(layer.id)]?.label ??
|
||||||
msg('Other layers');
|
msg('Other layers');
|
||||||
if (group && group !== previousGroup) {
|
if (group && group !== previousGroup)
|
||||||
const heading = document.createElement('h3');
|
this._rows.push({ key: `group:${group}`, heading: group });
|
||||||
heading.className = 'data-layer-group-heading';
|
|
||||||
heading.textContent = t(group);
|
|
||||||
this._toggleContainer.appendChild(heading);
|
|
||||||
}
|
|
||||||
previousGroup = group;
|
previousGroup = group;
|
||||||
const row = document.createElement('div');
|
|
||||||
row.className = 'data-toggle-row';
|
|
||||||
row.dataset.layerId = layer.id;
|
|
||||||
// English name for voice tools, which must not receive translated text.
|
|
||||||
row.dataset.layerName = englishPanelLabel(layer);
|
|
||||||
|
|
||||||
const topRow = document.createElement('div');
|
|
||||||
topRow.className = 'data-toggle-top';
|
|
||||||
|
|
||||||
const left = document.createElement('div');
|
|
||||||
left.className = 'data-toggle-left';
|
|
||||||
const icon = document.createElement('span');
|
|
||||||
icon.className = 'data-icon';
|
|
||||||
icon.setAttribute('aria-hidden', 'true');
|
|
||||||
icon.textContent = layer.icon;
|
|
||||||
const name = document.createElement('span');
|
|
||||||
name.className = 'data-name';
|
|
||||||
setRowText(name, panelLabel(layer));
|
|
||||||
left.appendChild(icon);
|
|
||||||
left.appendChild(name);
|
|
||||||
|
|
||||||
const right = document.createElement('div');
|
|
||||||
right.className = 'data-toggle-right';
|
|
||||||
|
|
||||||
const count = document.createElement('span');
|
|
||||||
count.className = 'data-count';
|
|
||||||
this._syncCount(row, count, layer.stats);
|
|
||||||
|
|
||||||
const toggle = document.createElement('button');
|
|
||||||
toggle.type = 'button';
|
|
||||||
toggle.className = `data-toggle-btn${layer.enabled ? ' active' : ''}`;
|
|
||||||
this._syncToggleButton(toggle, layer);
|
|
||||||
this._bind(toggle, 'click', async () => {
|
|
||||||
// Native `disabled` immediately evicts keyboard focus in Chromium. Keep
|
|
||||||
// the lifecycle control focusable while it is busy, and enforce the
|
|
||||||
// same single-flight interaction contract through ARIA instead.
|
|
||||||
if (
|
|
||||||
this._destroyed ||
|
|
||||||
this._generation !== generation ||
|
|
||||||
toggle.getAttribute('aria-disabled') === 'true'
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
toggle.setAttribute('aria-disabled', 'true');
|
|
||||||
toggle.setAttribute('aria-busy', 'true');
|
|
||||||
try {
|
|
||||||
await this.setEnabled(layer.id, !this.isEnabled(layer.id), {
|
|
||||||
origin: 'user',
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(`[Data] ${layer.id} toggle error:`, error);
|
|
||||||
} finally {
|
|
||||||
const current = this.getAll().find(({ id }) => id === layer.id);
|
|
||||||
if (!this._destroyed && current && this._generation === generation)
|
|
||||||
this._syncToggleButton(toggle, current);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const readout = Boolean(this._rowControlsFor(layer.id)?.readout);
|
const readout = Boolean(this._rowControlsFor(layer.id)?.readout);
|
||||||
if (!readout) right.appendChild(count);
|
const controls = this.hasRowControls(layer.id);
|
||||||
right.appendChild(toggle);
|
if (controls) {
|
||||||
topRow.appendChild(left);
|
|
||||||
topRow.appendChild(right);
|
|
||||||
|
|
||||||
const bottomRow = document.createElement('div');
|
|
||||||
bottomRow.className = 'data-toggle-meta';
|
|
||||||
setRowText(bottomRow, this._buildMetaText(layer));
|
|
||||||
|
|
||||||
row.appendChild(topRow);
|
|
||||||
row.appendChild(bottomRow);
|
|
||||||
|
|
||||||
// Optional per-layer sub-controls (chips + color legend). The click
|
|
||||||
// listener is delegated and attached once here, so it survives
|
|
||||||
// _refreshTogglePanel — which only rewrites the container's contents.
|
|
||||||
if (this.hasRowControls(layer.id)) {
|
|
||||||
// A layer whose controls settle asynchronously (a chunked catalog load
|
// A layer whose controls settle asynchronously (a chunked catalog load
|
||||||
// that can also fail) pushes a re-render through this; nothing else
|
// that can also fail) pushes a re-render through this; nothing else
|
||||||
// would repaint the row before its next scheduled refresh.
|
// would repaint the row before its next scheduled refresh.
|
||||||
@@ -386,46 +360,148 @@ export class LayerPanel {
|
|||||||
this._scheduleRowControlsRefresh(),
|
this._scheduleRowControlsRefresh(),
|
||||||
);
|
);
|
||||||
if (unsubscribe) this._removers.push(unsubscribe);
|
if (unsubscribe) this._removers.push(unsubscribe);
|
||||||
if (!readout) {
|
}
|
||||||
const controls = document.createElement('div');
|
this._rows.push({
|
||||||
controls.className = 'data-toggle-controls';
|
key: layer.id,
|
||||||
this._bind(controls, 'click', (event) => {
|
id: layer.id,
|
||||||
const button = event.target?.closest?.('.data-toggle-chip');
|
count: !readout,
|
||||||
if (!button || button.disabled) return;
|
controls: controls && !readout,
|
||||||
// Re-read the live descriptor rather than trusting the rendered
|
info: false,
|
||||||
// chip, so a stale row can never apply an inverted toggle.
|
|
||||||
const chip = this._rowControlsFor(layer.id)?.chips?.find(
|
|
||||||
(entry) => entry.id === button.dataset.chipId,
|
|
||||||
);
|
|
||||||
if (!chip || chip.disabled || !this.isEnabled(layer.id)) return;
|
|
||||||
if (typeof chip.onClick === 'function') chip.onClick();
|
|
||||||
else if (chip.params)
|
|
||||||
this.setLayerParams(layer.id, chip.params, { origin: 'user' });
|
|
||||||
});
|
});
|
||||||
row.appendChild(controls);
|
}
|
||||||
// An ordered list below the chips, for a layer whose row carries a
|
this._paint();
|
||||||
// sequence (turn-by-turn directions). Its own delegated listener, its
|
this._refreshWeatherPanel();
|
||||||
// own container — the chip row stays a chip row.
|
}
|
||||||
const list = document.createElement('ol');
|
|
||||||
list.className = 'data-row-list';
|
/** Present every row from the layers' current state and render them. */
|
||||||
list.hidden = true;
|
_paint() {
|
||||||
this._bind(list, 'click', (event) => {
|
if (this._destroyed || !this._toggleContainer) return;
|
||||||
const button = event.target?.closest?.('.data-row-list-item');
|
const layers = new Map(this.getAll().map((layer) => [layer.id, layer]));
|
||||||
if (!button || button.disabled) return;
|
const items = this._rows.map((row) => {
|
||||||
const item = this._rowControlsFor(layer.id)?.list?.items?.find(
|
if (row.heading) return { key: row.key, heading: t(row.heading) };
|
||||||
(entry) => entry.id === button.dataset.listItemId,
|
const layer = layers.get(row.id);
|
||||||
);
|
if (layer) this._rowViews.set(row.id, this._presentRow(row, layer));
|
||||||
if (item?.params)
|
return this._rowViews.get(row.id);
|
||||||
this.setLayerParams(layer.id, item.params, { origin: 'user' });
|
|
||||||
});
|
});
|
||||||
row.appendChild(list);
|
const generation = this._generation;
|
||||||
this._syncRowControls(controls, layer, list);
|
renderVue(
|
||||||
|
h(LayerRows, {
|
||||||
|
items: items.filter(Boolean),
|
||||||
|
onToggle: (id) => this._toggle(id, generation),
|
||||||
|
onChip: (id, chipId) => this._selectChip(id, chipId, generation),
|
||||||
|
onListItem: (id, itemId) =>
|
||||||
|
this._selectListItem(id, itemId, generation),
|
||||||
|
}),
|
||||||
|
this._toggleContainer,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_presentRow(row, layer) {
|
||||||
|
const toggle = presentLayerToggle(layer);
|
||||||
|
if (this._pending.has(layer.id)) {
|
||||||
|
// A busy toggle keeps keyboard focus: `aria-disabled` and the click
|
||||||
|
// guard prevent repeat activation, where native `disabled` would evict
|
||||||
|
// focus in Chromium.
|
||||||
|
toggle.ariaDisabled = true;
|
||||||
|
toggle.ariaBusy = true;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
key: layer.id,
|
||||||
|
id: layer.id,
|
||||||
|
// English name for voice tools, which must not receive translated text.
|
||||||
|
englishName: englishPanelLabel(layer),
|
||||||
|
icon: layer.icon,
|
||||||
|
name: panelLabel(layer),
|
||||||
|
count: row.count ? presentLayerCount(layer.stats) : null,
|
||||||
|
toggle,
|
||||||
|
meta: this._buildMetaText(layer),
|
||||||
|
controls: row.controls ? this._presentControls(row, layer) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A layer's row chips, color legend, info line and ordered list. The block
|
||||||
|
* is hidden while the layer is off (or while a dependency owner has
|
||||||
|
* surrendered it), so a quiet row stays quiet. Chips and list items are
|
||||||
|
* keyed, so the refresh a chip's own click triggers keeps keyboard focus.
|
||||||
|
* It records on `row` what persists across paints: a readout drops the
|
||||||
|
* controls for good, and a shown info line stays.
|
||||||
|
*/
|
||||||
|
_presentControls(row, layer) {
|
||||||
|
const controls = layer.enabled ? this._rowControlsFor(layer.id) : null;
|
||||||
|
if (controls?.readout) {
|
||||||
|
// The row became a rail readout: its controls go for good.
|
||||||
|
row.controls = false;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const chips = controls?.chips || [];
|
||||||
|
const legend = (controls?.legend || []).map((item) => ({
|
||||||
|
color: item.color,
|
||||||
|
blurb: item.blurb,
|
||||||
|
text:
|
||||||
|
item.count == null
|
||||||
|
? String(item.label)
|
||||||
|
: `${item.label} ${formatCount(item.count)}`,
|
||||||
|
}));
|
||||||
|
const infoText = controls?.info ? String(controls.info) : '';
|
||||||
|
// The info line, once shown, stays in place and hides when empty.
|
||||||
|
if (infoText) row.info = true;
|
||||||
|
return {
|
||||||
|
hidden: chips.length === 0 && legend.length === 0 && !infoText,
|
||||||
|
chips,
|
||||||
|
legend,
|
||||||
|
info: row.info
|
||||||
|
? { text: infoText, title: controls?.infoTitle || '' }
|
||||||
|
: null,
|
||||||
|
list: controls?.list || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async _toggle(id, generation) {
|
||||||
|
const layer = this.getAll().find((entry) => entry.id === id);
|
||||||
|
if (
|
||||||
|
this._destroyed ||
|
||||||
|
this._generation !== generation ||
|
||||||
|
!layer ||
|
||||||
|
this._pending.has(id) ||
|
||||||
|
presentLayerToggle(layer).ariaDisabled
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
this._pending.add(id);
|
||||||
|
this._paint();
|
||||||
|
try {
|
||||||
|
await this.setEnabled(id, !this.isEnabled(id), { origin: 'user' });
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[Data] ${id} toggle error:`, error);
|
||||||
|
} finally {
|
||||||
|
this._pending.delete(id);
|
||||||
|
// Repaint even after a remount: the new rows were painted busy while
|
||||||
|
// this toggle was pending.
|
||||||
|
if (!this._destroyed) this._paint();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this._toggleContainer.appendChild(row);
|
// Re-read the live descriptor rather than trusting the rendered chip, so a
|
||||||
|
// stale row can never apply an inverted toggle. Unmounted rows keep their
|
||||||
|
// listeners, so a click from a released generation is dropped.
|
||||||
|
_selectChip(id, chipId, generation = this._generation) {
|
||||||
|
if (this._destroyed || this._generation !== generation) return;
|
||||||
|
const chip = this._rowControlsFor(id)?.chips?.find(
|
||||||
|
(entry) => entry.id === chipId,
|
||||||
|
);
|
||||||
|
if (!chip || chip.disabled || !this.isEnabled(id)) return;
|
||||||
|
if (typeof chip.onClick === 'function') chip.onClick();
|
||||||
|
else if (chip.params)
|
||||||
|
this.setLayerParams(id, chip.params, { origin: 'user' });
|
||||||
}
|
}
|
||||||
this._refreshWeatherPanel();
|
|
||||||
|
_selectListItem(id, itemId, generation = this._generation) {
|
||||||
|
if (this._destroyed || this._generation !== generation) return;
|
||||||
|
const item = this._rowControlsFor(id)?.list?.items?.find(
|
||||||
|
(entry) => entry.id === itemId,
|
||||||
|
);
|
||||||
|
if (item && !item.disabled && item.params)
|
||||||
|
this.setLayerParams(id, item.params, { origin: 'user' });
|
||||||
}
|
}
|
||||||
|
|
||||||
_scheduleRowControlsRefresh() {
|
_scheduleRowControlsRefresh() {
|
||||||
@@ -474,125 +550,6 @@ export class LayerPanel {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Write a row's count. Outside English a count phrase ("24 nearby · 2 on
|
|
||||||
* screen") gets its own line, or it squeezes a longer translated name to a
|
|
||||||
* word per line. "—" is marked so a wide status chip can replace it
|
|
||||||
* (radio.css).
|
|
||||||
*/
|
|
||||||
_syncCount(row, count, stats) {
|
|
||||||
const text = this._layerCountText(stats);
|
|
||||||
setRowText(count, text);
|
|
||||||
const phrase =
|
|
||||||
getLocale() !== 'en' &&
|
|
||||||
typeof stats.countLabel === 'string' &&
|
|
||||||
/\d/.test(stats.countLabel);
|
|
||||||
row.classList.toggle('data-toggle-row--count-line', phrase);
|
|
||||||
count.classList.toggle('data-count-empty', text === '—');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Qualify a loaded count when it does not mean items currently on screen. */
|
|
||||||
_layerCountText(stats) {
|
|
||||||
if (typeof stats.countLabel === 'string' && stats.countLabel.trim())
|
|
||||||
return stats.countLabel;
|
|
||||||
return stats.count ? this._formatCount(stats.count) : '—';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render a layer's row chips and color legend, and keep the whole block
|
|
||||||
* hidden while the layer is off (or while a dependency owner has surrendered
|
|
||||||
* it) so a quiet row stays quiet.
|
|
||||||
*
|
|
||||||
* Chip BUTTONS are reconciled in place, keyed by chip id, rather than
|
|
||||||
* rebuilt: this runs on every panel refresh — including the one the chip's
|
|
||||||
* own click triggers — and replacing the node would drop keyboard focus
|
|
||||||
* mid-interaction. Legend entries are rebuilt only when their content changes;
|
|
||||||
* the info node is retained across refreshes.
|
|
||||||
* @param {HTMLElement|null} container The row's `.data-toggle-controls` node.
|
|
||||||
* @param {object} layer Registered layer entry.
|
|
||||||
* @param {HTMLElement|null} [listContainer] The row's `.data-row-list` node.
|
|
||||||
*/
|
|
||||||
_syncRowControls(container, layer, listContainer = null) {
|
|
||||||
if (!container) return;
|
|
||||||
const controls = layer.enabled ? this._rowControlsFor(layer.id) : null;
|
|
||||||
if (controls?.readout) {
|
|
||||||
container.remove();
|
|
||||||
listContainer?.remove();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const chips = controls?.chips || [];
|
|
||||||
const legend = controls?.legend || [];
|
|
||||||
const infoText = controls?.info;
|
|
||||||
this._syncRowList(listContainer, controls?.list || null);
|
|
||||||
container.hidden = chips.length === 0 && legend.length === 0 && !infoText;
|
|
||||||
|
|
||||||
const info = container._rowControlsInfo || null;
|
|
||||||
const firstLegend = container.querySelector('.data-toggle-legend-item');
|
|
||||||
|
|
||||||
syncChipGroup(container, chips, { before: firstLegend || info });
|
|
||||||
|
|
||||||
const legendSignature = JSON.stringify(
|
|
||||||
legend.map(({ label, color, count, blurb }) => [
|
|
||||||
label,
|
|
||||||
color,
|
|
||||||
count,
|
|
||||||
blurb,
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
if (container._legendSignature !== legendSignature) {
|
|
||||||
for (const node of [...container.children]) {
|
|
||||||
if (node.className === 'data-toggle-legend-item') node.remove();
|
|
||||||
}
|
|
||||||
for (const item of legend) {
|
|
||||||
const entry = document.createElement('span');
|
|
||||||
entry.className = 'data-toggle-legend-item';
|
|
||||||
if (item.blurb) entry.title = item.blurb;
|
|
||||||
const swatch = document.createElement('span');
|
|
||||||
swatch.className = 'data-toggle-legend-swatch';
|
|
||||||
swatch.style.background = item.color;
|
|
||||||
const text = document.createElement('span');
|
|
||||||
text.textContent =
|
|
||||||
item.count == null
|
|
||||||
? String(item.label)
|
|
||||||
: `${item.label} ${this._formatCount(item.count)}`;
|
|
||||||
entry.append(swatch, text);
|
|
||||||
container.insertBefore(entry, info);
|
|
||||||
}
|
|
||||||
container._legendSignature = legendSignature;
|
|
||||||
}
|
|
||||||
if (infoText || info) {
|
|
||||||
const node = info || document.createElement('div');
|
|
||||||
if (!info) {
|
|
||||||
container.appendChild(node);
|
|
||||||
container._rowControlsInfo = node;
|
|
||||||
}
|
|
||||||
const className = 'data-toggle-controls-info';
|
|
||||||
if (node.className !== className) node.className = className;
|
|
||||||
const text = infoText ? String(infoText) : '';
|
|
||||||
const title = controls?.infoTitle || '';
|
|
||||||
if (node.textContent !== text) node.textContent = text;
|
|
||||||
if (node.title !== title) node.title = title;
|
|
||||||
const hidden = !text;
|
|
||||||
if (node.hidden !== hidden) node.hidden = hidden;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render a row's ordered list (turn-by-turn directions).
|
|
||||||
*
|
|
||||||
* Each entry is a real `<button>` inside a real `<li>`, so Tab reaches it and
|
|
||||||
* Enter activates it with no key handling of our own, and the `<ol>` carries
|
|
||||||
* the ordering a screen reader announces. Items are reconciled in place,
|
|
||||||
* keyed by id, for the same reason chips are: this runs on every refresh —
|
|
||||||
* including the one a click on the list triggers — and replacing the node
|
|
||||||
* would drop keyboard focus mid-interaction.
|
|
||||||
* @param {HTMLElement|null} container The row's `.data-row-list` node.
|
|
||||||
* @param {{ariaLabel?: string, items?: Array<object>}|null} list Descriptor.
|
|
||||||
*/
|
|
||||||
_syncRowList(container, list) {
|
|
||||||
syncRowList(container, list);
|
|
||||||
}
|
|
||||||
|
|
||||||
_refreshTogglePanel() {
|
_refreshTogglePanel() {
|
||||||
if (this._destroyed || !this._toggleContainer) return;
|
if (this._destroyed || !this._toggleContainer) return;
|
||||||
// Skip DOM churn while hidden; visibilitychange (main.js) triggers one
|
// Skip DOM churn while hidden; visibilitychange (main.js) triggers one
|
||||||
@@ -601,33 +558,7 @@ export class LayerPanel {
|
|||||||
this.onHiddenRefresh();
|
this.onHiddenRefresh();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (const layer of this.getAll()) {
|
this._paint();
|
||||||
const row = this._toggleContainer.querySelector(
|
|
||||||
`[data-layer-id="${layer.id}"]`,
|
|
||||||
);
|
|
||||||
if (!row) continue;
|
|
||||||
|
|
||||||
const btn = row.querySelector('.data-toggle-btn');
|
|
||||||
if (btn) {
|
|
||||||
this._syncToggleButton(btn, layer);
|
|
||||||
}
|
|
||||||
|
|
||||||
const count = row.querySelector('.data-count');
|
|
||||||
if (count) {
|
|
||||||
this._syncCount(row, count, layer.stats);
|
|
||||||
}
|
|
||||||
|
|
||||||
const meta = row.querySelector('.data-toggle-meta');
|
|
||||||
if (meta) {
|
|
||||||
setRowText(meta, this._buildMetaText(layer));
|
|
||||||
}
|
|
||||||
|
|
||||||
this._syncRowControls(
|
|
||||||
row.querySelector('.data-toggle-controls'),
|
|
||||||
layer,
|
|
||||||
row.querySelector('.data-row-list'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
this._refreshWeatherPanel();
|
this._refreshWeatherPanel();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -745,86 +676,6 @@ export class LayerPanel {
|
|||||||
return t('{source} · {ago}', { source, ago });
|
return t('{source} · {ago}', { source, ago });
|
||||||
}
|
}
|
||||||
|
|
||||||
_syncToggleButton(button, layer) {
|
|
||||||
const unavailable = layer.stats?.sourceUnavailable === true;
|
|
||||||
const feedState = unavailable
|
|
||||||
? 'unavailable'
|
|
||||||
: layer.enabled
|
|
||||||
? layerFeedState(layer.stats)
|
|
||||||
: 'off';
|
|
||||||
const transitioning =
|
|
||||||
layer.lifecycleState === 'enabling' ||
|
|
||||||
layer.lifecycleState === 'disabling';
|
|
||||||
const uncertain = Boolean(layer.lifecycleUncertain);
|
|
||||||
button.classList.toggle('active', layer.enabled);
|
|
||||||
button.classList.toggle('transitioning', transitioning);
|
|
||||||
button.classList.toggle('enabling', layer.lifecycleState === 'enabling');
|
|
||||||
button.classList.toggle('disabling', layer.lifecycleState === 'disabling');
|
|
||||||
button.classList.toggle('lifecycle-uncertain', uncertain);
|
|
||||||
for (const state of Object.keys(FEED_STATE_LABEL_KEYS)) {
|
|
||||||
button.classList.toggle(
|
|
||||||
`feed-${state}`,
|
|
||||||
(layer.enabled || unavailable) && !uncertain && feedState === state,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
button.dataset.feedState = transitioning
|
|
||||||
? layer.lifecycleState
|
|
||||||
: uncertain
|
|
||||||
? 'uncertain'
|
|
||||||
: feedState;
|
|
||||||
// A busy toggle remains the keyboard focus owner. `aria-disabled` plus the
|
|
||||||
// click guard above prevents repeat activation without the focus loss caused
|
|
||||||
// by native `disabled`.
|
|
||||||
button.disabled = false;
|
|
||||||
button.setAttribute(
|
|
||||||
'aria-disabled',
|
|
||||||
String(transitioning || (unavailable && !layer.enabled)),
|
|
||||||
);
|
|
||||||
button.setAttribute('aria-busy', String(transitioning));
|
|
||||||
const showsFeedState =
|
|
||||||
!transitioning && !uncertain && (layer.enabled || unavailable);
|
|
||||||
const stateText = transitioning
|
|
||||||
? lifecycleStateLabel(layer.lifecycleState)
|
|
||||||
: uncertain
|
|
||||||
? t('UNCERTAIN')
|
|
||||||
: showsFeedState
|
|
||||||
? t(FEED_STATE_LABEL_KEYS[feedState])
|
|
||||||
: t('OFF');
|
|
||||||
// The chip may abbreviate where a language's word is wider than the
|
|
||||||
// status column; the meta line and the accessible name keep the full word.
|
|
||||||
button.textContent =
|
|
||||||
showsFeedState && feedState === 'unavailable'
|
|
||||||
? t('UNAVAILABLE', { context: 'status chip' })
|
|
||||||
: stateText;
|
|
||||||
const keyGuidance = unavailable
|
|
||||||
? String(
|
|
||||||
translateFeedError(layer.stats.error) || t('Data source unavailable'),
|
|
||||||
)
|
|
||||||
: layerKeyRequirementTooltip(layer);
|
|
||||||
// Name the missing key on the control itself: a row reading KEY REQUIRED
|
|
||||||
// without saying WHICH key leaves a dead control and no next step. Empty
|
|
||||||
// when the layer needs no key, or already has one.
|
|
||||||
button.title = keyGuidance;
|
|
||||||
const ariaLabel = t('{name}: {state}', {
|
|
||||||
name: panelLabel(layer),
|
|
||||||
state: stateText,
|
|
||||||
});
|
|
||||||
button.setAttribute(
|
|
||||||
'aria-label',
|
|
||||||
keyGuidance ? `${ariaLabel}. ${keyGuidance}` : ariaLabel,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
_formatCount(n) {
|
|
||||||
if (n >= 1000)
|
|
||||||
return `${formatNumber(n / 1000, {
|
|
||||||
minimumFractionDigits: 1,
|
|
||||||
maximumFractionDigits: 1,
|
|
||||||
useGrouping: false,
|
|
||||||
})}K`;
|
|
||||||
return String(n);
|
|
||||||
}
|
|
||||||
|
|
||||||
_timeAgo(timestamp) {
|
_timeAgo(timestamp) {
|
||||||
const diff = Math.floor((Date.now() - timestamp) / 1000);
|
const diff = Math.floor((Date.now() - timestamp) / 1000);
|
||||||
if (diff < 5) return t('just now');
|
if (diff < 5) return t('just now');
|
||||||
|
|||||||
+55
-102
@@ -1,3 +1,4 @@
|
|||||||
|
import '../testSupport/registerDom.mjs';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
@@ -34,14 +35,8 @@ test('panel presentation places Transit between Street Traffic and Bike Share in
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('partial feed controls distinguish incomplete records from stale data and outages', async () => {
|
test('partial feed controls distinguish incomplete records from stale data and outages', async () => {
|
||||||
const { LayerPanel, layerFeedState } = await import('./layerPanel.js');
|
const { LayerPanel, layerFeedState, presentLayerToggle } =
|
||||||
const classes = new Map();
|
await import('./layerPanel.js');
|
||||||
const attrs = new Map();
|
|
||||||
const button = {
|
|
||||||
classList: { toggle: (key, value) => classes.set(key, value) },
|
|
||||||
dataset: {},
|
|
||||||
setAttribute: (key, value) => attrs.set(key, value),
|
|
||||||
};
|
|
||||||
const layer = {
|
const layer = {
|
||||||
id: 'ais-live-vessels',
|
id: 'ais-live-vessels',
|
||||||
name: 'Live Vessels',
|
name: 'Live Vessels',
|
||||||
@@ -57,12 +52,12 @@ test('partial feed controls distinguish incomplete records from stale data and o
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
const panel = LayerPanel.prototype;
|
const panel = LayerPanel.prototype;
|
||||||
panel._syncToggleButton(button, layer);
|
let toggle = presentLayerToggle(layer);
|
||||||
assert.equal(button.textContent, 'PARTIAL');
|
assert.equal(toggle.text, 'PARTIAL');
|
||||||
assert.equal(button.dataset.feedState, 'partial');
|
assert.equal(toggle.feedState, 'partial');
|
||||||
assert.equal(classes.get('feed-partial'), true);
|
assert.equal(toggle.classes['feed-partial'], true);
|
||||||
assert.equal(classes.get('feed-stale'), false);
|
assert.equal(toggle.classes['feed-stale'], false);
|
||||||
assert.match(attrs.get('aria-label'), /PARTIAL/);
|
assert.match(toggle.ariaLabel, /PARTIAL/);
|
||||||
assert.match(
|
assert.match(
|
||||||
panel._buildMetaText(layer),
|
panel._buildMetaText(layer),
|
||||||
/^PARTIAL · AISStream · 2 of 3 records accepted · /,
|
/^PARTIAL · AISStream · 2 of 3 records accepted · /,
|
||||||
@@ -85,12 +80,11 @@ test('partial feed controls distinguish incomplete records from stale data and o
|
|||||||
);
|
);
|
||||||
assert.equal(layerFeedState({ ...layer.stats, loading: true }), 'loading');
|
assert.equal(layerFeedState({ ...layer.stats, loading: true }), 'loading');
|
||||||
layer.stats = { ...layer.stats, partial: false };
|
layer.stats = { ...layer.stats, partial: false };
|
||||||
panel._syncToggleButton(button, layer);
|
toggle = presentLayerToggle(layer);
|
||||||
assert.equal(button.textContent, 'ON');
|
assert.equal(toggle.text, 'ON');
|
||||||
assert.equal(classes.get('feed-partial'), false);
|
assert.equal(toggle.classes['feed-partial'], false);
|
||||||
layer.enabled = false;
|
layer.enabled = false;
|
||||||
panel._syncToggleButton(button, layer);
|
assert.equal(presentLayerToggle(layer).text, 'OFF');
|
||||||
assert.equal(button.textContent, 'OFF');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a stale row dates cached data by its oldest tile, not the last load', async () => {
|
test('a stale row dates cached data by its oldest tile, not the last load', async () => {
|
||||||
@@ -116,10 +110,9 @@ test('a stale row dates cached data by its oldest tile, not the last load', asyn
|
|||||||
|
|
||||||
test('readout rows contain only toggles and metadata; ordinary rows retain controls', async () => {
|
test('readout rows contain only toggles and metadata; ordinary rows retain controls', async () => {
|
||||||
const { LayerPanel } = await import('./layerPanel.js');
|
const { LayerPanel } = await import('./layerPanel.js');
|
||||||
const { railFixture } = await import('./railTestFixture.mjs');
|
document.body.replaceChildren();
|
||||||
const f = railFixture();
|
const container = document.body.appendChild(document.createElement('div'));
|
||||||
const previousDocument = globalThis.document;
|
const find = (id) => container.querySelector(`[data-layer-id="${id}"]`);
|
||||||
globalThis.document = f.document;
|
|
||||||
const ids = [
|
const ids = [
|
||||||
'wind',
|
'wind',
|
||||||
'weather-radar',
|
'weather-radar',
|
||||||
@@ -160,9 +153,9 @@ test('readout rows contain only toggles and metadata; ordinary rows retain contr
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
panel.mount(f.container);
|
panel.mount(container);
|
||||||
for (const id of ids.slice(0, -1)) {
|
for (const id of ids.slice(0, -1)) {
|
||||||
const row = f.find((n) => n.dataset.layerId === id);
|
const row = find(id);
|
||||||
assert.equal(row.children.length, 2);
|
assert.equal(row.children.length, 2);
|
||||||
assert.ok(row.querySelector('.data-name'));
|
assert.ok(row.querySelector('.data-name'));
|
||||||
assert.ok(row.querySelector('.data-toggle-meta'));
|
assert.ok(row.querySelector('.data-toggle-meta'));
|
||||||
@@ -173,14 +166,14 @@ test('readout rows contain only toggles and metadata; ordinary rows retain contr
|
|||||||
'data-toggle-controls-status',
|
'data-toggle-controls-status',
|
||||||
'data-row-list',
|
'data-row-list',
|
||||||
])
|
])
|
||||||
assert.equal(row.querySelector(`.${cls}`), null);
|
assert.ok(!row.querySelector(`.${cls}`), cls);
|
||||||
row.querySelector('.data-toggle-btn').click();
|
row.querySelector('.data-toggle-btn').click();
|
||||||
}
|
}
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
calls.map(([id]) => id),
|
calls.map(([id]) => id),
|
||||||
ids.slice(0, -1),
|
ids.slice(0, -1),
|
||||||
);
|
);
|
||||||
const ordinary = f.find((n) => n.dataset.layerId === 'other');
|
const ordinary = find('other');
|
||||||
assert.equal(
|
assert.equal(
|
||||||
ordinary.querySelector('.data-toggle-controls-info').textContent,
|
ordinary.querySelector('.data-toggle-controls-info').textContent,
|
||||||
'Detailed time',
|
'Detailed time',
|
||||||
@@ -195,19 +188,16 @@ test('readout rows contain only toggles and metadata; ordinary rows retain contr
|
|||||||
assert.equal(typeof refresh, 'function');
|
assert.equal(typeof refresh, 'function');
|
||||||
} finally {
|
} finally {
|
||||||
panel.destroy();
|
panel.destroy();
|
||||||
globalThis.document = previousDocument;
|
document.body.replaceChildren();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the Recent Imagery readout mounts in its rail body like the weather readout and is rebuilt or released with the panel', async () => {
|
test('the Recent Imagery readout mounts in its rail body like the weather readout and is rebuilt or released with the panel', async () => {
|
||||||
const { LayerPanel } = await import('./layerPanel.js');
|
const { LayerPanel } = await import('./layerPanel.js');
|
||||||
const { railFixture } = await import('./railTestFixture.mjs');
|
document.body.replaceChildren();
|
||||||
const f = railFixture();
|
const container = document.body.appendChild(document.createElement('div'));
|
||||||
const body = f.document.createElement('div');
|
const body = document.body.appendChild(document.createElement('div'));
|
||||||
f.document.getElementById = (id) =>
|
body.id = 'recent-imagery-panel-body';
|
||||||
id === 'recent-imagery-panel-body' ? body : null;
|
|
||||||
const previousDocument = globalThis.document;
|
|
||||||
globalThis.document = f.document;
|
|
||||||
const mounted = [];
|
const mounted = [];
|
||||||
const factory = (container) => {
|
const factory = (container) => {
|
||||||
const readout = {
|
const readout = {
|
||||||
@@ -232,10 +222,10 @@ test('the Recent Imagery readout mounts in its rail body like the weather readou
|
|||||||
try {
|
try {
|
||||||
panel.attachRecentImagery(factory);
|
panel.attachRecentImagery(factory);
|
||||||
assert.equal(mounted.length, 0, 'no body before the panel mounts');
|
assert.equal(mounted.length, 0, 'no body before the panel mounts');
|
||||||
panel.mount(f.container);
|
panel.mount(container);
|
||||||
assert.equal(mounted.length, 1);
|
assert.equal(mounted.length, 1);
|
||||||
assert.equal(mounted[0].container, body);
|
assert.ok(mounted[0].container === body);
|
||||||
panel.mount(f.container);
|
panel.mount(container);
|
||||||
assert.equal(mounted[0].destroyed, true, 'a remount rebuilds it');
|
assert.equal(mounted[0].destroyed, true, 'a remount rebuilds it');
|
||||||
assert.equal(mounted.length, 2);
|
assert.equal(mounted.length, 2);
|
||||||
panel.attachRecentImagery(null);
|
panel.attachRecentImagery(null);
|
||||||
@@ -248,18 +238,12 @@ test('the Recent Imagery readout mounts in its rail body like the weather readou
|
|||||||
assert.equal(mounted.length, 3, 'a destroyed panel mounts nothing');
|
assert.equal(mounted.length, 3, 'a destroyed panel mounts nothing');
|
||||||
} finally {
|
} finally {
|
||||||
panel.destroy();
|
panel.destroy();
|
||||||
globalThis.document = previousDocument;
|
document.body.replaceChildren();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('an omitted source is visibly unavailable and cannot be toggled on', async () => {
|
test('an omitted source is visibly unavailable and cannot be toggled on', async () => {
|
||||||
const { LayerPanel } = await import('./layerPanel.js');
|
const { presentLayerToggle } = await import('./layerPanel.js');
|
||||||
const attrs = new Map();
|
|
||||||
const button = {
|
|
||||||
classList: { toggle() {} },
|
|
||||||
dataset: {},
|
|
||||||
setAttribute: (key, value) => attrs.set(key, value),
|
|
||||||
};
|
|
||||||
const layer = {
|
const layer = {
|
||||||
id: 'flights',
|
id: 'flights',
|
||||||
name: 'Flights',
|
name: 'Flights',
|
||||||
@@ -270,62 +254,37 @@ test('an omitted source is visibly unavailable and cannot be toggled on', async
|
|||||||
error: 'Data source not configured: flights',
|
error: 'Data source not configured: flights',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
LayerPanel.prototype._syncToggleButton(button, layer);
|
const toggle = presentLayerToggle(layer);
|
||||||
assert.equal(button.textContent, 'UNAVAILABLE');
|
assert.equal(toggle.text, 'UNAVAILABLE');
|
||||||
assert.equal(attrs.get('aria-disabled'), 'true');
|
assert.equal(toggle.ariaDisabled, true);
|
||||||
assert.match(button.title, /not configured/);
|
assert.match(toggle.title, /not configured/);
|
||||||
LayerPanel.prototype._syncToggleButton(button, { ...layer, enabled: true });
|
|
||||||
assert.equal(
|
assert.equal(
|
||||||
attrs.get('aria-disabled'),
|
presentLayerToggle({ ...layer, enabled: true }).ariaDisabled,
|
||||||
'false',
|
false,
|
||||||
'an active layer can always be switched off',
|
'an active layer can always be switched off',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('row text keeps a hyphenated code whole and never starts a line with the separator', async () => {
|
test('row text keeps a hyphenated code whole and never starts a line with the separator', async () => {
|
||||||
const { setRowText } = await import('./layerPanel.js');
|
const { rowTextRuns } = await import('./layerRowText.js');
|
||||||
const previousDocument = globalThis.document;
|
const text = (runs) => runs.map((run) => run.text).join('');
|
||||||
const element = () => ({
|
const kept = (runs) => runs.filter((run) => run.keep).map((run) => run.text);
|
||||||
className: '',
|
|
||||||
textContent: '',
|
|
||||||
children: [],
|
|
||||||
appendChild(child) {
|
|
||||||
this.children.push(child);
|
|
||||||
return child;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
globalThis.document = { createElement: element };
|
|
||||||
try {
|
|
||||||
const text = (node) =>
|
|
||||||
node.children.length
|
|
||||||
? node.children.map((child) => child.textContent).join('')
|
|
||||||
: node.textContent;
|
|
||||||
const kept = (node) =>
|
|
||||||
node.children
|
|
||||||
.filter((child) => child.className === 'data-keep')
|
|
||||||
.map((child) => child.textContent);
|
|
||||||
|
|
||||||
const name = element();
|
const name = rowTextRuns('Локальный ADS-B');
|
||||||
setRowText(name, 'Локальный ADS-B');
|
|
||||||
assert.equal(text(name), 'Локальный ADS-B');
|
assert.equal(text(name), 'Локальный ADS-B');
|
||||||
assert.deepEqual(kept(name), ['ADS-B']);
|
assert.deepEqual(kept(name), ['ADS-B']);
|
||||||
|
|
||||||
const meta = element();
|
const meta = rowTextRuns('UNAVAILABLE · NASA FIRMS · LIVE · KEY REQUIRED');
|
||||||
setRowText(meta, 'UNAVAILABLE · NASA FIRMS · LIVE · KEY REQUIRED');
|
|
||||||
assert.equal(text(meta), 'UNAVAILABLE · NASA FIRMS · LIVE · KEY REQUIRED');
|
assert.equal(text(meta), 'UNAVAILABLE · NASA FIRMS · LIVE · KEY REQUIRED');
|
||||||
assert.deepEqual(kept(meta), [' ·', ' ·', ' ·']);
|
assert.deepEqual(kept(meta), [' ·', ' ·', ' ·']);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
meta.children.every((child) => !child.textContent.startsWith('·')),
|
meta.every((run) => !run.text.startsWith('·')),
|
||||||
'the space before each dot is held to the piece it closes',
|
'the space before each dot is held to the piece it closes',
|
||||||
);
|
);
|
||||||
|
|
||||||
const plain = element();
|
assert.equal(rowTextRuns('Satellites'), null, 'plain text needs no runs');
|
||||||
setRowText(plain, 'Satellites');
|
|
||||||
assert.equal(plain.textContent, 'Satellites');
|
|
||||||
assert.equal(plain.children.length, 0);
|
|
||||||
|
|
||||||
const compound = element();
|
const compound = rowTextRuns('Kartierte ALPR-Kameras · ADS-B-Flüge');
|
||||||
setRowText(compound, 'Kartierte ALPR-Kameras · ADS-B-Flüge');
|
|
||||||
assert.equal(text(compound), 'Kartierte ALPR-Kameras · ADS-B-Flüge');
|
assert.equal(text(compound), 'Kartierte ALPR-Kameras · ADS-B-Flüge');
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
kept(compound),
|
kept(compound),
|
||||||
@@ -333,31 +292,25 @@ test('row text keeps a hyphenated code whole and never starts a line with the se
|
|||||||
'a compound may break at its hyphen; the code inside it stays whole',
|
'a compound may break at its hyphen; the code inside it stays whole',
|
||||||
);
|
);
|
||||||
|
|
||||||
const long = element();
|
const long = rowTextRuns('see https-proxy-endpoint-config-x');
|
||||||
setRowText(long, 'see https-proxy-endpoint-config-x');
|
assert.deepEqual(
|
||||||
assert.deepEqual(kept(long), [], 'a long hyphenated run may still wrap');
|
kept(long || []),
|
||||||
} finally {
|
[],
|
||||||
globalThis.document = previousDocument;
|
'a long hyphenated run may still wrap',
|
||||||
}
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the unavailable chip may abbreviate, but its accessible name keeps the full state', async () => {
|
test('the unavailable chip may abbreviate, but its accessible name keeps the full state', async () => {
|
||||||
const { LayerPanel } = await import('./layerPanel.js');
|
const { presentLayerToggle } = await import('./layerPanel.js');
|
||||||
const attrs = new Map();
|
const toggle = presentLayerToggle({
|
||||||
const button = {
|
|
||||||
classList: { toggle() {} },
|
|
||||||
dataset: {},
|
|
||||||
setAttribute: (key, value) => attrs.set(key, value),
|
|
||||||
};
|
|
||||||
LayerPanel.prototype._syncToggleButton(button, {
|
|
||||||
id: 'local-firms',
|
id: 'local-firms',
|
||||||
name: 'FIRMS Active Fires',
|
name: 'FIRMS Active Fires',
|
||||||
enabled: false,
|
enabled: false,
|
||||||
stats: { sourceUnavailable: true, error: 'KEY REQUIRED' },
|
stats: { sourceUnavailable: true, error: 'KEY REQUIRED' },
|
||||||
});
|
});
|
||||||
assert.equal(button.textContent, 'UNAVAILABLE');
|
assert.equal(toggle.text, 'UNAVAILABLE');
|
||||||
assert.equal(button.dataset.feedState, 'unavailable');
|
assert.equal(toggle.feedState, 'unavailable');
|
||||||
assert.match(attrs.get('aria-label'), /^Active Fires: UNAVAILABLE\./);
|
assert.match(toggle.ariaLabel, /^Active Fires: UNAVAILABLE\./);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the Weather readout receives enabled air-quality alongside weather layers', async () => {
|
test('the Weather readout receives enabled air-quality alongside weather layers', async () => {
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<script setup>
|
||||||
|
import RailChips from '../rail/RailChips.vue';
|
||||||
|
import RailRowList from '../rail/RailRowList.vue';
|
||||||
|
import RowText from './RowText.vue';
|
||||||
|
|
||||||
|
// The data layer rows under their group headings. LayerPanel presents every
|
||||||
|
// value; clicks reach it with the layer id, and it re-reads the live
|
||||||
|
// descriptor before acting.
|
||||||
|
defineProps({
|
||||||
|
items: { type: Array, required: true },
|
||||||
|
onToggle: { type: Function, default: () => {} },
|
||||||
|
onChip: { type: Function, default: () => {} },
|
||||||
|
onListItem: { type: Function, default: () => {} },
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<template v-for="item in items" :key="item.key"
|
||||||
|
><h3 v-if="item.heading" class="data-layer-group-heading">{{
|
||||||
|
item.heading
|
||||||
|
}}</h3
|
||||||
|
><div
|
||||||
|
v-else
|
||||||
|
:class="[
|
||||||
|
'data-toggle-row',
|
||||||
|
{ 'data-toggle-row--count-line': item.count?.countLine },
|
||||||
|
]"
|
||||||
|
:data-layer-id="item.id"
|
||||||
|
:data-layer-name="item.englishName"
|
||||||
|
><div class="data-toggle-top"
|
||||||
|
><div class="data-toggle-left"
|
||||||
|
><span class="data-icon" aria-hidden="true">{{ item.icon }}</span
|
||||||
|
><RowText class="data-name" :text="item.name" /></div
|
||||||
|
><div class="data-toggle-right"
|
||||||
|
><RowText
|
||||||
|
v-if="item.count"
|
||||||
|
:class="['data-count', { 'data-count-empty': item.count.empty }]"
|
||||||
|
:text="item.count.text"
|
||||||
|
/><button
|
||||||
|
type="button"
|
||||||
|
:class="['data-toggle-btn', item.toggle.classes]"
|
||||||
|
:data-feed-state="item.toggle.feedState"
|
||||||
|
:aria-disabled="String(item.toggle.ariaDisabled)"
|
||||||
|
:aria-busy="String(item.toggle.ariaBusy)"
|
||||||
|
:title="item.toggle.title"
|
||||||
|
:aria-label="item.toggle.ariaLabel"
|
||||||
|
@click="onToggle(item.id)"
|
||||||
|
>{{ item.toggle.text }}</button
|
||||||
|
></div
|
||||||
|
></div
|
||||||
|
><RowText tag="div" class="data-toggle-meta" :text="item.meta" /><template
|
||||||
|
v-if="item.controls"
|
||||||
|
><div class="data-toggle-controls" :hidden="item.controls.hidden"
|
||||||
|
><RailChips
|
||||||
|
:chips="item.controls.chips"
|
||||||
|
:on-select="(chip) => onChip(item.id, chip.id)"
|
||||||
|
/><span
|
||||||
|
v-for="(entry, index) in item.controls.legend"
|
||||||
|
:key="index"
|
||||||
|
class="data-toggle-legend-item"
|
||||||
|
:title="entry.blurb || undefined"
|
||||||
|
><span
|
||||||
|
class="data-toggle-legend-swatch"
|
||||||
|
:style="{ background: entry.color }"
|
||||||
|
></span
|
||||||
|
><span>{{ entry.text }}</span></span
|
||||||
|
><div
|
||||||
|
v-if="item.controls.info"
|
||||||
|
class="data-toggle-controls-info"
|
||||||
|
:title="item.controls.info.title"
|
||||||
|
:hidden="!item.controls.info.text"
|
||||||
|
>{{ item.controls.info.text }}</div
|
||||||
|
></div
|
||||||
|
><RailRowList
|
||||||
|
:list="item.controls.list"
|
||||||
|
:on-select="
|
||||||
|
(entry) => onListItem(item.id, entry.id)
|
||||||
|
" /></template></div
|
||||||
|
></template>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { rowTextRuns } from '../layerRowText.js';
|
||||||
|
|
||||||
|
// Row text that wraps well in every language: hyphenated codes and the
|
||||||
|
// space before a " · " separator never break (see rowTextRuns).
|
||||||
|
const props = defineProps({
|
||||||
|
tag: { type: String, default: 'span' },
|
||||||
|
text: { type: String, default: '' },
|
||||||
|
});
|
||||||
|
const runs = computed(() => rowTextRuns(props.text));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<component :is="tag"
|
||||||
|
><template v-if="runs"
|
||||||
|
><span
|
||||||
|
v-for="(run, index) in runs"
|
||||||
|
:key="index"
|
||||||
|
v-bind="run.keep ? { class: 'data-keep' } : {}"
|
||||||
|
>{{ run.text }}</span
|
||||||
|
></template
|
||||||
|
><template v-else>{{ text }}</template></component
|
||||||
|
>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
const ROW_TEXT_SEPARATOR = ' · ';
|
||||||
|
|
||||||
|
// A short Latin code joined by hyphens: every part after a hyphen is capitals
|
||||||
|
// or digits (ADS-B, RTL-SDR, GTFS-RT). A compound such as "ALPR-Kameras" is a
|
||||||
|
// word, not a code, and may break at its hyphen. Longer runs (a URL, a token
|
||||||
|
// in an error) keep their break opportunities so they cannot overflow the rail.
|
||||||
|
const HYPHENATED_CODE = /\b[A-Za-z0-9]+(?:-[A-Z0-9]+)+(?![A-Za-z0-9])/g;
|
||||||
|
const HYPHENATED_CODE_MAX = 16;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split a row's name, count or meta line so that a narrow column wraps it well
|
||||||
|
* in every language: a Latin code with hyphens (ADS-B, RTL-SDR, GTFS-RT) never
|
||||||
|
* splits at its hyphen, and a " · " separator stays at the end of the line it
|
||||||
|
* closes, so no line starts with "·". Each run is `{text, keep}`, where `keep`
|
||||||
|
* stops the browser breaking inside it; the runs join back to the text. Text
|
||||||
|
* with neither feature returns null and is shown as it is.
|
||||||
|
*
|
||||||
|
* @param {string} text
|
||||||
|
* @returns {Array<{text: string, keep: boolean}>|null}
|
||||||
|
*/
|
||||||
|
export function rowTextRuns(text) {
|
||||||
|
const value = String(text ?? '');
|
||||||
|
const codes = [...value.matchAll(HYPHENATED_CODE)].filter(
|
||||||
|
(match) => match[0].length <= HYPHENATED_CODE_MAX,
|
||||||
|
);
|
||||||
|
if (!codes.length && !value.includes(ROW_TEXT_SEPARATOR)) return null;
|
||||||
|
const runs = [];
|
||||||
|
const pushPlain = (from, to) => {
|
||||||
|
if (to <= from) return;
|
||||||
|
const pieces = value.slice(from, to).split(ROW_TEXT_SEPARATOR);
|
||||||
|
pieces.forEach((piece, index) => {
|
||||||
|
// The space before the dot belongs to a no-wrap run, so the only break
|
||||||
|
// opportunity around a separator is the space after it.
|
||||||
|
if (index > 0) runs.push({ text: ' ·', keep: true });
|
||||||
|
const textPiece = index > 0 ? ` ${piece}` : piece;
|
||||||
|
if (textPiece) runs.push({ text: textPiece, keep: false });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
let cursor = 0;
|
||||||
|
for (const match of codes) {
|
||||||
|
pushPlain(cursor, match.index);
|
||||||
|
runs.push({ text: match[0], keep: true });
|
||||||
|
cursor = match.index + match[0].length;
|
||||||
|
}
|
||||||
|
pushPlain(cursor, value.length);
|
||||||
|
return runs;
|
||||||
|
}
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
const set = (node, key, value) => {
|
|
||||||
if (node[key] !== value) node[key] = value;
|
|
||||||
};
|
|
||||||
const attribute = (node, key, value) => {
|
|
||||||
if (node.getAttribute(key) !== value) node.setAttribute(key, value);
|
|
||||||
};
|
|
||||||
/** Reconcile ordered layer/card lists without replacing focused items. */
|
|
||||||
export function syncRowList(container, list) {
|
|
||||||
const document = container?.ownerDocument ?? globalThis.document;
|
|
||||||
if (!container) return;
|
|
||||||
const items = list?.items || [];
|
|
||||||
set(container, 'hidden', items.length === 0);
|
|
||||||
attribute(container, 'aria-label', list?.ariaLabel || '');
|
|
||||||
|
|
||||||
const stale = new Map();
|
|
||||||
for (const node of [...container.children]) {
|
|
||||||
if (node.dataset?.listItemId) stale.set(node.dataset.listItemId, node);
|
|
||||||
}
|
|
||||||
let index = 0;
|
|
||||||
let activeButton = null;
|
|
||||||
for (const item of items) {
|
|
||||||
let entry = stale.get(item.id);
|
|
||||||
stale.delete(item.id);
|
|
||||||
let button;
|
|
||||||
if (!entry) {
|
|
||||||
entry = document.createElement('li');
|
|
||||||
entry.dataset.listItemId = item.id;
|
|
||||||
button = document.createElement('button');
|
|
||||||
button.type = 'button';
|
|
||||||
button.className = 'data-row-list-item';
|
|
||||||
button.dataset.listItemId = item.id;
|
|
||||||
const lead = document.createElement('span');
|
|
||||||
lead.className = 'data-row-list-lead';
|
|
||||||
const text = document.createElement('span');
|
|
||||||
text.className = 'data-row-list-text';
|
|
||||||
button.append(lead, text);
|
|
||||||
entry.appendChild(button);
|
|
||||||
} else {
|
|
||||||
button = entry.querySelector('.data-row-list-item');
|
|
||||||
}
|
|
||||||
// Keep DOM order in step with descriptor order without rebuilding.
|
|
||||||
const anchor = container.children[index++] || null;
|
|
||||||
if (entry !== anchor) container.insertBefore(entry, anchor);
|
|
||||||
if (!button) continue;
|
|
||||||
const lead = button.querySelector('.data-row-list-lead');
|
|
||||||
const text = button.querySelector('.data-row-list-text');
|
|
||||||
const leadText = String(item.lead ?? '');
|
|
||||||
const bodyText = String(item.text ?? '');
|
|
||||||
if (lead && lead.textContent !== leadText) lead.textContent = leadText;
|
|
||||||
if (text && text.textContent !== bodyText) text.textContent = bodyText;
|
|
||||||
set(button, 'disabled', Boolean(item.disabled));
|
|
||||||
set(
|
|
||||||
button,
|
|
||||||
'className',
|
|
||||||
`data-row-list-item${item.disabled ? ' note' : ''}${item.active ? ' active' : ''}${item.current ? ' current' : ''}`,
|
|
||||||
);
|
|
||||||
attribute(button, 'aria-current', item.current ? 'step' : 'false');
|
|
||||||
attribute(button, 'aria-pressed', item.active ? 'true' : 'false');
|
|
||||||
set(button, 'title', bodyText);
|
|
||||||
if (item.current) activeButton = button;
|
|
||||||
}
|
|
||||||
for (const node of stale.values()) node.remove();
|
|
||||||
// Follow the flight, but never steal a scroll the reader is making
|
|
||||||
// themselves: only when the step actually changed.
|
|
||||||
if (
|
|
||||||
activeButton &&
|
|
||||||
container.dataset.currentId !== activeButton.dataset.listItemId
|
|
||||||
) {
|
|
||||||
container.dataset.currentId = activeButton.dataset.listItemId;
|
|
||||||
activeButton.scrollIntoView?.({ block: 'nearest' });
|
|
||||||
} else if (!activeButton) {
|
|
||||||
delete container.dataset.currentId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -128,20 +128,9 @@ test('cockpit brief controls keep their English sizes', () => {
|
|||||||
|
|
||||||
test('the count-line class is set only outside English', async () => {
|
test('the count-line class is set only outside English', async () => {
|
||||||
const { setLocale } = await import('../../i18n/index.js');
|
const { setLocale } = await import('../../i18n/index.js');
|
||||||
const { LayerPanel } = await import('../layerPanel.js');
|
const { presentLayerCount } = await import('../layerPanel.js');
|
||||||
const classes = () => {
|
const classes = () =>
|
||||||
const toggled = new Map();
|
presentLayerCount({ countLabel: '693 nearby · 93 on screen' }).countLine;
|
||||||
const node = () => ({
|
|
||||||
textContent: '',
|
|
||||||
classList: { toggle: (name, on) => toggled.set(name, on) },
|
|
||||||
appendChild() {},
|
|
||||||
});
|
|
||||||
LayerPanel.prototype._syncCount.call(LayerPanel.prototype, node(), node(), {
|
|
||||||
countLabel: '693 nearby · 93 on screen',
|
|
||||||
});
|
|
||||||
return toggled.get('data-toggle-row--count-line');
|
|
||||||
};
|
|
||||||
globalThis.document ??= { createElement: () => ({}) };
|
|
||||||
try {
|
try {
|
||||||
setLocale('en');
|
setLocale('en');
|
||||||
assert.equal(classes(), false);
|
assert.equal(classes(), false);
|
||||||
|
|||||||
@@ -697,7 +697,7 @@ html:not([lang='en']) .data-toggle-right {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Row text pieces that must not break inside: a hyphenated code (ADS-B) and
|
/* Row text pieces that must not break inside: a hyphenated code (ADS-B) and
|
||||||
the space before a " · " separator (see setRowText in layerPanel.js). */
|
the space before a " · " separator (see rowTextRuns in layerRowText.js). */
|
||||||
html:not([lang='en']) .data-keep {
|
html:not([lang='en']) .data-keep {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user