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

+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);
},
};
}