mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 02:44:02 +00:00
feat(ui): render the data layer rows through Vue from presented row state
This commit is contained in:
1 parent
ce343c1c6d
commit
d63f4d697b
16 files changed
+1434
-1552
No files matched your search
@@ -1,53 +0,0 @@
|
||||
const set = (node, key, value) => {
|
||||
if (node[key] !== value) node[key] = value;
|
||||
};
|
||||
const attribute = (node, key, value) => {
|
||||
if (node.getAttribute(key) !== value) node.setAttribute(key, value);
|
||||
};
|
||||
|
||||
/** Keyed chips shared by layer rows and rail cards; the caller owns dispatch. */
|
||||
export function syncChipGroup(container, chips = [], { before = null } = {}) {
|
||||
if (!container) return;
|
||||
const document = container.ownerDocument ?? globalThis.document;
|
||||
const stale = new Map();
|
||||
for (const node of [...container.children])
|
||||
if (node.dataset?.chipId) stale.set(node.dataset.chipId, node);
|
||||
const ordered = [];
|
||||
for (const chip of chips) {
|
||||
let button = stale.get(chip.id);
|
||||
stale.delete(chip.id);
|
||||
if (!button) {
|
||||
button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.dataset.chipId = chip.id;
|
||||
}
|
||||
const state = chip.state || (chip.active ? 'active' : 'idle');
|
||||
set(
|
||||
button,
|
||||
'className',
|
||||
`data-toggle-chip chip-${state}${chip.active ? ' active' : ''}`,
|
||||
);
|
||||
set(button, 'textContent', chip.label);
|
||||
// Optional per-chip colour; CSS decides how it is used.
|
||||
const color = chip.color || '';
|
||||
if ((button.dataset.chipColor || '') !== color) {
|
||||
if (color) {
|
||||
button.style.setProperty('--chip-color', color);
|
||||
button.dataset.chipColor = color;
|
||||
} else {
|
||||
button.style.removeProperty('--chip-color');
|
||||
delete button.dataset.chipColor;
|
||||
}
|
||||
}
|
||||
set(button, 'title', chip.title || '');
|
||||
set(button, 'disabled', Boolean(chip.disabled));
|
||||
attribute(button, 'aria-pressed', String(Boolean(chip.active)));
|
||||
attribute(button, 'aria-busy', String(Boolean(chip.busy)));
|
||||
ordered.push(button);
|
||||
}
|
||||
for (const node of stale.values()) node.remove();
|
||||
ordered.forEach((node, index) => {
|
||||
if (container.children[index] !== node)
|
||||
container.insertBefore(node, container.children[index] || before);
|
||||
});
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { syncChipGroup } from './chipGroup.js';
|
||||
import { railFixture } from './railTestFixture.mjs';
|
||||
|
||||
test('keyed chips retain focus, reflect state, reorder and remove without redundant writes', () => {
|
||||
let writes = 0;
|
||||
const f = railFixture(() => writes++);
|
||||
const a = { id: 'a', label: 'GFS', active: true };
|
||||
const b = { id: 'b', label: 'ECMWF' };
|
||||
const legend = f.document.createElement('span');
|
||||
f.container.appendChild(legend);
|
||||
syncChipGroup(f.container, [a, b], { before: legend });
|
||||
const first = f.container.children[0];
|
||||
first.focus();
|
||||
const next = [{ ...a, label: 'Changed', busy: true, disabled: true }, b];
|
||||
syncChipGroup(f.container, next);
|
||||
assert.equal(f.container.children[0], first);
|
||||
assert.equal(f.document.activeElement, first);
|
||||
assert.equal(first.disabled, true);
|
||||
assert.equal(first.getAttribute('aria-busy'), 'true');
|
||||
assert.equal(first.getAttribute('aria-pressed'), 'true');
|
||||
writes = 0;
|
||||
syncChipGroup(f.container, next);
|
||||
assert.equal(writes, 0);
|
||||
syncChipGroup(f.container, [b, a]);
|
||||
assert.equal(f.container.children[1], first);
|
||||
assert.equal(f.container.children[2], legend);
|
||||
syncChipGroup(f.container, [a]);
|
||||
assert.deepEqual(f.container.children, [first, legend]);
|
||||
syncChipGroup(f.container, []);
|
||||
assert.deepEqual(f.container.children, [legend]);
|
||||
});
|
||||
|
||||
test('a chip colour is set once, changed, and cleared through a CSS variable', () => {
|
||||
const f = railFixture(() => {});
|
||||
const calls = [];
|
||||
syncChipGroup(f.container, [{ id: 'm', label: 'M' }]);
|
||||
const chip = f.container.children[0];
|
||||
chip.style.setProperty = (name, value) => calls.push(['set', name, value]);
|
||||
chip.style.removeProperty = (name) => calls.push(['remove', name]);
|
||||
syncChipGroup(f.container, [{ id: 'm', label: 'M' }]);
|
||||
assert.deepEqual(calls, [], 'a chip without a colour never touches style');
|
||||
syncChipGroup(f.container, [{ id: 'm', label: 'M', color: '#05cb63' }]);
|
||||
syncChipGroup(f.container, [{ id: 'm', label: 'M', color: '#05cb63' }]);
|
||||
syncChipGroup(f.container, [{ id: 'm', label: 'M', color: '#a66bff' }]);
|
||||
syncChipGroup(f.container, [{ id: 'm', label: 'M' }]);
|
||||
assert.deepEqual(calls, [
|
||||
['set', '--chip-color', '#05cb63'],
|
||||
['set', '--chip-color', '#a66bff'],
|
||||
['remove', '--chip-color'],
|
||||
]);
|
||||
assert.equal(chip.dataset.chipColor, undefined);
|
||||
});
|
||||
+255
-404
@@ -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 `<button>` inside a real `<li>`, so Tab reaches it and
|
||||
* Enter activates it with no key handling of our own, and the `<ol>` carries
|
||||
* the ordering a screen reader announces. Items are reconciled in place,
|
||||
* keyed by id, for the same reason chips are: this runs on every refresh —
|
||||
* including the one a click on the list triggers — and replacing the node
|
||||
* would drop keyboard focus mid-interaction.
|
||||
* @param {HTMLElement|null} container The row's `.data-row-list` node.
|
||||
* @param {{ariaLabel?: string, items?: Array<object>}|null} list Descriptor.
|
||||
*/
|
||||
_syncRowList(container, list) {
|
||||
syncRowList(container, list);
|
||||
}
|
||||
|
||||
_refreshTogglePanel() {
|
||||
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');
|
||||
|
||||
+68
-115
@@ -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 () => {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
<script setup>
|
||||
import RailChips from '../rail/RailChips.vue';
|
||||
import RailRowList from '../rail/RailRowList.vue';
|
||||
import RowText from './RowText.vue';
|
||||
|
||||
// The data layer rows under their group headings. LayerPanel presents every
|
||||
// value; clicks reach it with the layer id, and it re-reads the live
|
||||
// descriptor before acting.
|
||||
defineProps({
|
||||
items: { type: Array, required: true },
|
||||
onToggle: { type: Function, default: () => {} },
|
||||
onChip: { type: Function, default: () => {} },
|
||||
onListItem: { type: Function, default: () => {} },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-for="item in items" :key="item.key"
|
||||
><h3 v-if="item.heading" class="data-layer-group-heading">{{
|
||||
item.heading
|
||||
}}</h3
|
||||
><div
|
||||
v-else
|
||||
:class="[
|
||||
'data-toggle-row',
|
||||
{ 'data-toggle-row--count-line': item.count?.countLine },
|
||||
]"
|
||||
:data-layer-id="item.id"
|
||||
:data-layer-name="item.englishName"
|
||||
><div class="data-toggle-top"
|
||||
><div class="data-toggle-left"
|
||||
><span class="data-icon" aria-hidden="true">{{ item.icon }}</span
|
||||
><RowText class="data-name" :text="item.name" /></div
|
||||
><div class="data-toggle-right"
|
||||
><RowText
|
||||
v-if="item.count"
|
||||
:class="['data-count', { 'data-count-empty': item.count.empty }]"
|
||||
:text="item.count.text"
|
||||
/><button
|
||||
type="button"
|
||||
:class="['data-toggle-btn', item.toggle.classes]"
|
||||
:data-feed-state="item.toggle.feedState"
|
||||
:aria-disabled="String(item.toggle.ariaDisabled)"
|
||||
:aria-busy="String(item.toggle.ariaBusy)"
|
||||
:title="item.toggle.title"
|
||||
:aria-label="item.toggle.ariaLabel"
|
||||
@click="onToggle(item.id)"
|
||||
>{{ item.toggle.text }}</button
|
||||
></div
|
||||
></div
|
||||
><RowText tag="div" class="data-toggle-meta" :text="item.meta" /><template
|
||||
v-if="item.controls"
|
||||
><div class="data-toggle-controls" :hidden="item.controls.hidden"
|
||||
><RailChips
|
||||
:chips="item.controls.chips"
|
||||
:on-select="(chip) => onChip(item.id, chip.id)"
|
||||
/><span
|
||||
v-for="(entry, index) in item.controls.legend"
|
||||
:key="index"
|
||||
class="data-toggle-legend-item"
|
||||
:title="entry.blurb || undefined"
|
||||
><span
|
||||
class="data-toggle-legend-swatch"
|
||||
:style="{ background: entry.color }"
|
||||
></span
|
||||
><span>{{ entry.text }}</span></span
|
||||
><div
|
||||
v-if="item.controls.info"
|
||||
class="data-toggle-controls-info"
|
||||
:title="item.controls.info.title"
|
||||
:hidden="!item.controls.info.text"
|
||||
>{{ item.controls.info.text }}</div
|
||||
></div
|
||||
><RailRowList
|
||||
:list="item.controls.list"
|
||||
:on-select="
|
||||
(entry) => onListItem(item.id, entry.id)
|
||||
" /></template></div
|
||||
></template>
|
||||
</template>
|
||||
@@ -0,0 +1,25 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { rowTextRuns } from '../layerRowText.js';
|
||||
|
||||
// Row text that wraps well in every language: hyphenated codes and the
|
||||
// space before a " · " separator never break (see rowTextRuns).
|
||||
const props = defineProps({
|
||||
tag: { type: String, default: 'span' },
|
||||
text: { type: String, default: '' },
|
||||
});
|
||||
const runs = computed(() => rowTextRuns(props.text));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component :is="tag"
|
||||
><template v-if="runs"
|
||||
><span
|
||||
v-for="(run, index) in runs"
|
||||
:key="index"
|
||||
v-bind="run.keep ? { class: 'data-keep' } : {}"
|
||||
>{{ run.text }}</span
|
||||
></template
|
||||
><template v-else>{{ text }}</template></component
|
||||
>
|
||||
</template>
|
||||
@@ -0,0 +1,47 @@
|
||||
const ROW_TEXT_SEPARATOR = ' · ';
|
||||
|
||||
// A short Latin code joined by hyphens: every part after a hyphen is capitals
|
||||
// or digits (ADS-B, RTL-SDR, GTFS-RT). A compound such as "ALPR-Kameras" is a
|
||||
// word, not a code, and may break at its hyphen. Longer runs (a URL, a token
|
||||
// in an error) keep their break opportunities so they cannot overflow the rail.
|
||||
const HYPHENATED_CODE = /\b[A-Za-z0-9]+(?:-[A-Z0-9]+)+(?![A-Za-z0-9])/g;
|
||||
const HYPHENATED_CODE_MAX = 16;
|
||||
|
||||
/**
|
||||
* Split a row's name, count or meta line so that a narrow column wraps it well
|
||||
* in every language: a Latin code with hyphens (ADS-B, RTL-SDR, GTFS-RT) never
|
||||
* splits at its hyphen, and a " · " separator stays at the end of the line it
|
||||
* closes, so no line starts with "·". Each run is `{text, keep}`, where `keep`
|
||||
* stops the browser breaking inside it; the runs join back to the text. Text
|
||||
* with neither feature returns null and is shown as it is.
|
||||
*
|
||||
* @param {string} text
|
||||
* @returns {Array<{text: string, keep: boolean}>|null}
|
||||
*/
|
||||
export function rowTextRuns(text) {
|
||||
const value = String(text ?? '');
|
||||
const codes = [...value.matchAll(HYPHENATED_CODE)].filter(
|
||||
(match) => match[0].length <= HYPHENATED_CODE_MAX,
|
||||
);
|
||||
if (!codes.length && !value.includes(ROW_TEXT_SEPARATOR)) return null;
|
||||
const runs = [];
|
||||
const pushPlain = (from, to) => {
|
||||
if (to <= from) return;
|
||||
const pieces = value.slice(from, to).split(ROW_TEXT_SEPARATOR);
|
||||
pieces.forEach((piece, index) => {
|
||||
// The space before the dot belongs to a no-wrap run, so the only break
|
||||
// opportunity around a separator is the space after it.
|
||||
if (index > 0) runs.push({ text: ' ·', keep: true });
|
||||
const textPiece = index > 0 ? ` ${piece}` : piece;
|
||||
if (textPiece) runs.push({ text: textPiece, keep: false });
|
||||
});
|
||||
};
|
||||
let cursor = 0;
|
||||
for (const match of codes) {
|
||||
pushPlain(cursor, match.index);
|
||||
runs.push({ text: match[0], keep: true });
|
||||
cursor = match.index + match[0].length;
|
||||
}
|
||||
pushPlain(cursor, value.length);
|
||||
return runs;
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
const set = (node, key, value) => {
|
||||
if (node[key] !== value) node[key] = value;
|
||||
};
|
||||
const attribute = (node, key, value) => {
|
||||
if (node.getAttribute(key) !== value) node.setAttribute(key, value);
|
||||
};
|
||||
/** Reconcile ordered layer/card lists without replacing focused items. */
|
||||
export function syncRowList(container, list) {
|
||||
const document = container?.ownerDocument ?? globalThis.document;
|
||||
if (!container) return;
|
||||
const items = list?.items || [];
|
||||
set(container, 'hidden', items.length === 0);
|
||||
attribute(container, 'aria-label', list?.ariaLabel || '');
|
||||
|
||||
const stale = new Map();
|
||||
for (const node of [...container.children]) {
|
||||
if (node.dataset?.listItemId) stale.set(node.dataset.listItemId, node);
|
||||
}
|
||||
let index = 0;
|
||||
let activeButton = null;
|
||||
for (const item of items) {
|
||||
let entry = stale.get(item.id);
|
||||
stale.delete(item.id);
|
||||
let button;
|
||||
if (!entry) {
|
||||
entry = document.createElement('li');
|
||||
entry.dataset.listItemId = item.id;
|
||||
button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'data-row-list-item';
|
||||
button.dataset.listItemId = item.id;
|
||||
const lead = document.createElement('span');
|
||||
lead.className = 'data-row-list-lead';
|
||||
const text = document.createElement('span');
|
||||
text.className = 'data-row-list-text';
|
||||
button.append(lead, text);
|
||||
entry.appendChild(button);
|
||||
} else {
|
||||
button = entry.querySelector('.data-row-list-item');
|
||||
}
|
||||
// Keep DOM order in step with descriptor order without rebuilding.
|
||||
const anchor = container.children[index++] || null;
|
||||
if (entry !== anchor) container.insertBefore(entry, anchor);
|
||||
if (!button) continue;
|
||||
const lead = button.querySelector('.data-row-list-lead');
|
||||
const text = button.querySelector('.data-row-list-text');
|
||||
const leadText = String(item.lead ?? '');
|
||||
const bodyText = String(item.text ?? '');
|
||||
if (lead && lead.textContent !== leadText) lead.textContent = leadText;
|
||||
if (text && text.textContent !== bodyText) text.textContent = bodyText;
|
||||
set(button, 'disabled', Boolean(item.disabled));
|
||||
set(
|
||||
button,
|
||||
'className',
|
||||
`data-row-list-item${item.disabled ? ' note' : ''}${item.active ? ' active' : ''}${item.current ? ' current' : ''}`,
|
||||
);
|
||||
attribute(button, 'aria-current', item.current ? 'step' : 'false');
|
||||
attribute(button, 'aria-pressed', item.active ? 'true' : 'false');
|
||||
set(button, 'title', bodyText);
|
||||
if (item.current) activeButton = button;
|
||||
}
|
||||
for (const node of stale.values()) node.remove();
|
||||
// Follow the flight, but never steal a scroll the reader is making
|
||||
// themselves: only when the step actually changed.
|
||||
if (
|
||||
activeButton &&
|
||||
container.dataset.currentId !== activeButton.dataset.listItemId
|
||||
) {
|
||||
container.dataset.currentId = activeButton.dataset.listItemId;
|
||||
activeButton.scrollIntoView?.({ block: 'nearest' });
|
||||
} else if (!activeButton) {
|
||||
delete container.dataset.currentId;
|
||||
}
|
||||
}
|
||||
@@ -128,20 +128,9 @@ test('cockpit brief controls keep their English sizes', () => {
|
||||
|
||||
test('the count-line class is set only outside English', async () => {
|
||||
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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user