feat(ui): render rail cards and card blocks as components behind their builders

The rail cards, card blocks, settings, chips and row lists are Vue
components in src/ui/rail/. createRailCards() and createRailCardBlocks()
keep their update and destroy API and patch the components synchronously
with render(), so Weather and Recent imagery are unchanged. Controls of a
removed block dispatch nothing, a released range keeps its value until the
owner's next update, and range values are written only when they differ.
Weather's remount state is keyed by its panel. Tests of rendered parts run
on happy-dom.
This commit is contained in:
Sameh Khamis committed 2026-10-30 18:36:01 -07:00
1 parent d9fdcfc641
commit dcecf5c34c
20 files changed
+754 -766

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
import { GlobalRegistrator } from '@happy-dom/global-registrator';
// Import first in a test file whose modules load Vue at import time: Vue binds
// the document when it loads. Each test file runs in its own process.
GlobalRegistrator.register({ url: 'http://localhost/' });
+67
View File
@@ -0,0 +1,67 @@
<script setup>
import { computed, onBeforeUnmount } from 'vue';
import RailCardBlocks from './RailCardBlocks.vue';
// One keyed card: a disclosure header and its blocks. The owner decides
// which card is open.
const props = defineProps({
card: { type: Object, required: true },
cardClassName: { type: String, default: '' },
badgeClassName: { type: String, default: '' },
onParams: { type: Function, default: () => {} },
onOpen: { type: Function, default: () => {} },
});
const bodyId = `rail-card-body-${nextBodyId()}`;
const open = computed(() => props.card.open !== false);
// A removed card's header opens nothing.
let live = true;
onBeforeUnmount(() => {
live = false;
});
const select = () => live && props.onOpen(props.card.id);
</script>
<script>
let bodies = 0;
const nextBodyId = () => ++bodies;
</script>
<template>
<article
:class="`rail-card${cardClassName ? ` ${cardClassName}` : ''}${open ? ' is-open' : ''}`"
:data-card-id="card.id"
:data-open="String(open)"
><button
class="rail-card-header"
type="button"
:aria-controls="bodyId"
:aria-expanded="String(open)"
@click="select"
><span class="rail-card-labels"
><span class="rail-card-heading"
><span class="data-icon" aria-hidden="true" :hidden="!card.icon">{{
card.icon || ''
}}</span
><span class="rail-card-title rail-card-nowrap" :title="card.title">{{
card.title
}}</span></span
><span
:class="`gev-status-badge gev-status-badge--plain rail-card-badge${badgeClassName ? ` ${badgeClassName}` : ''}`"
:hidden="!card.badge"
>{{ card.badge || '' }}</span
></span
><span class="rail-card-disclosure" aria-hidden="true">{{
open ? '▾' : '▸'
}}</span
><span
:class="`rail-card-compact${card.compactStatus ? ' status' : ''}`"
:hidden="open"
>{{ card.compact || '' }}</span
></button
><div :id="bodyId" class="rail-card-body" :hidden="!open"
><RailCardBlocks
:blocks="card.blocks || []"
:card-id="card.id"
:on-params="onParams" /></div
></article>
</template>
+197
View File
@@ -0,0 +1,197 @@
<script setup>
import { onBeforeUnmount } from 'vue';
import { t } from '../../i18n/index.js';
import { formatScaleLabel, unitLabel } from '../unitLabels.js';
import RailCardSetting from './RailCardSetting.vue';
import RailChips from './RailChips.vue';
import RailRowList from './RailRowList.vue';
// Optional, ordered blocks without disclosure state. Every control is keyed.
const props = defineProps({
blocks: { type: Array, default: () => [] },
cardId: { type: String, default: undefined },
onParams: { type: Function, default: () => {} },
});
// Controls of removed blocks dispatch nothing: a detached element keeps its
// last handler, which names a block the owner no longer lists.
let live = true;
onBeforeUnmount(() => {
live = false;
});
function dispatch(descriptor, block) {
if (!live || !props.blocks.includes(block)) return;
if (!descriptor || descriptor.disabled) return;
if (descriptor.params) props.onParams(props.cardId, descriptor.params);
else descriptor.onClick?.();
}
const rootTag = (block) =>
block.type === 'list' ? 'ol' : block.type === 'actions' ? 'footer' : 'div';
const lines = (block) => block.lines || [{ id: block.id, text: block.text }];
// "960 — 1050+ гПа": the unit stays with the last number.
function scaleText(low, high, units) {
const unit = unitLabel(units);
const range = `${formatScaleLabel(low)} — ${formatScaleLabel(high)}`;
return unit ? `${range} ${unit}` : range;
}
function legendView(legend) {
const colors = (legend?.colors || []).filter((color) =>
/^#(?:[0-9a-f]{6}|[0-9a-f]{8})$/i.test(color),
);
const visible = colors.length >= 2 && colors.length === legend.colors.length;
const zeroVisible =
visible &&
legend.units === '°C' &&
Number.isInteger(legend.zeroIndex) &&
legend.zeroIndex >= 0 &&
legend.zeroIndex < colors.length;
return {
colors,
visible,
categorical: Boolean(legend?.categorical),
zeroVisible,
// Styles are strings so an unchanged legend writes nothing; browsers
// normalize the colors they read back.
rampStyle: visible
? `background: linear-gradient(to right, ${colors.join(',')})`
: undefined,
zeroStyle: zeroVisible
? `left: ${(legend.zeroIndex / (colors.length - 1)) * 100}%`
: undefined,
scale: visible
? scaleText(legend.labels[0], legend.labels.at(-1), legend.units)
: '',
labels: legend?.labels || [],
};
}
</script>
<template>
<template v-for="block in blocks" :key="`${block.id}:${block.type}`"
><RailRowList
v-if="block.type === 'list'"
:list="block.list"
:block-id="block.id"
:on-select="(item) => dispatch(item, block)"
/><component
:is="rootTag(block)"
v-else
:class="`rail-card-${block.type}`"
:data-block-id="block.id"
:hidden="
block.type === 'legend' ? !legendView(block.legend).visible : undefined
"
><RailCardBlocks
v-if="block.type === 'settings'"
:blocks="
(block.settings || []).map((setting) => ({
...setting,
type: 'setting',
}))
"
:card-id="cardId"
:on-params="onParams"
/><RailCardSetting
v-else-if="block.type === 'setting'"
:setting="block"
:dispatch="(descriptor) => dispatch(descriptor, block)"
/><template v-else-if="block.type === 'result'"
><div class="rail-card-result-header"
><span class="panel-title">{{ block.label }}</span
><button
class="data-toggle-chip"
type="button"
data-action-id="clear"
:aria-label="t('Clear reading')"
:title="t('Clear reading')"
:hidden="!block.clear"
@click="dispatch(block.clear, block)"
>×</button
></div
><div class="rail-card-result-lines"
><RailCardBlocks
:blocks="[
{ id: 'result-lines', type: 'lines', lines: block.lines },
]"
:card-id="cardId"
:on-params="onParams" /></div></template
><template v-else-if="block.type === 'legend'"
><div
class="rail-card-ramp"
:hidden="
!legendView(block.legend).visible ||
legendView(block.legend).categorical
"
:style="legendView(block.legend).rampStyle"
><span
class="rail-card-zero"
aria-hidden="true"
:hidden="!legendView(block.legend).zeroVisible"
:style="legendView(block.legend).zeroStyle"
></span></div
><div
class="rail-card-scale"
:hidden="legendView(block.legend).categorical"
>{{ legendView(block.legend).scale }}</div
><div
class="rail-card-legend-entries"
:hidden="!legendView(block.legend).categorical"
><template v-if="legendView(block.legend).categorical"
><div
v-for="(color, index) in legendView(block.legend).colors"
:key="index"
class="rail-card-legend-entry"
:data-color="color"
><span
class="rail-card-legend-swatch"
:style="`background: ${color}`"
></span
><span>{{
legendView(block.legend).labels[index] || ''
}}</span></div
></template
></div
></template
><template v-else-if="block.type === 'actions'"
><template
v-for="action in block.actions || []"
:key="`${action.id}:${action.href ? 'a' : 'button'}`"
><a
v-if="action.href"
class="data-toggle-chip"
:data-action-id="action.id"
target="_blank"
rel="noopener"
:href="action.href"
:title="action.title || undefined"
@click="dispatch(action, block)"
>{{ action.label }}</a
><button
v-else
class="data-toggle-chip"
type="button"
:data-action-id="action.id"
:title="action.title || undefined"
:disabled="Boolean(action.disabled)"
@click="dispatch(action, block)"
>{{ action.label }}</button
><span class="rail-card-action-hint" :hidden="!action.hint">{{
action.hint || ''
}}</span></template
></template
><template v-else
><div
v-for="line in lines(block)"
:key="line.id"
:class="`rail-card-line${line.muted ? ' muted' : ''}`"
:data-line-id="line.id"
>{{ String(line.text ?? '') }}</div
></template
></component
></template
>
</template>
+104
View File
@@ -0,0 +1,104 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { vRangeValue } from './rangeValue.js';
import RailChips from './RailChips.vue';
// One setting: a chip group and an optional range. The handle's value is held
// until the owner's next update; a range that commits on change keeps it
// across updates until its descriptor changes.
const props = defineProps({
setting: { type: Object, required: true },
dispatch: { type: Function, required: true },
});
const draft = ref(null);
watch(
() => props.setting,
() => {
const range = props.setting.range;
const held = draft.value;
if (
held &&
(held.committed ||
!range ||
range.disabled ||
!range.commitOnChange ||
['id', 'param', 'min', 'max', 'step', 'value'].some(
(key) => range[key] !== held.range[key],
))
)
draft.value = null;
},
{ flush: 'sync' },
);
const range = computed(() => props.setting.range);
const value = computed(() => {
const { min, max } = range.value;
const candidate = Number(draft.value?.value ?? range.value.value);
return Math.min(
Number(max),
Math.max(Number(min), Number.isFinite(candidate) ? candidate : Number(min)),
);
});
const valueText = computed(() => `${value.value}${range.value.unit || ''}`);
function rangeParams(input) {
const current = range.value;
const next = Number(input.value);
if (
!current?.param ||
current.disabled ||
!Number.isFinite(next) ||
next < Number(current.min) ||
next > Number(current.max)
)
return null;
return { params: { [current.param]: next } };
}
function onInput(event) {
const params = rangeParams(event.target);
if (!params) return;
// The readout follows the handle at once, whether or not the owner redraws.
draft.value = { range: range.value, value: Number(event.target.value) };
if (!range.value.commitOnChange) props.dispatch(params);
}
function onChange(event) {
if (!range.value?.commitOnChange) return;
const params = rangeParams(event.target);
// Committed: the next update shows the owner's value, as before.
if (draft.value) draft.value = { ...draft.value, committed: true };
if (params) props.dispatch(params);
}
</script>
<template>
<span class="rail-card-setting-label panel-title">{{ setting.label }}</span
><div class="rail-card-chips" role="group" :aria-label="setting.label"
><RailChips :chips="setting.chips" :on-select="dispatch" /></div
><div class="rail-card-setting-range-row" :hidden="!range"
><template v-if="range"
><span class="rail-card-setting-range-label" :hidden="!range.label">{{
range.label || ''
}}</span
><input
type="range"
class="gev-quantitative-slider rail-card-setting-range"
:data-range-id="range.id || setting.id"
:min="String(Number(range.min))"
:max="String(Number(range.max))"
:step="String(Number(range.step))"
v-range-value="String(value)"
:disabled="Boolean(range.disabled)"
:aria-label="range.ariaLabel || setting.label"
:aria-valuetext="valueText"
@input="onInput"
@change="onChange"
/><span class="rail-card-setting-range-value">{{
valueText
}}</span></template
></div
>
</template>
+24
View File
@@ -0,0 +1,24 @@
<script setup>
import RailCard from './RailCard.vue';
// Keyed cards in the owner's order; the owner owns the open-card choice.
defineProps({
cards: { type: Array, default: () => [] },
cardClassName: { type: String, default: '' },
badgeClassName: { type: String, default: '' },
onParams: { type: Function, default: () => {} },
onOpen: { type: Function, default: () => {} },
});
</script>
<template>
<RailCard
v-for="card in cards"
:key="card.id"
:card="card"
:card-class-name="cardClassName"
:badge-class-name="badgeClassName"
:on-params="onParams"
:on-open="onOpen"
/>
</template>
+28
View File
@@ -0,0 +1,28 @@
<script setup>
// Keyed chips shared by rail cards; the owner decides what a click does.
defineProps({
chips: { type: Array, default: () => [] },
onSelect: { type: Function, default: () => {} },
});
const chipClass = (chip) =>
`data-toggle-chip chip-${chip.state || (chip.active ? 'active' : 'idle')}${chip.active ? ' active' : ''}`;
</script>
<template>
<button
v-for="chip in chips"
:key="chip.id"
type="button"
:data-chip-id="chip.id"
:class="chipClass(chip)"
:style="chip.color ? `--chip-color: ${chip.color}` : undefined"
:data-chip-color="chip.color || undefined"
:title="chip.title || undefined"
:disabled="Boolean(chip.disabled)"
:aria-pressed="String(Boolean(chip.active))"
:aria-busy="String(Boolean(chip.busy))"
@click="onSelect(chip)"
>{{ chip.label }}</button
>
</template>
+62
View File
@@ -0,0 +1,62 @@
<script setup>
import { onMounted, onUpdated, ref } from 'vue';
// An ordered list whose rows keep their identity, so a focused row stays.
const props = defineProps({
list: { type: Object, default: null },
blockId: { type: String, default: undefined },
onSelect: { type: Function, default: () => {} },
});
const root = ref(null);
const rowClass = (item) =>
`data-row-list-item${item.disabled ? ' note' : ''}${item.active ? ' active' : ''}${item.current ? ' current' : ''}`;
// Follow the flight, but never steal a scroll the reader is making
// themselves: only when the step actually changed.
function followCurrent() {
const list = root.value;
if (!list) return;
const current = props.list?.items?.find((item) => item.current);
if (!current) {
delete list.dataset.currentId;
return;
}
if (list.dataset.currentId === current.id) return;
list.dataset.currentId = current.id;
[...list.querySelectorAll('.data-row-list-item')]
.find((button) => button.dataset.listItemId === current.id)
?.scrollIntoView?.({ block: 'nearest' });
}
onMounted(followCurrent);
onUpdated(followCurrent);
</script>
<template>
<ol
ref="root"
class="data-row-list"
:data-block-id="blockId"
:hidden="!list?.items?.length"
:aria-label="list?.ariaLabel || ''"
><li
v-for="item in list?.items ?? []"
:key="item.id"
:data-list-item-id="item.id"
><button
type="button"
:class="rowClass(item)"
:data-list-item-id="item.id"
:disabled="Boolean(item.disabled)"
:aria-current="item.current ? 'step' : 'false'"
:aria-pressed="item.active ? 'true' : 'false'"
:title="String(item.text ?? '')"
@click="onSelect(item)"
><span class="data-row-list-lead">{{ String(item.lead ?? '') }}</span
><span class="data-row-list-text">{{
String(item.text ?? '')
}}</span></button
></li
></ol
>
</template>
+11
View File
@@ -0,0 +1,11 @@
/**
* `v-range-value`: a range input's live value, written only when it differs.
* A `:value` binding would also rewrite the value attribute on every patch;
* the directive runs after the element's min, max and step are patched, so
* the value is never clamped to a stale range.
*/
const apply = (el, { value }) => {
const next = String(value);
if (el.value !== next) el.value = next;
};
export const vRangeValue = { mounted: apply, updated: apply };
+14 -399
View File
@@ -1,407 +1,22 @@
import { t } from '../i18n/index.js';
import { syncChipGroup } from './chipGroup.js';
import { syncRowList } from './rowList.js';
import { formatScaleLabel, unitLabel } from './unitLabels.js';
import { createSlider } from './slider.js';
import { h, render } from 'vue';
import RailCardBlocks from './rail/RailCardBlocks.vue';
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);
};
const order = (parent, nodes) =>
nodes.forEach((node, i) => {
if (parent.children[i] !== node)
parent.insertBefore(node, parent.children[i] || null);
});
// "960 — 1050+ гПа": the unit stays with the last number.
const scaleText = (low, high, units) => {
const unit = unitLabel(units);
const range = `${formatScaleLabel(low)} — ${formatScaleLabel(high)}`;
return unit ? `${range}\u00a0${unit}` : range;
};
/** Optional, ordered blocks without disclosure state. All controls are keyed. */
/**
* Optional, ordered blocks without disclosure state. All controls are keyed.
* Renders the RailCardBlocks component into `container`, which it owns, and
* patches it synchronously on every update.
*/
export function createRailCardBlocks({ container, cardId, onParams }) {
const document = container.ownerDocument;
const blocks = new Map();
const make = (tag, className, parent) => {
const node = document.createElement(tag);
node.className = className;
parent.appendChild(node);
return node;
};
const dispatch = (props) => {
if (!props || props.disabled) return;
if (props.params) onParams(cardId, props.params);
else props.onClick?.();
};
const dispose = (block) => {
block.removeClick?.();
block.removeInput?.();
block.children?.destroy();
block.root.remove();
};
let destroyed = false;
return {
update(descriptors = []) {
const ids = new Set();
for (const props of descriptors) {
ids.add(props.id);
let block = blocks.get(props.id);
if (block && block.type !== props.type) {
dispose(block);
blocks.delete(props.id);
block = null;
}
if (!block) {
const root = make(
props.type === 'list'
? 'ol'
: props.type === 'actions'
? 'footer'
: 'div',
props.type === 'list' ? 'data-row-list' : `rail-card-${props.type}`,
container,
);
root.dataset.blockId = props.id;
block = { root, type: props.type, props };
const bind = (fn) => {
root.addEventListener('click', fn);
block.removeClick = () => root.removeEventListener('click', fn);
};
if (props.type === 'settings') {
block.children = createRailCardBlocks({
container: root,
cardId,
onParams,
});
} else if (props.type === 'setting') {
block.label = make(
'span',
'rail-card-setting-label panel-title',
root,
);
block.chips = make('div', 'rail-card-chips', root);
block.chips.setAttribute('role', 'group');
block.rangeRow = make('div', 'rail-card-setting-range-row', root);
block.rangeLabel = make(
'span',
'rail-card-setting-range-label',
block.rangeRow,
);
block.rangeInput = createSlider({
documentRef: document,
className: 'rail-card-setting-range',
});
block.rangeRow.appendChild(block.rangeInput);
block.rangeValue = make(
'span',
'rail-card-setting-range-value',
block.rangeRow,
);
const rangeParams = () => {
const range = block.props.range;
const value = Number(block.rangeInput.value);
if (
!range?.param ||
range.disabled ||
!Number.isFinite(value) ||
value < Number(range.min) ||
value > Number(range.max)
)
return null;
return { params: { [range.param]: value } };
};
const onInput = () => {
const range = block.props.range;
const value = Number(block.rangeInput.value);
const params = rangeParams();
if (!params) return;
if (range.commitOnChange) block.rangeDraft = { range, value };
set(
block.rangeValue,
'textContent',
`${value}${range.unit || ''}`,
);
attribute(
block.rangeInput,
'aria-valuetext',
`${value}${range.unit || ''}`,
);
if (!range.commitOnChange) dispatch(params);
};
const onChange = () => {
if (!block.props.range?.commitOnChange) return;
const params = rangeParams();
block.rangeDraft = null;
if (params) dispatch(params);
};
block.rangeInput.addEventListener('input', onInput);
block.rangeInput.addEventListener('change', onChange);
block.removeInput = () => {
block.rangeInput.removeEventListener('input', onInput);
block.rangeInput.removeEventListener('change', onChange);
};
bind((event) => {
const button = event.target?.closest?.('[data-chip-id]');
dispatch(
block.props.chips?.find(
({ id }) => id === button?.dataset.chipId,
),
);
});
} else if (props.type === 'list') {
bind((event) => {
const button = event.target?.closest?.('.data-row-list-item');
dispatch(
block.props.list?.items?.find(
({ id }) => id === button?.dataset.listItemId,
),
);
});
} else if (props.type === 'result') {
block.header = make('div', 'rail-card-result-header', root);
block.label = make('span', 'panel-title', block.header);
block.clear = make('button', 'data-toggle-chip', block.header);
block.clear.type = 'button';
block.clear.dataset.actionId = 'clear';
block.clear.textContent = '×';
block.clear.setAttribute('aria-label', t('Clear reading'));
block.clear.title = t('Clear reading');
bind((event) => {
if (event.target === block.clear) dispatch(block.props.clear);
});
block.children = createRailCardBlocks({
container: make('div', 'rail-card-result-lines', root),
cardId,
onParams,
});
} else if (props.type === 'legend') {
block.ramp = make('div', 'rail-card-ramp', root);
block.zero = make('span', 'rail-card-zero', block.ramp);
block.zero.setAttribute('aria-hidden', 'true');
block.scale = make('div', 'rail-card-scale', root);
block.entries = make('div', 'rail-card-legend-entries', root);
} else if (props.type === 'actions') {
block.actions = new Map();
bind((event) => {
for (const action of block.actions.values())
if (event.target === action.node) dispatch(action.props);
});
} else block.lines = new Map();
blocks.set(props.id, block);
}
block.props = props;
if (props.type === 'settings') {
block.children.update(
(props.settings || []).map((setting) => ({
...setting,
type: 'setting',
})),
);
} else if (props.type === 'setting') {
set(block.label, 'textContent', props.label);
attribute(block.chips, 'aria-label', props.label);
syncChipGroup(block.chips, props.chips);
const range = props.range;
set(block.rangeRow, 'hidden', !range);
const draft = block.rangeDraft;
if (
draft &&
(!range ||
range.disabled ||
!range.commitOnChange ||
['id', 'param', 'min', 'max', 'step', 'value'].some(
(key) => range[key] !== draft.range[key],
))
)
block.rangeDraft = null;
if (range) {
const min = Number(range.min);
const max = Number(range.max);
const step = Number(range.step);
const candidate = Number(block.rangeDraft?.value ?? range.value);
const value = Math.min(
max,
Math.max(min, Number.isFinite(candidate) ? candidate : min),
);
set(block.rangeLabel, 'textContent', range.label || '');
set(block.rangeLabel, 'hidden', !range.label);
set(block.rangeInput.dataset, 'rangeId', range.id || props.id);
set(block.rangeInput, 'min', String(min));
set(block.rangeInput, 'max', String(max));
set(block.rangeInput, 'step', String(step));
set(block.rangeInput, 'value', String(value));
set(block.rangeInput, 'disabled', Boolean(range.disabled));
attribute(
block.rangeInput,
'aria-label',
range.ariaLabel || props.label,
);
attribute(
block.rangeInput,
'aria-valuetext',
`${value}${range.unit || ''}`,
);
set(block.rangeValue, 'textContent', `${value}${range.unit || ''}`);
}
} else if (props.type === 'list') syncRowList(block.root, props.list);
else if (props.type === 'result') {
set(block.label, 'textContent', props.label);
set(block.clear, 'hidden', !props.clear);
block.children.update([
{ id: 'result-lines', type: 'lines', lines: props.lines },
]);
} else if (props.type === 'legend') {
const legend = props.legend;
const colors = (legend?.colors || []).filter((color) =>
/^#(?:[0-9a-f]{6}|[0-9a-f]{8})$/i.test(color),
);
const visible =
colors.length >= 2 && colors.length === legend.colors.length;
set(block.root, 'hidden', !visible);
set(block.ramp, 'hidden', !visible || Boolean(legend?.categorical));
set(block.scale, 'hidden', Boolean(legend?.categorical));
set(block.entries, 'hidden', !legend?.categorical);
if (legend?.categorical) {
while (block.entries.children.length > colors.length)
block.entries.children[
block.entries.children.length - 1
].remove();
colors.forEach((color, index) => {
let entry = block.entries.children[index];
if (!entry) {
entry = make('div', 'rail-card-legend-entry', block.entries);
make('span', 'rail-card-legend-swatch', entry);
make('span', '', entry);
}
if (entry.dataset.color !== color) {
entry.children[0].style.background = color;
entry.dataset.color = color;
}
set(entry.children[1], 'textContent', legend.labels[index] || '');
});
}
const gradient = visible
? `linear-gradient(to right, ${colors.join(',')})`
: '';
if (block.gradient !== gradient) {
block.ramp.style.background = gradient;
block.gradient = gradient;
}
const zeroVisible =
visible &&
legend.units === '°C' &&
Number.isInteger(legend.zeroIndex) &&
legend.zeroIndex >= 0 &&
legend.zeroIndex < colors.length;
set(block.zero, 'hidden', !zeroVisible);
if (zeroVisible) {
const left = `${(legend.zeroIndex / (colors.length - 1)) * 100}%`;
if (block.zeroLeft !== left) {
block.zero.style.left = left;
block.zeroLeft = left;
}
}
set(
block.scale,
'textContent',
visible
? scaleText(legend.labels[0], legend.labels.at(-1), legend.units)
: '',
);
} else if (props.type === 'actions') {
const actions = props.actions || [];
for (const [id, action] of block.actions)
if (
!actions.some(
(props) =>
props.id === id &&
(props.href ? 'a' : 'button') === action.tag,
)
) {
action.node.remove();
action.hint.remove();
block.actions.delete(id);
}
for (const actionProps of actions) {
let action = block.actions.get(actionProps.id);
if (!action) {
const tag = actionProps.href ? 'a' : 'button';
const node = make(tag, 'data-toggle-chip', block.root);
node.dataset.actionId = actionProps.id;
if (tag === 'button') node.type = 'button';
else {
node.target = '_blank';
node.rel = 'noopener';
}
action = {
node,
tag,
hint: make('span', 'rail-card-action-hint', block.root),
};
block.actions.set(actionProps.id, action);
}
action.props = actionProps;
set(action.node, 'textContent', actionProps.label);
set(action.node, 'title', actionProps.title || '');
set(action.node, 'disabled', Boolean(actionProps.disabled));
if (action.tag === 'a' && action.href !== actionProps.href) {
action.node.href = actionProps.href;
action.href = actionProps.href;
}
set(action.hint, 'textContent', actionProps.hint || '');
set(action.hint, 'hidden', !actionProps.hint);
}
order(
block.root,
actions.flatMap(({ id }) => {
const action = block.actions.get(id);
return [action.node, action.hint];
}),
);
} else {
const lines = props.lines || [{ id: props.id, text: props.text }];
for (const [id, node] of block.lines)
if (!lines.some((line) => line.id === id)) {
node.remove();
block.lines.delete(id);
}
for (const line of lines) {
let node = block.lines.get(line.id);
if (!node) {
node = make('div', 'rail-card-line', block.root);
node.dataset.lineId = line.id;
block.lines.set(line.id, node);
}
set(node, 'textContent', String(line.text ?? ''));
set(
node,
'className',
`rail-card-line${line.muted ? ' muted' : ''}`,
);
}
order(
block.root,
lines.map(({ id }) => block.lines.get(id)),
);
}
}
for (const [id, block] of blocks)
if (!ids.has(id)) {
dispose(block);
blocks.delete(id);
}
order(
container,
descriptors.map(({ id }) => blocks.get(id).root),
);
update(blocks = []) {
if (destroyed) return;
render(h(RailCardBlocks, { blocks, cardId, onParams }), container);
},
destroy() {
for (const block of blocks.values()) dispose(block);
blocks.clear();
if (destroyed) return;
destroyed = true;
render(null, container);
},
};
}
+19 -112
View File
@@ -1,12 +1,11 @@
import { createRailCardBlocks } from './railCardBlocks.js';
import { createStatusBadge, updateStatusBadge } from './statusBadge.js';
import { h, render } from 'vue';
import RailCards from './rail/RailCards.vue';
const set = (node, key, value) => {
if (node[key] !== value) node[key] = value;
};
let nextId = 0;
/** Keyed cards with optional ordered blocks; the feature owns open-card choice. */
/**
* Keyed cards with optional ordered blocks; the feature owns open-card choice.
* Renders the RailCards component into `container`, which it owns, and
* patches it synchronously on every update so the owner can measure at once.
*/
export function createRailCards({
container,
document = container?.ownerDocument,
@@ -16,117 +15,25 @@ export function createRailCards({
onOpen = () => {},
} = {}) {
if (!document?.createElement || !container) return null;
const rows = new Map();
let destroyed = false;
const make = (tag, className, parent) => {
const node = document.createElement(tag);
node.className = className;
parent.appendChild(node);
return node;
};
const dispose = (row) => {
row.header.removeEventListener('click', row.click);
row.blocks.destroy();
row.element.remove();
};
return {
update(cards = []) {
if (destroyed) return;
const active = new Set();
for (const card of cards) {
active.add(card.id);
let row = rows.get(card.id);
if (!row) {
const element = make(
'article',
`rail-card${cardClassName ? ` ${cardClassName}` : ''}`,
container,
);
element.dataset.cardId = card.id;
const header = make('button', 'rail-card-header', element);
header.type = 'button';
const labels = make('span', 'rail-card-labels', header);
const heading = make('span', 'rail-card-heading', labels);
const icon = make('span', 'data-icon', heading);
icon.setAttribute('aria-hidden', 'true');
const title = make(
'span',
'rail-card-title rail-card-nowrap',
heading,
);
const badge = createStatusBadge({
documentRef: document,
appearance: 'plain',
className: `rail-card-badge${badgeClassName ? ` ${badgeClassName}` : ''}`,
});
labels.appendChild(badge);
const disclosure = make('span', 'rail-card-disclosure', header);
disclosure.setAttribute('aria-hidden', 'true');
const compact = make('span', 'rail-card-compact', header);
const body = make('div', 'rail-card-body', element);
body.id = `rail-card-body-${++nextId}`;
header.setAttribute('aria-controls', body.id);
const blocks = createRailCardBlocks({
container: body,
cardId: card.id,
onParams,
});
const click = () => onOpen(card.id);
header.addEventListener('click', click);
row = {
element,
header,
icon,
title,
disclosure,
badge,
compact,
body,
blocks,
click,
};
rows.set(card.id, row);
}
set(row.title, 'textContent', card.title);
if (row.title.getAttribute('title') !== card.title)
row.title.setAttribute('title', card.title);
set(row.icon, 'textContent', card.icon || '');
set(row.icon, 'hidden', !card.icon);
updateStatusBadge(row.badge, {
label: card.badge || '',
hidden: !card.badge,
});
const open = card.open !== false;
set(row.element.dataset, 'open', String(open));
row.element.classList.toggle('is-open', open);
set(row.disclosure, 'textContent', open ? '▾' : '▸');
if (row.header.getAttribute('aria-expanded') !== String(open))
row.header.setAttribute('aria-expanded', String(open));
set(row.body, 'hidden', !open);
set(row.compact, 'hidden', open);
set(row.compact, 'textContent', card.compact || '');
set(
row.compact,
'className',
`rail-card-compact${card.compactStatus ? ' status' : ''}`,
);
row.blocks.update(card.blocks || []);
}
for (const [id, row] of rows)
if (!active.has(id)) {
dispose(row);
rows.delete(id);
}
cards.forEach(({ id }, index) => {
const node = rows.get(id).element;
if (container.children[index] !== node)
container.insertBefore(node, container.children[index] || null);
});
render(
h(RailCards, {
cards,
cardClassName,
badgeClassName,
onParams,
onOpen,
}),
container,
);
},
destroy() {
if (destroyed) return;
destroyed = true;
for (const row of rows.values()) dispose(row);
rows.clear();
render(null, container);
},
};
}
+45 -33
View File
@@ -1,7 +1,9 @@
import '../testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { nextTick } from 'vue';
import { createRailCards } from './railCards.js';
import { railFixture } from './railTestFixture.mjs';
import { railDocumentFixture as railFixture } from './railTestFixture.mjs';
const legend = {
colors: ['#112233', '#ffffff', '#abcdef'],
@@ -25,8 +27,7 @@ const card = {
],
};
test('keyed cards and optional blocks reorder without redundant writes', () => {
let writes = 0;
const f = railFixture(() => writes++);
const f = railFixture();
const view = createRailCards(f);
view.update([card]);
const article = f.container.children[0];
@@ -50,13 +51,15 @@ test('keyed cards and optional blocks reorder without redundant writes', () => {
[],
]) {
view.update(cards);
writes = 0;
f.writes();
view.update(cards);
assert.equal(writes, 0);
assert.equal(f.writes(), 0);
}
view.update([{ ...card, blocks: [...card.blocks].reverse() }]);
assert.deepEqual(
f.container.children[0].children[1].children.map((n) => n.dataset.blockId),
[...f.container.children[0].children[1].children].map(
(n) => n.dataset.blockId,
),
['actions', 'legend', 'details'],
);
view.destroy();
@@ -92,7 +95,7 @@ test('footer buttons retain focus and current callbacks; links use safe new tabs
]),
]);
assert.equal(f.document.activeElement, button);
assert.equal(button.parent.tagName, 'FOOTER');
assert.equal(button.parentElement.tagName, 'FOOTER');
assert.equal(
f.find((n) => n.className === 'rail-card-action-hint').textContent,
'at map center',
@@ -119,7 +122,7 @@ test('footer buttons retain focus and current callbacks; links use safe new tabs
button.click();
assert.deepEqual(calls, [2]);
});
test('setting ranges stay below presets and commit expensive filters on release', () => {
test('setting ranges stay below presets and commit expensive filters on release', async () => {
const f = railFixture();
const calls = [];
const view = createRailCards({
@@ -177,6 +180,7 @@ test('setting ranges stay below presets and commit expensive filters on release'
slider.focus();
slider.value = '53';
slider.dispatchEvent(new Event('input'));
await nextTick();
assert.equal(calls.length, 0, 'expensive image work waits until release');
assert.equal(
f.find((node) => node.className === 'rail-card-setting-range-value')
@@ -192,11 +196,14 @@ test('setting ranges stay below presets and commit expensive filters on release'
);
slider.dispatchEvent(new Event('change'));
assert.deepEqual(calls.at(-1), ['a', { infrared: 53 }]);
await nextTick();
assert.equal(slider.value, '53', 'release keeps the handle until the owner');
view.update([model(53)]);
assert.equal(slider.value, '53');
assert.equal(f.document.activeElement, slider);
slider.value = '41';
slider.dispatchEvent(new Event('input'));
await nextTick();
view.update([model(70)]);
assert.equal(
slider.value,
@@ -208,10 +215,31 @@ test('setting ranges stay below presets and commit expensive filters on release'
slider.dispatchEvent(new Event('change'));
assert.equal(calls.length, 1, 'destroy removes the range listener');
});
test('controls of a removed block dispatch nothing', () => {
const f = railFixture();
const calls = [];
const view = createRailCards({
...f,
onParams: (...args) => calls.push(args),
});
const actions = {
id: 'actions',
type: 'actions',
actions: [{ id: 'go', label: 'Go', params: { go: 1 } }],
};
view.update([{ ...card, blocks: [actions] }]);
const button = f.find((node) => node.dataset.actionId === 'go');
view.update([{ ...card, blocks: [] }]);
button.click();
assert.deepEqual(calls, []);
view.update([{ ...card, blocks: [{ ...actions }] }]);
f.find((node) => node.dataset.actionId === 'go').click();
assert.deepEqual(calls, [['a', { go: 1 }]]);
view.destroy();
});
test('legend validates colors, preserves the freezing anchor and ignores CSSOM normalization', () => {
let writes = 0;
const f = railFixture(() => writes++);
const f = railFixture();
const view = createRailCards(f);
const show = (legend) =>
view.update([
@@ -222,26 +250,11 @@ test('legend validates colors, preserves the freezing anchor and ignores CSSOM n
const ramp = f.find((n) => n.className === 'rail-card-ramp');
assert.equal(zero.hidden, false);
assert.equal(zero.style.left, '50%');
Object.defineProperty(ramp.style, 'background', {
get: () =>
'linear-gradient(to right, rgb(17, 34, 51), rgb(255, 255, 255), rgb(171, 205, 239))',
set() {
writes++;
},
configurable: true,
});
Object.defineProperty(zero.style, 'left', {
get: () => '50.0%',
set() {
writes++;
},
configurable: true,
});
writes = 0;
// An unchanged legend writes nothing, whatever the browser normalizes the
// colors it reads back to.
f.writes();
show(legend);
assert.equal(writes, 0);
delete ramp.style.background;
delete zero.style.left;
assert.equal(f.writes(), 0);
show({
...legend,
colors: Array(10).fill('#abcdef'),
@@ -257,8 +270,7 @@ test('legend validates colors, preserves the freezing anchor and ignores CSSOM n
view.destroy();
});
test('labelled rows, lists and results retain focus and dispatch current params', () => {
let writes = 0;
const f = railFixture(() => writes++);
const f = railFixture();
const calls = [];
const view = createRailCards({
...f,
@@ -337,9 +349,9 @@ test('labelled rows, lists and results retain focus and dispatch current params'
clear.click();
assert.deepEqual(calls.at(-1), ['a', { inspect: false }]);
assert.equal(f.find((n) => n.dataset.lineId === 'wind').children.length, 0);
writes = 0;
f.writes();
view.update([model(true)]);
assert.equal(writes, 0);
assert.equal(f.writes(), 0);
view.destroy();
const before = calls.length;
chip.click();
+24
View File
@@ -114,3 +114,27 @@ export function railFixture(onWrite = () => {}) {
find: (predicate, root = container) => find(root, predicate),
};
}
/**
* A container in the test document for presentation that renders with Vue,
* with a count of DOM writes. Import `../testSupport/registerDom.mjs` first.
*/
export function railDocumentFixture() {
const container = document.createElement('div');
document.body.appendChild(container);
const observer = new MutationObserver(() => {});
observer.observe(container, {
subtree: true,
childList: true,
attributes: true,
characterData: true,
});
return {
container,
document,
find: (predicate, root = container) =>
[root, ...root.querySelectorAll('*')].find(predicate) ?? null,
/** DOM writes since the last call. */
writes: () => observer.takeRecords().length,
};
}
+70 -182
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';
@@ -18,186 +19,73 @@ const S18 = 'S30:2026-09-18';
const L16 = 'L30:2026-09-16';
const V21 = 'VIIRS:2026-09-21';
// ---- a small DOM double ------------------------------------------------------
class FakeClassList {
constructor(node) {
this.node = node;
}
get names() {
return new Set(
String(this.node.className || '')
.split(/\s+/)
.filter(Boolean),
);
}
contains(name) {
return this.names.has(name);
}
toggle(name, force) {
const names = this.names;
const on = force === undefined ? !names.has(name) : Boolean(force);
if (on) names.add(name);
else names.delete(name);
this.node.className = [...names].join(' ');
return on;
}
add(...list) {
for (const name of list) this.toggle(name, true);
}
remove(...list) {
for (const name of list) this.toggle(name, false);
}
}
function matches(node, selector) {
if (selector.startsWith('.'))
return node.classList.contains(selector.slice(1));
if (selector.startsWith('#')) return node.id === selector.slice(1);
const attr = /^\[([\w-]+)(?:="([^"]*)")?\]$/.exec(selector);
if (!attr) return false;
const key = attr[1].startsWith('data-')
? attr[1].slice(5).replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())
: null;
const value = key ? node.dataset[key] : node.getAttribute(attr[1]);
return attr[2] === undefined ? value != null : value === attr[2];
}
class FakeElement {
constructor(tagName, ownerDocument) {
this.tagName = String(tagName).toUpperCase();
this.ownerDocument = ownerDocument;
this.children = [];
this.parentNode = null;
this.className = '';
this.dataset = {};
this.style = {};
this.attributes = new Map();
this.listeners = new Map();
this.hidden = false;
this.disabled = false;
this.id = '';
this.title = '';
this._text = '';
this.scrollTop = 0;
this.scrollLeft = 0;
this.focused = 0;
this.blurred = 0;
}
get classList() {
return new FakeClassList(this);
}
get textContent() {
return this.children.length
? this.children.map((child) => child.textContent).join('')
: this._text;
}
set textContent(value) {
for (const child of this.children) child.parentNode = null;
this.children = [];
this._text = String(value);
}
setAttribute(name, value) {
this.attributes.set(name, String(value));
}
getAttribute(name) {
return this.attributes.get(name) ?? null;
}
removeAttribute(name) {
this.attributes.delete(name);
}
appendChild(child) {
return this.insertBefore(child, null);
}
insertBefore(child, reference) {
child.parentNode?.removeChild(child);
const index = reference ? this.children.indexOf(reference) : -1;
if (index < 0) this.children.push(child);
else this.children.splice(index, 0, child);
child.parentNode = this;
return child;
}
append(...children) {
for (const child of children) this.appendChild(child);
}
removeChild(child) {
this.children.splice(this.children.indexOf(child), 1);
child.parentNode = null;
}
remove() {
this.parentNode?.removeChild(this);
}
addEventListener(type, handler) {
if (!this.listeners.has(type)) this.listeners.set(type, new Set());
this.listeners.get(type).add(handler);
}
removeEventListener(type, handler) {
this.listeners.get(type)?.delete(handler);
}
/** Bubble an event from this node to the root. */
dispatch(type, init = {}) {
const event = {
type,
target: this,
defaultPrevented: false,
preventDefault() {
this.defaultPrevented = true;
},
...init,
};
for (let node = this; node; node = node.parentNode)
for (const handler of [...(node.listeners.get(type) || [])])
handler(event);
return event;
}
click() {
if (!this.disabled) this.dispatch('click');
}
matches(selector) {
return matches(this, selector);
}
closest(selector) {
for (let node = this; node; node = node.parentNode)
if (matches(node, selector)) return node;
return null;
}
querySelector(selector) {
return this.find((node) => matches(node, selector));
}
find(predicate) {
for (const child of this.children) {
if (predicate(child)) return child;
const inner = child.find(predicate);
if (inner) return inner;
}
return null;
}
findAll(predicate, out = []) {
for (const child of this.children) {
if (predicate(child)) out.push(child);
child.findAll(predicate, out);
}
return out;
}
focus(options) {
this.focused += 1;
this.focusOptions = options;
}
blur() {
this.blurred += 1;
}
// ---- the test document ------------------------------------------------------
// The readout renders its cards with Vue, so it runs in a real document. These
// test-only conveniences count focus, blur and listeners per element.
const elementProto = globalThis.HTMLElement.prototype;
const { focus: nativeFocus, blur: nativeBlur } = elementProto;
elementProto.focus = function (options) {
this.focused = (this.focused || 0) + 1;
this.focusOptions = options;
return nativeFocus.call(this, options);
};
elementProto.blur = function () {
this.blurred = (this.blurred || 0) + 1;
return nativeBlur.call(this);
};
const listeners = new WeakMap();
const { addEventListener: nativeAdd, removeEventListener: nativeRemove } =
globalThis.Element.prototype;
globalThis.Element.prototype.addEventListener = function (
type,
handler,
...rest
) {
if (!listeners.has(this)) listeners.set(this, new Map());
const byType = listeners.get(this);
if (!byType.has(type)) byType.set(type, new Set());
byType.get(type).add(handler);
return nativeAdd.call(this, type, handler, ...rest);
};
globalThis.Element.prototype.removeEventListener = function (
type,
handler,
...rest
) {
listeners.get(this)?.get(type)?.delete(handler);
return nativeRemove.call(this, type, handler, ...rest);
};
Object.assign(globalThis.Element.prototype, {
listenerCount() {
return [...this.listeners.values()].reduce((n, set) => n + set.size, 0);
}
}
return [...(listeners.get(this)?.values() ?? [])].reduce(
(n, set) => n + set.size,
0,
);
},
/** The first descendant matching `predicate`, in document order. */
find(predicate) {
return [...this.querySelectorAll('*')].find(predicate) ?? null;
},
findAll(predicate) {
return [...this.querySelectorAll('*')].filter(predicate);
},
/** Bubble an event of `type` carrying `init`'s fields; returns the event. */
dispatch(type, init = {}) {
const event = new Event(type, { bubbles: true, cancelable: true });
for (const [key, value] of Object.entries(init))
Object.defineProperty(event, key, { value });
this.dispatchEvent(event);
return event;
},
});
/** The static rail panel skeleton from views/ContextView.vue. */
function fakeDocument() {
const document = {
documentElement: { clientWidth: 1200 },
createElement: (tag) => new FakeElement(tag, document),
getElementById: (id) => document.body.find((node) => node.id === id),
};
document.body = new FakeElement('body', document);
document.body.textContent = '';
Object.defineProperty(document.documentElement, 'clientWidth', {
configurable: true,
value: 1200,
});
const panel = document.createElement('div');
panel.id = 'recent-imagery-panel';
panel.className = 'panel-collapsible collapsed';
@@ -386,7 +274,7 @@ test('the readout mounts in the rail body, hides while the layer is off and open
test('the body is a fixed stack of seven blocks in a fixed order, each with a fixed height in the stylesheet', () => {
const f = fixture();
assert.deepEqual(
f.root.children.map((node) => node.id || node.className),
[...f.root.children].map((node) => node.id || node.className),
[
'ri-actions',
'ri-notice',
@@ -520,7 +408,7 @@ test('no control changes place in the tree, no block is hidden and every fixed-h
/height: 22px/,
],
'details header': [
f.root.children.at(-1) === f.byId('ri-details')
[...f.root.children].at(-1) === f.byId('ri-details')
? header?.find((node) => node.classList.contains('rail-card-title'))
: null,
'rail-card-nowrap',
@@ -582,7 +470,7 @@ test('no control changes place in the tree, no block is hidden and every fixed-h
const path = [];
for (let at = node; at && at !== f.root; at = at.parentNode) {
assert.equal(at.hidden, false, `${name} is never hidden`);
path.unshift(`${at.tagName}${at.parentNode.children.indexOf(at)}`);
path.unshift(`${at.tagName}${[...at.parentNode.children].indexOf(at)}`);
}
out[name] = path.join('/');
}
@@ -704,7 +592,7 @@ test('cards carry thumbnail, date, sensor, cloud, START HERE and PREVIEW; the ch
],
['A', 'B', false, false],
);
assert.equal(a.parentNode.children.indexOf(a), 0);
assert.equal([...a.parentNode.children].indexOf(a), 0);
assert.equal(b.getAttribute('aria-label'), 'Pin Sep 18 as B');
// A day is one slot only: B moves it.
b.click();
@@ -923,7 +811,7 @@ test('an oversized box offers ZOOM IN in a fixed slot at the end of the notice l
const zoom = f.byId('ri-zoom-in');
assert.equal(zoom.tagName, 'BUTTON');
assert.equal(zoom.textContent, 'ZOOM IN');
assert.equal(notice.children.at(-1), zoom, 'the right end of the line');
assert.equal([...notice.children].at(-1), zoom, 'the right end of the line');
const reserved = () => [
zoom.classList.contains('is-reserved'),
zoom.disabled,
@@ -1152,7 +1040,7 @@ test('opening DETAILS scrolls its card into view, and closing it does not scroll
await f.ready();
// The body is 100 px high and scrolled 40 px; the card opens 150 px down.
f.body.scrollTop = 40;
f.body.clientHeight = 100;
Object.defineProperty(f.body, 'clientHeight', { value: 100 });
f.body.getBoundingClientRect = () => ({ top: 0 });
const header = f
.byId('ri-details')
@@ -18,6 +18,9 @@ globalThis.document ??= {
getElementById: (id) => elements[id] ?? null,
addEventListener() {},
removeEventListener() {},
// The layer panel reaches Vue, whose DOM runtime creates one element as it
// loads.
createElement: () => ({}),
hidden: false,
};
const { ShellFeedback, localizeLoadingPresentation } =
+8 -5
View File
@@ -1,8 +1,9 @@
import '../testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { createStatusBadge, updateStatusBadge } from './statusBadge.js';
import { createRailCards } from './railCards.js';
import { railFixture } from './railTestFixture.mjs';
import { railDocumentFixture as railFixture } from './railTestFixture.mjs';
test('badges paint literal translated text without installing announcement or interaction semantics', () => {
const { document } = railFixture();
@@ -32,19 +33,21 @@ test('badges paint literal translated text without installing announcement or in
});
test('painting preserves feature state, live regions and focus with zero identical writes', () => {
let writes = 0;
const { document } = railFixture(() => writes++);
const f = railFixture();
const { document } = f;
const badge = createStatusBadge({ documentRef: document, hidden: true });
f.container.appendChild(badge);
badge.setAttribute('role', 'status');
badge.setAttribute('aria-live', 'polite');
badge.dataset.frameState = 'ready';
const focusOwner = document.createElement('button');
document.body.appendChild(focusOwner);
focusOwner.focus();
updateStatusBadge(badge, { label: 'SNAPSHOT · DEGRADED' });
assert.equal(badge.hidden, false);
writes = 0;
f.writes();
updateStatusBadge(badge, { label: 'SNAPSHOT · DEGRADED' });
assert.equal(writes, 0);
assert.equal(f.writes(), 0);
assert.equal(badge.getAttribute('role'), 'status');
assert.equal(badge.getAttribute('aria-live'), 'polite');
assert.equal(badge.dataset.frameState, 'ready');
+3
View File
@@ -9,6 +9,9 @@ globalThis.document ??= {
getElementById: () => null,
addEventListener() {},
removeEventListener() {},
// The layer panel reaches Vue, whose DOM runtime creates one element as it
// loads.
createElement: () => ({}),
hidden: false,
};
const { LayerPanel } = await import('./layerPanel.js');
+9 -7
View File
@@ -3,8 +3,9 @@ import { createRailCards } from './railCards.js';
import { createRailTimeline, utcClock } from './railTimeline.js';
// Remounting controls must not reopen a panel the user already collapsed.
const appearedDocuments = new WeakSet();
const openDocuments = new WeakMap();
// Keyed by the page's Weather panel, which outlives remounted controls.
const appearedPanels = new WeakSet();
const openPanels = new WeakMap();
const ORDER = [
'weather-cyclones',
'wind',
@@ -97,7 +98,8 @@ export function createWeatherPanel({
let destroyed = false;
let previousIds = null;
let hasAppeared = false;
let openId = openDocuments.get(document) || null;
const page = panel || document;
let openId = openPanels.get(page) || null;
// Open card as last shown; null until cards are on screen.
let shownOpenId = null;
const timeline = createRailTimeline({
@@ -118,7 +120,7 @@ export function createWeatherPanel({
onParams: (id, params) => setLayerParams(id, params, { origin: 'user' }),
onOpen: (id) => {
openId = id;
openDocuments.set(document, id);
openPanels.set(page, id);
render();
},
};
@@ -260,8 +262,8 @@ export function createWeatherPanel({
set(root, 'hidden', hidden);
if (count) set(count, 'textContent', String(active.length));
if (panel) set(panel, 'hidden', hidden);
if (!hidden && panel && !appearedDocuments.has(document)) {
appearedDocuments.add(document);
if (!hidden && panel && !appearedPanels.has(panel)) {
appearedPanels.add(panel);
// First appearance opens the panel unless a stored or shared collapse
// choice exists (marked by the panel chrome when it restored the state).
const preference = panel.dataset?.collapsedPreference;
@@ -284,7 +286,7 @@ export function createWeatherPanel({
previousIds && entries.filter(({ id }) => !previousIds.has(id));
if (hasAppeared && added?.length) {
openId = added.at(-1).id;
openDocuments.set(document, openId);
openPanels.set(page, openId);
} else if (ids.size && !ids.has(openId)) {
openId =
(!hasAppeared &&
+30 -23
View File
@@ -1,8 +1,9 @@
import '../testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { setLocale } from '../i18n/index.js';
import { createWeatherPanel } from './weatherPanel.js';
import { railFixture } from './railTestFixture.mjs';
import { railDocumentFixture as railFixture } from './railTestFixture.mjs';
import { createAirQualityLayer } from '../layers/airQuality/index.js';
const ticks = ['2026-09-22T01:00:00Z', '2026-09-22T01:05:00Z'];
const wind = {
@@ -20,14 +21,16 @@ const radar = {
icon: '◉',
summary: { label: 'Rain radar', coverage: 'CONUS' },
};
function fixture(onWrite) {
const f = railFixture(onWrite);
function fixture() {
const f = railFixture();
const panel = f.document.createElement('div');
const count = f.document.createElement('span');
let collapsed = true;
let clicks = 0;
panel.hidden = true;
panel.classList = { contains: () => collapsed };
Object.defineProperty(panel, 'classList', {
value: { contains: () => collapsed },
});
const collapse = {
click() {
collapsed = !collapsed;
@@ -112,7 +115,7 @@ test('ordered articles omit Controls and retain history labels, forecast dates a
view.update([radar, wind]);
const windCard = card(f, 'wind');
assert.equal(windCard.tagName, 'ARTICLE');
assert.equal(windCard.parent.children[0], windCard);
assert.equal(windCard.parentElement.children[0], windCard);
assert.equal(
f.find((n) => n.dataset.weatherOpen),
null,
@@ -186,7 +189,9 @@ test('cyclone readout retains advisory, position, intensity, geometry, coverage,
line(f, cyclone.id, 'geometry').textContent,
/awaiting advisory 10/,
);
const link = f.find((n) => n.textContent === 'Official advisory ↗');
const link = f.find(
(n) => n.tagName === 'A' && n.textContent === 'Official advisory ↗',
);
assert.equal(link.href, cyclone.summary.actions[0].href);
assert.equal(link.tagName, 'A');
assert.equal(link.rel, 'noopener');
@@ -198,8 +203,7 @@ test('cyclone readout retains advisory, position, intensity, geometry, coverage,
view.destroy();
});
test('identical updates write nothing and status keeps its line through loading and empty text', () => {
let writes = 0;
const f = fixture(() => writes++);
const f = fixture();
const view = createWeatherPanel(f);
for (const entries of [
[wind, radar],
@@ -209,9 +213,9 @@ test('identical updates write nothing and status keeps its line through loading
]) {
view.update(entries);
const status = entries.length ? line(f, 'wind', 'status') : null;
writes = 0;
f.writes();
view.update(entries);
assert.equal(writes, 0);
assert.equal(f.writes(), 0);
if (status) {
assert.equal(status.hidden, false);
assert.equal(line(f, 'wind', 'status'), status);
@@ -256,7 +260,7 @@ test('count, hidden-empty and first-appearance expansion survive remount and own
test('a stored or shared collapse choice is not overridden on first appearance', () => {
for (const preference of ['stored', 'share']) {
const f = fixture();
f.panel.dataset = { collapsedPreference: preference };
f.panel.dataset.collapsedPreference = preference;
const view = createWeatherPanel(f);
view.update([radar]);
assert.equal(f.panel.hidden, false);
@@ -268,7 +272,7 @@ test('a stored or shared collapse choice is not overridden on first appearance',
view.destroy();
}
const f = fixture();
f.panel.dataset = { collapsedPreference: 'default' };
f.panel.dataset.collapsedPreference = 'default';
const view = createWeatherPanel(f);
view.update([radar]);
assert.equal(f.clicks(), 1, 'a default state still expands once');
@@ -326,7 +330,7 @@ test('settings, reading and footer actions pass the layer id, params and user or
assert.equal(header.children[1].textContent, '×');
assert.equal(header.children[1].getAttribute('aria-label'), 'Clear reading');
assert.deepEqual(
result.children[1].children[0].children.map((n) => [
[...result.children[1].children[0].children].map((n) => [
n.dataset.lineId,
n.textContent,
]),
@@ -348,7 +352,7 @@ test('settings, reading and footer actions pass the layer id, params and user or
layer.destroy();
const body = card(f, 'wind').children[1];
assert.deepEqual(
body.children.map((n) => n.dataset.blockId),
[...body.children].map((n) => n.dataset.blockId),
['details', 'settings', 'actions', 'reading'],
);
view.destroy();
@@ -395,7 +399,7 @@ test('cyclones lead, and observed history owns one bordered group with only acti
view.update([lightning, radar, wind, cyclone, satellite]);
const root = f.container.children[0];
assert.deepEqual(
root.children[0].children.map((n) => n.dataset.cardId),
[...root.children[0].children].map((n) => n.dataset.cardId),
['weather-cyclones', 'wind'],
);
const group = f.find((n) => n.className === 'weather-observed-group');
@@ -407,7 +411,7 @@ test('cyclones lead, and observed history owns one bordered group with only acti
);
assert.equal(group.children[2], timelineHost(f));
assert.deepEqual(
group.children[3].children.map((n) => n.dataset.cardId),
[...group.children[3].children].map((n) => n.dataset.cardId),
['weather-radar', 'weather-satellite', 'weather-lightning'],
);
assert.deepEqual(opened(f), ['weather-cyclones']);
@@ -579,7 +583,7 @@ test('all five headers retain descriptor icons and disclosure state when opened
view.destroy();
});
test('a change of open card scrolls its header into view once; refreshes never move the body', () => {
test('a change of open card scrolls its header into view once; refreshes never move the body', (t) => {
const f = fixture();
const body = f.container;
const writes = [];
@@ -594,11 +598,14 @@ test('a change of open card scrolls its header into view once; refreshes never m
scrollTop = value;
},
});
body.clientHeight = 200;
Object.defineProperty(body, 'clientHeight', { value: 200 });
body.getBoundingClientRect = () => ({ top: 100, height: 200 });
const create = f.document.createElement;
t.after(() => {
f.document.createElement = create;
});
f.document.createElement = (tag) => {
const node = create(tag);
const node = create.call(f.document, tag);
node.getBoundingClientRect = function () {
return rects.get(this.dataset.cardId) || { top: 0, height: 0 };
};
@@ -666,7 +673,7 @@ test('history time keeps the hour whatever the length of the localized date', ()
view.update([wind, radar]);
const ends = f.find((n) => n.className === 'rail-timeline-endpoints');
assert.deepEqual(
ends.children.map((n) => n.textContent),
[...ends.children].map((n) => n.textContent),
['01:00 UTC', '01:05 UTC'],
locale,
);
@@ -734,7 +741,7 @@ test('air-quality readout renders its band legend, Canada coverage and worst-fir
const entries = f.find((n) => n.className === 'rail-card-legend-entries', aq);
assert.equal(entries.hidden, false);
assert.deepEqual(
entries.children.map((entry) => entry.children[1].textContent),
[...entries.children].map((entry) => entry.children[1].textContent),
['Low', 'Moderate', 'High', 'Very High'],
);
assert.equal(
@@ -744,8 +751,8 @@ test('air-quality readout renders its band legend, Canada coverage and worst-fir
const list = f.find((n) => n.dataset.blockId === 'list', aq);
assert.equal(list.hidden, false);
assert.deepEqual(
list.children.map((item) =>
item.children[0].children.map((part) => part.textContent).join(' '),
[...list.children].map((item) =>
[...item.children[0].children].map((part) => part.textContent).join(' '),
),
['AQHI 8 Hamilton · high risk', 'AQHI 2 Toronto · low risk'],
);