diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md index 93f71d234..1e039fa1b 100644 --- a/docs/CODE-BOUNDARIES.md +++ b/docs/CODE-BOUNDARIES.md @@ -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. `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. -- `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. - `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 ramp/scale (including a physical freezing anchor) or separate keyed swatches. - `actions`: keyed `actions: [{ id, label, params?, href?, onClick?, disabled?, diff --git a/docs/VUE-MIGRATION.md b/docs/VUE-MIGRATION.md index 4424b73c1..2c9478e11 100644 --- a/docs/VUE-MIGRATION.md +++ b/docs/VUE-MIGRATION.md @@ -90,7 +90,10 @@ They contain no application state. the whole panel from the `gev-cctv` store, including the camera picker, the inline pose editor, the frame preview, source badge and typed 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 directly gain a slot instead. diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index d9041aebd..dbf39cef1 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -548,8 +548,9 @@ "src/ui/railCards.js", "src/ui/railCardBlocks.js", "src/ui/slider.js", - "src/ui/chipGroup.js", - "src/ui/rowList.js", + "src/ui/layerRowText.js", + "src/ui/layerPanel/LayerRows.vue", + "src/ui/layerPanel/RowText.vue", "src/ui/railTimeline.js", "src/ui/clearLayersControl.js", "src/loadingFeedback.js", @@ -852,8 +853,9 @@ "src/ui/weatherPanel.js", "src/ui/railCards.js", "src/ui/railCardBlocks.js", - "src/ui/chipGroup.js", - "src/ui/rowList.js", + "src/ui/layerRowText.js", + "src/ui/layerPanel/LayerRows.vue", + "src/ui/layerPanel/RowText.vue", "src/ui/railTimeline.js", "src/ui/layers.js", "src/ui/leftPanelRail.js", @@ -2547,8 +2549,9 @@ "src/ui/weatherPanel.js", "src/ui/railCards.js", "src/ui/railCardBlocks.js", - "src/ui/chipGroup.js", - "src/ui/rowList.js", + "src/ui/layerRowText.js", + "src/ui/layerPanel/LayerRows.vue", + "src/ui/layerPanel/RowText.vue", "src/ui/railTimeline.js", "src/ui/layers.js", "src/ui/leftPanelRail.js", diff --git a/src/data/manager.js b/src/data/manager.js index 79bb7d9ae..cf7b31fae 100644 --- a/src/data/manager.js +++ b/src/data/manager.js @@ -28,9 +28,6 @@ export class DataLayerManager extends LayerLifecycle { _buildMetaText(layer) { return this._presentation.panel._buildMetaText(layer); } - _syncToggleButton(button, layer) { - return this._presentation.panel._syncToggleButton(button, layer); - } get _layerPanel() { return this._presentation._panel; } diff --git a/src/data/manager.test.mjs b/src/data/manager.test.mjs index b1384a2de..70f705ec6 100644 --- a/src/data/manager.test.mjs +++ b/src/data/manager.test.mjs @@ -12,6 +12,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { DataLayerManager, layerFeedState } from './manager.js'; +import { presentLayerToggle } from '../ui/layerPanel.js'; import { contextSnapshotLayerIds, shouldCaptureContextSession, @@ -90,68 +91,6 @@ test('adopts settled visibility without re-running lifecycle work', async () => 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 () => { const mgr = new DataLayerManager({}); 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', () => { 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 = { name: 'Radio', source: 'Radio Browser', @@ -1881,14 +1809,14 @@ test('uncertain lifecycle state overrides ordinary feed status without disabling stats: { count: 750, lastUpdate: Date.now() }, }; - mgr._syncToggleButton(button, layer); + const toggle = presentLayerToggle(layer); - assert.equal(button.textContent, 'UNCERTAIN'); - assert.equal(button.dataset.feedState, 'uncertain'); - assert.equal(button.disabled, false, 'the lifecycle toggle remains available to reconcile authority'); - assert.equal(attributes.get('aria-label'), 'Radio: UNCERTAIN'); - assert.equal(classes.get('lifecycle-uncertain'), true); - assert.equal(classes.get('feed-nominal'), false); + assert.equal(toggle.text, 'UNCERTAIN'); + assert.equal(toggle.feedState, 'uncertain'); + assert.equal(toggle.ariaDisabled, false, 'the lifecycle toggle remains available to reconcile authority'); + assert.equal(toggle.ariaLabel, 'Radio: UNCERTAIN'); + assert.equal(toggle.classes['lifecycle-uncertain'], true); + assert.equal(toggle.classes['feed-nominal'], false); assert.equal( mgr._buildMetaText(layer), '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); }); -// ── 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', () => { const mgr = new DataLayerManager({}); 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/); }); - -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 = ''; - 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 () => { const { LayerLifecycle } = await import('./lifecycle.js'); const lifecycle = new LayerLifecycle({}); diff --git a/src/data/managerPanel.test.mjs b/src/data/managerPanel.test.mjs new file mode 100644 index 000000000..51cfd3d23 --- /dev/null +++ b/src/data/managerPanel.test.mjs @@ -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 = ''; + 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(); + } +}); diff --git a/src/ui/chipGroup.js b/src/ui/chipGroup.js deleted file mode 100644 index 24eb44f3b..000000000 --- a/src/ui/chipGroup.js +++ /dev/null @@ -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); - }); -} diff --git a/src/ui/chipGroup.test.mjs b/src/ui/chipGroup.test.mjs deleted file mode 100644 index 784029a5c..000000000 --- a/src/ui/chipGroup.test.mjs +++ /dev/null @@ -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); -}); diff --git a/src/ui/layerPanel.js b/src/ui/layerPanel.js index 37fc09161..af90b07db 100644 --- a/src/ui/layerPanel.js +++ b/src/ui/layerPanel.js @@ -1,5 +1,5 @@ -import { syncChipGroup } from './chipGroup.js'; -import { syncRowList } from './rowList.js'; +import { h, render as renderVue } from 'vue'; +import LayerRows from './layerPanel/LayerRows.vue'; import { layerFeedState } from '../data/feedState.js'; export { layerFeedState } from '../data/feedState.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 -// 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; - -/** 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; +/** Qualify a loaded count when it does not mean items currently on screen. */ +function layerCountText(stats) { + if (typeof stats.countLabel === 'string' && stats.countLabel.trim()) + return stats.countLabel; + return stats.count ? formatCount(stats.count) : '—'; } /** - * Set 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 "·". The element's textContent is still the - * plain string; text with neither feature is set directly. - * - * @param {HTMLElement} element - * @param {string} text + * A row's count. Outside English a count phrase ("24 nearby · 2 on screen") + * gets its own line (`countLine`), or it squeezes a longer translated name to + * a word per line. "—" is marked `empty` so a wide status chip can replace it + * (radio.css). + * @param {object} stats Layer stats. + * @returns {{text: string, empty: boolean, countLine: boolean}} */ -export function setRowText(element, text) { - const value = String(text ?? ''); - // Refreshes run every second; leave unchanged text (and its spans) alone. - if (element.textContent === value) return; - const codes = [...value.matchAll(HYPHENATED_CODE)].filter( - (match) => match[0].length <= HYPHENATED_CODE_MAX, - ); - if (!codes.length && !value.includes(ROW_TEXT_SEPARATOR)) { - element.textContent = value; - 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)); - }); +export function presentLayerCount(stats = {}) { + const text = layerCountText(stats); + return { + text, + empty: text === '—', + countLine: + getLocale() !== 'en' && + typeof stats.countLabel === 'string' && + /\d/.test(stats.countLabel), + }; +} + +/** + * 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, }; - 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); } /** Layer row presentation over supplied state and actions; no layer imports. */ @@ -223,10 +266,17 @@ export class LayerPanel { this._cancelRowControlsRefresh = null; this._recentImageryFactory = null; this._recentImageryPanel = null; + this._rows = []; + this._rowViews = new Map(); + this._pending = new Set(); } mount(container) { if (this._destroyed) return; 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._weatherPanel?.destroy(); this._weatherPanel = createWeatherPanel({ @@ -258,10 +308,6 @@ export class LayerPanel { if (container && this._recentImageryFactory) this._recentImageryPanel = this._recentImageryFactory(container) || null; } - _bind(element, type, listener) { - element.addEventListener(type, listener); - this._removers.push(() => element.removeEventListener(type, listener)); - } _releaseBindings() { this._generation++; this._cancelRowControlsRefresh?.(); @@ -277,14 +323,15 @@ export class LayerPanel { this._recentImageryPanel?.destroy(); this._recentImageryPanel = null; this._recentImageryFactory = null; + if (this._toggleContainer) renderVue(null, this._toggleContainer); this._toggleContainer = null; } _renderToggles() { if (this._destroyed || !this._toggleContainer) return; this._releaseBindings(); - this._toggleContainer.innerHTML = ''; - - const generation = this._generation; + // 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 + // its toggle and metadata. const layers = this.getAll() .slice() .sort( @@ -292,93 +339,20 @@ export class LayerPanel { (PANEL_POSITIONS.get(a.id) ?? PANEL_ORDER.length) - (PANEL_POSITIONS.get(b.id) ?? PANEL_ORDER.length), ); + this._rows = []; + this._rowViews = new Map(); let previousGroup = ''; for (const layer of layers) { if (!layer.showInTogglePanel) continue; const group = PANEL_ORDER[PANEL_POSITIONS.get(layer.id)]?.label ?? msg('Other layers'); - if (group && group !== previousGroup) { - const heading = document.createElement('h3'); - heading.className = 'data-layer-group-heading'; - heading.textContent = t(group); - this._toggleContainer.appendChild(heading); - } + if (group && group !== previousGroup) + this._rows.push({ key: `group:${group}`, heading: 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); - if (!readout) right.appendChild(count); - right.appendChild(toggle); - 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)) { + const controls = this.hasRowControls(layer.id); + if (controls) { // A layer whose controls settle asynchronously (a chunked catalog load // that can also fail) pushes a re-render through this; nothing else // would repaint the row before its next scheduled refresh. @@ -386,48 +360,150 @@ export class LayerPanel { this._scheduleRowControlsRefresh(), ); if (unsubscribe) this._removers.push(unsubscribe); - if (!readout) { - const controls = document.createElement('div'); - controls.className = 'data-toggle-controls'; - this._bind(controls, 'click', (event) => { - const button = event.target?.closest?.('.data-toggle-chip'); - if (!button || button.disabled) return; - // Re-read the live descriptor rather than trusting the rendered - // 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 - // sequence (turn-by-turn directions). Its own delegated listener, its - // own container — the chip row stays a chip row. - const list = document.createElement('ol'); - list.className = 'data-row-list'; - list.hidden = true; - this._bind(list, 'click', (event) => { - const button = event.target?.closest?.('.data-row-list-item'); - if (!button || button.disabled) return; - const item = this._rowControlsFor(layer.id)?.list?.items?.find( - (entry) => entry.id === button.dataset.listItemId, - ); - if (item?.params) - this.setLayerParams(layer.id, item.params, { origin: 'user' }); - }); - row.appendChild(list); - this._syncRowControls(controls, layer, list); - } } - - this._toggleContainer.appendChild(row); + this._rows.push({ + key: layer.id, + id: layer.id, + count: !readout, + controls: controls && !readout, + info: false, + }); } + this._paint(); this._refreshWeatherPanel(); } + /** Present every row from the layers' current state and render them. */ + _paint() { + if (this._destroyed || !this._toggleContainer) return; + const layers = new Map(this.getAll().map((layer) => [layer.id, layer])); + const items = this._rows.map((row) => { + if (row.heading) return { key: row.key, heading: t(row.heading) }; + const layer = layers.get(row.id); + if (layer) this._rowViews.set(row.id, this._presentRow(row, layer)); + return this._rowViews.get(row.id); + }); + const generation = this._generation; + 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(); + } + } + + // 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' }); + } + + _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() { if (this._destroyed || this._cancelRowControlsRefresh) return; const refresh = () => { @@ -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 `` inside a real ``, so Tab reaches it and - * Enter activates it with no key handling of our own, and the `` 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}|null} list Descriptor. - */ - _syncRowList(container, list) { - syncRowList(container, list); - } - _refreshTogglePanel() { if (this._destroyed || !this._toggleContainer) return; // Skip DOM churn while hidden; visibilitychange (main.js) triggers one @@ -601,33 +558,7 @@ export class LayerPanel { this.onHiddenRefresh(); return; } - for (const layer of this.getAll()) { - 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._paint(); this._refreshWeatherPanel(); } @@ -745,86 +676,6 @@ export class LayerPanel { 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) { const diff = Math.floor((Date.now() - timestamp) / 1000); if (diff < 5) return t('just now'); diff --git a/src/ui/layerPanel.test.mjs b/src/ui/layerPanel.test.mjs index bb1ea7a5d..b666eeb3a 100644 --- a/src/ui/layerPanel.test.mjs +++ b/src/ui/layerPanel.test.mjs @@ -1,3 +1,4 @@ +import '../testSupport/registerDom.mjs'; import test from 'node:test'; import assert from 'node:assert/strict'; 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 () => { - const { LayerPanel, layerFeedState } = await import('./layerPanel.js'); - const classes = new Map(); - const attrs = new Map(); - const button = { - classList: { toggle: (key, value) => classes.set(key, value) }, - dataset: {}, - setAttribute: (key, value) => attrs.set(key, value), - }; + const { LayerPanel, layerFeedState, presentLayerToggle } = + await import('./layerPanel.js'); const layer = { id: 'ais-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; - panel._syncToggleButton(button, layer); - assert.equal(button.textContent, 'PARTIAL'); - assert.equal(button.dataset.feedState, 'partial'); - assert.equal(classes.get('feed-partial'), true); - assert.equal(classes.get('feed-stale'), false); - assert.match(attrs.get('aria-label'), /PARTIAL/); + let toggle = presentLayerToggle(layer); + assert.equal(toggle.text, 'PARTIAL'); + assert.equal(toggle.feedState, 'partial'); + assert.equal(toggle.classes['feed-partial'], true); + assert.equal(toggle.classes['feed-stale'], false); + assert.match(toggle.ariaLabel, /PARTIAL/); assert.match( panel._buildMetaText(layer), /^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'); layer.stats = { ...layer.stats, partial: false }; - panel._syncToggleButton(button, layer); - assert.equal(button.textContent, 'ON'); - assert.equal(classes.get('feed-partial'), false); + toggle = presentLayerToggle(layer); + assert.equal(toggle.text, 'ON'); + assert.equal(toggle.classes['feed-partial'], false); layer.enabled = false; - panel._syncToggleButton(button, layer); - assert.equal(button.textContent, 'OFF'); + assert.equal(presentLayerToggle(layer).text, 'OFF'); }); 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 () => { const { LayerPanel } = await import('./layerPanel.js'); - const { railFixture } = await import('./railTestFixture.mjs'); - const f = railFixture(); - const previousDocument = globalThis.document; - globalThis.document = f.document; + document.body.replaceChildren(); + const container = document.body.appendChild(document.createElement('div')); + const find = (id) => container.querySelector(`[data-layer-id="${id}"]`); const ids = [ 'wind', 'weather-radar', @@ -160,9 +153,9 @@ test('readout rows contain only toggles and metadata; ordinary rows retain contr }), }); try { - panel.mount(f.container); + panel.mount(container); 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.ok(row.querySelector('.data-name')); 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-row-list', ]) - assert.equal(row.querySelector(`.${cls}`), null); + assert.ok(!row.querySelector(`.${cls}`), cls); row.querySelector('.data-toggle-btn').click(); } assert.deepEqual( calls.map(([id]) => id), ids.slice(0, -1), ); - const ordinary = f.find((n) => n.dataset.layerId === 'other'); + const ordinary = find('other'); assert.equal( ordinary.querySelector('.data-toggle-controls-info').textContent, 'Detailed time', @@ -195,19 +188,16 @@ test('readout rows contain only toggles and metadata; ordinary rows retain contr assert.equal(typeof refresh, 'function'); } finally { 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 () => { const { LayerPanel } = await import('./layerPanel.js'); - const { railFixture } = await import('./railTestFixture.mjs'); - const f = railFixture(); - const body = f.document.createElement('div'); - f.document.getElementById = (id) => - id === 'recent-imagery-panel-body' ? body : null; - const previousDocument = globalThis.document; - globalThis.document = f.document; + document.body.replaceChildren(); + const container = document.body.appendChild(document.createElement('div')); + const body = document.body.appendChild(document.createElement('div')); + body.id = 'recent-imagery-panel-body'; const mounted = []; const factory = (container) => { const readout = { @@ -232,10 +222,10 @@ test('the Recent Imagery readout mounts in its rail body like the weather readou try { panel.attachRecentImagery(factory); 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[0].container, body); - panel.mount(f.container); + assert.ok(mounted[0].container === body); + panel.mount(container); assert.equal(mounted[0].destroyed, true, 'a remount rebuilds it'); assert.equal(mounted.length, 2); 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'); } finally { panel.destroy(); - globalThis.document = previousDocument; + document.body.replaceChildren(); } }); test('an omitted source is visibly unavailable and cannot be toggled on', async () => { - const { LayerPanel } = await import('./layerPanel.js'); - const attrs = new Map(); - const button = { - classList: { toggle() {} }, - dataset: {}, - setAttribute: (key, value) => attrs.set(key, value), - }; + const { presentLayerToggle } = await import('./layerPanel.js'); const layer = { id: 'flights', name: 'Flights', @@ -270,94 +254,63 @@ test('an omitted source is visibly unavailable and cannot be toggled on', async error: 'Data source not configured: flights', }, }; - LayerPanel.prototype._syncToggleButton(button, layer); - assert.equal(button.textContent, 'UNAVAILABLE'); - assert.equal(attrs.get('aria-disabled'), 'true'); - assert.match(button.title, /not configured/); - LayerPanel.prototype._syncToggleButton(button, { ...layer, enabled: true }); + const toggle = presentLayerToggle(layer); + assert.equal(toggle.text, 'UNAVAILABLE'); + assert.equal(toggle.ariaDisabled, true); + assert.match(toggle.title, /not configured/); assert.equal( - attrs.get('aria-disabled'), - 'false', + presentLayerToggle({ ...layer, enabled: true }).ariaDisabled, + false, '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 () => { - const { setRowText } = await import('./layerPanel.js'); - const previousDocument = globalThis.document; - const element = () => ({ - 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 { rowTextRuns } = await import('./layerRowText.js'); + const text = (runs) => runs.map((run) => run.text).join(''); + const kept = (runs) => runs.filter((run) => run.keep).map((run) => run.text); - const name = element(); - setRowText(name, 'Локальный ADS-B'); - assert.equal(text(name), 'Локальный ADS-B'); - assert.deepEqual(kept(name), ['ADS-B']); + const name = rowTextRuns('Локальный ADS-B'); + assert.equal(text(name), 'Локальный ADS-B'); + assert.deepEqual(kept(name), ['ADS-B']); - const meta = element(); - setRowText(meta, 'UNAVAILABLE · NASA FIRMS · LIVE · KEY REQUIRED'); - assert.equal(text(meta), 'UNAVAILABLE · NASA FIRMS · LIVE · KEY REQUIRED'); - assert.deepEqual(kept(meta), [' ·', ' ·', ' ·']); - assert.ok( - meta.children.every((child) => !child.textContent.startsWith('·')), - 'the space before each dot is held to the piece it closes', - ); + const meta = rowTextRuns('UNAVAILABLE · NASA FIRMS · LIVE · KEY REQUIRED'); + assert.equal(text(meta), 'UNAVAILABLE · NASA FIRMS · LIVE · KEY REQUIRED'); + assert.deepEqual(kept(meta), [' ·', ' ·', ' ·']); + assert.ok( + meta.every((run) => !run.text.startsWith('·')), + 'the space before each dot is held to the piece it closes', + ); - const plain = element(); - setRowText(plain, 'Satellites'); - assert.equal(plain.textContent, 'Satellites'); - assert.equal(plain.children.length, 0); + assert.equal(rowTextRuns('Satellites'), null, 'plain text needs no runs'); - const compound = element(); - setRowText(compound, 'Kartierte ALPR-Kameras · ADS-B-Flüge'); - assert.equal(text(compound), 'Kartierte ALPR-Kameras · ADS-B-Flüge'); - assert.deepEqual( - kept(compound), - [' ·', 'ADS-B'], - 'a compound may break at its hyphen; the code inside it stays whole', - ); + const compound = rowTextRuns('Kartierte ALPR-Kameras · ADS-B-Flüge'); + assert.equal(text(compound), 'Kartierte ALPR-Kameras · ADS-B-Flüge'); + assert.deepEqual( + kept(compound), + [' ·', 'ADS-B'], + 'a compound may break at its hyphen; the code inside it stays whole', + ); - const long = element(); - setRowText(long, 'see https-proxy-endpoint-config-x'); - assert.deepEqual(kept(long), [], 'a long hyphenated run may still wrap'); - } finally { - globalThis.document = previousDocument; - } + const long = rowTextRuns('see https-proxy-endpoint-config-x'); + assert.deepEqual( + kept(long || []), + [], + 'a long hyphenated run may still wrap', + ); }); test('the unavailable chip may abbreviate, but its accessible name keeps the full state', async () => { - const { LayerPanel } = await import('./layerPanel.js'); - const attrs = new Map(); - const button = { - classList: { toggle() {} }, - dataset: {}, - setAttribute: (key, value) => attrs.set(key, value), - }; - LayerPanel.prototype._syncToggleButton(button, { + const { presentLayerToggle } = await import('./layerPanel.js'); + const toggle = presentLayerToggle({ id: 'local-firms', name: 'FIRMS Active Fires', enabled: false, stats: { sourceUnavailable: true, error: 'KEY REQUIRED' }, }); - assert.equal(button.textContent, 'UNAVAILABLE'); - assert.equal(button.dataset.feedState, 'unavailable'); - assert.match(attrs.get('aria-label'), /^Active Fires: UNAVAILABLE\./); + assert.equal(toggle.text, 'UNAVAILABLE'); + assert.equal(toggle.feedState, 'unavailable'); + assert.match(toggle.ariaLabel, /^Active Fires: UNAVAILABLE\./); }); test('the Weather readout receives enabled air-quality alongside weather layers', async () => { diff --git a/src/ui/layerPanel/LayerRows.vue b/src/ui/layerPanel/LayerRows.vue new file mode 100644 index 000000000..e3897ce30 --- /dev/null +++ b/src/ui/layerPanel/LayerRows.vue @@ -0,0 +1,80 @@ + + + + {{ + item.heading + }}{{ item.icon }}{{ item.toggle.text }}{{ entry.text }}{{ item.controls.info.text }} + diff --git a/src/ui/layerPanel/RowText.vue b/src/ui/layerPanel/RowText.vue new file mode 100644 index 000000000..2ea959df8 --- /dev/null +++ b/src/ui/layerPanel/RowText.vue @@ -0,0 +1,25 @@ + + + + {{ run.text }}{{ text }} + diff --git a/src/ui/layerRowText.js b/src/ui/layerRowText.js new file mode 100644 index 000000000..aa3d7087f --- /dev/null +++ b/src/ui/layerRowText.js @@ -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; +} diff --git a/src/ui/rowList.js b/src/ui/rowList.js deleted file mode 100644 index 1b329e9cc..000000000 --- a/src/ui/rowList.js +++ /dev/null @@ -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; - } -} diff --git a/src/ui/styles/english-css-parity.test.mjs b/src/ui/styles/english-css-parity.test.mjs index 7d9a1ae4c..a597e86b6 100644 --- a/src/ui/styles/english-css-parity.test.mjs +++ b/src/ui/styles/english-css-parity.test.mjs @@ -128,20 +128,9 @@ test('cockpit brief controls keep their English sizes', () => { test('the count-line class is set only outside English', async () => { const { setLocale } = await import('../../i18n/index.js'); - const { LayerPanel } = await import('../layerPanel.js'); - const classes = () => { - const toggled = new Map(); - 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: () => ({}) }; + const { presentLayerCount } = await import('../layerPanel.js'); + const classes = () => + presentLayerCount({ countLabel: '693 nearby · 93 on screen' }).countLine; try { setLocale('en'); assert.equal(classes(), false); diff --git a/src/ui/styles/radio.css b/src/ui/styles/radio.css index 92350c3d0..31d29573f 100644 --- a/src/ui/styles/radio.css +++ b/src/ui/styles/radio.css @@ -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 - 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 { white-space: nowrap; }