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

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

No files matched your search

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