feat(ui): render the data layer rows through Vue from presented row state

This commit is contained in:
Sameh Khamis committed 2026-10-30 22:23:40 -07:00
1 parent ce343c1c6d
commit d63f4d697b
16 files changed
+1417 -1535

No files matched your search

+3 -3
View File
@@ -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?,
+4 -1
View File
@@ -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.
+9 -6
View File
@@ -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",
-3
View File
@@ -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
View File
@@ -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({});
+931
View File
@@ -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();
}
});
-53
View File
@@ -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);
});
}
-54
View File
@@ -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
View File
@@ -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
View File
@@ -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 () => {
+80
View File
@@ -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>
+25
View File
@@ -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>
+47
View File
@@ -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;
}
-74
View File
@@ -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;
}
}
+3 -14
View File
@@ -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);
+1 -1
View File
@@ -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;
} }