// UI primitives for v2: segmented controls, thin sliders, switches, a searchable multi-picker, numbers that count to
// their new value, and "i" popovers. Plain DOM, no framework.
export const REDUCED = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
export const $ = (s, el = document) => el.querySelector(s);
export const el = (tag, attrs = {}, html = "") => { const e = document.createElement(tag); Object.assign(e, attrs); if (html) e.innerHTML = html; return e; };
// segmented control: one of several options, with a sliding thumb
export function segmented(parent, options, value, onChange, { label = "", id = "", small = false } = {}) {
const wrap = el("div", { className: `seg${small ? " small" : ""}`, id });
wrap.setAttribute("role", "radiogroup");
if (label) wrap.setAttribute("aria-label", label);
const thumb = el("span", { className: "seg-thumb" });
wrap.append(thumb);
const btns = options.map((o) => {
const [text, val] = Array.isArray(o) ? o : [o, o];
const b = el("button", { type: "button", className: "seg-btn", textContent: text });
b.dataset.value = val;
b.setAttribute("role", "radio");
b.addEventListener("click", () => { if (b.disabled) return; set(val); onChange(val); });
wrap.append(b);
return b;
});
parent.append(wrap);
const place = () => {
const on = btns.find((b) => b.getAttribute("aria-checked") === "true");
if (!on || !on.offsetWidth) { thumb.style.opacity = "0"; return; }
thumb.style.opacity = "1";
thumb.style.width = `${on.offsetWidth}px`;
thumb.style.transform = `translateX(${on.offsetLeft - 3}px)`;
};
function set(val) {
btns.forEach((b) => b.setAttribute("aria-checked", String(b.dataset.value === String(val))));
requestAnimationFrame(place);
}
set(value);
new ResizeObserver(place).observe(wrap);
return { set, el: wrap, btns, disable: (vals) => btns.forEach((b) => { b.disabled = vals.includes(b.dataset.value); }) };
}
// slider: label (with an optional "i" popover, `info`: plain text), value read-out (editable), a thin track with a round knob
export function slider(parent, { label, info: tip, min, max, step, value, unit = "", id, onInput, onChange, fmt }) {
const wrap = el("div", { className: "sl" });
const head = el("div", { className: "sl-head" });
const lab = el("label", { htmlFor: id, textContent: label });
const name = el("span", { className: "sl-lab" });
name.append(lab);
if (tip) { const b = info(tip); b.setAttribute("aria-label", `About ${label}`); name.append(b); }
const num = el("input", { type: "number", min, max, step, value, className: "sl-num" });
num.setAttribute("aria-label", label);
const rng = el("input", { type: "range", min, max, step, value, id });
head.append(name, el("span", { className: "sl-val" }));
head.lastChild.append(num, unit ? el("span", { className: "sl-unit", textContent: unit }) : "");
wrap.append(head, rng);
parent.append(wrap);
const f = fmt || ((v) => String(+(+v).toFixed(4)));
const fill = () => rng.style.setProperty("--p", `${((rng.value - min) / (max - min)) * 100}%`);
fill();
// the value is kept exactly: a range input snaps what it is given to its step (a real day's load 0.9461 would read 0.95)
let cur = +value;
rng.addEventListener("input", () => { cur = +rng.value; num.value = f(cur); fill(); onInput && onInput(cur); });
rng.addEventListener("change", () => onChange && onChange(cur));
num.addEventListener("change", () => { const v = Math.min(max, Math.max(min, +num.value || 0)); cur = v; rng.value = v; num.value = f(v); fill(); onInput && onInput(v); onChange && onChange(v); });
return { get: () => cur, set: (v) => { cur = +v; rng.value = v; num.value = f(v); fill(); }, el: wrap, range: rng, num };
}
// switch: an on/off toggle with a label
export function toggle(parent, { label, checked = false, id, onChange }) {
const lab = el("label", { className: "sw" });
const inp = el("input", { type: "checkbox", checked, id });
inp.setAttribute("role", "switch");
lab.append(inp, el("span", { className: "sw-track" }), el("span", { className: "sw-label", textContent: label }));
inp.addEventListener("change", () => onChange && onChange(inp.checked));
parent.append(lab);
return { get: () => inp.checked, set: (v) => { inp.checked = v; }, input: inp };
}
// searchable multi-select: type to filter, pick a match, x on a chip to remove it
export function picker(parent, { label, info, options, onChange, id }) {
const wrap = el("div", { className: "pk" });
wrap.append(el("label", { className: "pk-label", htmlFor: id, textContent: label }));
if (info) wrap.append(el("div", { className: "pk-info", textContent: info }));
const inp = el("input", { type: "search", id, placeholder: "Search by number, bus or fuel", autocomplete: "off" });
const list = el("div", { className: "pk-list", hidden: true });
list.setAttribute("role", "listbox");
const chips = el("div", { className: "chips" });
wrap.append(inp, list, chips);
parent.append(wrap);
let sel = [];
const render = () => {
chips.innerHTML = "";
for (const v of sel) {
const c = el("span", { className: "chip" }, options.find((o) => o[1] === v)[0].split(":")[0]);
const x = el("button", { type: "button", textContent: "×" });
x.setAttribute("aria-label", "remove");
x.addEventListener("click", () => { sel = sel.filter((s) => s !== v); render(); onChange(sel); });
c.append(x); chips.append(c);
}
};
inp.addEventListener("input", () => {
const q = inp.value.trim().toLowerCase();
list.innerHTML = "";
if (!q) { list.hidden = true; return; }
const hits = options.filter((o) => o[0].toLowerCase().includes(q) && !sel.includes(o[1])).slice(0, 30);
for (const [t, v] of hits) {
const o = el("button", { type: "button", className: "pk-opt", textContent: t });
o.dataset.value = v;
o.setAttribute("role", "option");
o.addEventListener("click", () => { sel = [...sel, v].sort((a, b) => a - b); render(); onChange(sel); inp.value = ""; list.hidden = true; });
list.append(o);
}
list.hidden = !hits.length;
});
return { get: () => sel, set: (v) => { sel = [...v]; render(); } };
}
// ---------------------------------------------------------------- rolling numbers (Apple's numericText)
// A number changes by sliding each changed digit straight from its old value to its new one, up if the value rose and
// down if it fell; unchanged digits stay still, and no value in between is ever shown (a count-up shows numbers that were
// never computed). Only the text runs of fmt's HTML move; its elements (/h) stay static, and so do
// separators that only swap places. Old and new runs align from the right; a digit that appears or disappears slides
// and fades in or out while its column grows or shrinks. A new value mid-roll starts from the last one. The first value fades in; with reduced motion the
// final text is set at once. Every glyph sits in a column one line tall, so the width does not move while digits roll;
// the outgoing glyph is aria-hidden. data-v keeps the value and data-text the final text (what tests read).
const ROLL_MS = 350;
const isDigit = (c) => c != null && c >= "0" && c <= "9";
const esc = (c) => ({ "<": "<", ">": ">", "&": "&" }[c] || c);
function valueParts(html) {
const t = document.createElement("template");
t.innerHTML = html;
return [...t.content.childNodes].map((n) => (n.nodeType === Node.TEXT_NODE ? { text: n.textContent } : { text: null, el: n.outerHTML || "", plain: n.textContent }));
}
// one text run as glyph columns; old (that run's previous text) aligned from the right, or null for no roll
function glyphRun(nw, old, how, fade) {
if (old == null) return [...nw].map((c) => `${esc(c)}`).join("");
const pre = nw.match(/^\D*/)[0]; // a shared prefix ("$") stays put; the digits align after it
if (pre && pre !== nw && old.match(/^\D*/)[0] === pre) return glyphRun(pre, pre, how) + glyphRun(nw.slice(pre.length), old.slice(pre.length), how);
const n = Math.max(nw.length, old.length);
let s = "";
for (let i = 0; i < n; i++) {
const a = i >= n - old.length ? old[i - (n - old.length)] : null, b = i >= n - nw.length ? nw[i - (n - nw.length)] : null;
if (a === b || (a != null && b != null && !isDigit(a) && !isDigit(b))) s += `${esc(b)}`;
else if (a != null && b != null) s += `${esc(a)}${esc(b)}`;
else if (b != null) s += `${esc(b)}`;
else s += `${esc(a)}`;
}
return s;
}
function paintValue(node, P, old, how, fade) {
let k = 0;
node.innerHTML = `${P.map((p) => (p.text != null ? glyphRun(p.text, old ? old[k++] : null, how, fade) : p.el)).join("")}`;
// a column that appears or goes animates to its glyph's own width, so it opens and closes in step with the roll
for (const g of node.querySelectorAll(".g.in, .g.gone")) g.style.setProperty("--w", `${g.firstElementChild.getBoundingClientRect().width}px`);
}
export function countTo(node, to, fmt) {
if (!node) return;
const P = valueParts(fmt(to)), texts = P.filter((p) => p.text != null).map((p) => p.text);
const final = P.map((p) => (p.text != null ? p.text : p.plain)).join("");
const prev = node._roll && node.firstElementChild && node.firstElementChild.classList.contains("rl") ? node._roll : null;
node.dataset.v = to;
node.dataset.text = final;
clearTimeout(node._rollT);
if (prev && prev.final === final) { // the same text: settle a roll in flight, else nothing to do
if (prev.rolling) { paintValue(node, P, null, "", false); prev.rolling = false; }
prev.v = to;
return;
}
const roll = !REDUCED && !!prev && prev.texts.length === texts.length && Number.isFinite(prev.v) && Number.isFinite(to);
node._roll = { v: to, texts, final, rolling: roll };
if (!roll) { paintValue(node, P, null, "", !REDUCED); return; }
paintValue(node, P, prev.texts, to > prev.v ? "up" : to < prev.v ? "down" : "swap", false);
node._rollT = setTimeout(() => { paintValue(node, P, null, "", false); node._roll.rolling = false; }, ROLL_MS + 30);
}
// an "i" button that shows a short explanation in a popover
export function info(text) {
const b = el("button", { type: "button", className: "i-btn", textContent: "i" });
b.setAttribute("aria-label", "more information");
b.dataset.tip = text;
return b;
}
// the popover of an "i" button: on tap or click, and on hover where there is a mouse
let POP = null, POP_BY = null;
function hidePop() { if (POP) { POP.remove(); POP = null; POP_BY = null; } }
function showPop(b) {
hidePop();
POP = el("div", { className: "pop", textContent: b.dataset.tip });
POP_BY = b;
document.body.append(POP);
const r = b.getBoundingClientRect(), w = Math.min(300, window.innerWidth - 24);
POP.style.width = `${w}px`;
POP.style.left = `${Math.max(12, Math.min(window.innerWidth - w - 12, r.left + r.width / 2 - w / 2))}px`;
POP.style.top = `${r.bottom + window.scrollY + 8}px`;
}
document.addEventListener("click", (e) => {
const b = e.target.closest && e.target.closest(".i-btn");
if (!b) { hidePop(); return; }
e.stopPropagation();
if (POP_BY === b && e.detail > 0 && b.dataset.hovered === "1") { b.dataset.hovered = ""; return; } // already open from hover
if (POP_BY === b) hidePop(); else showPop(b);
});
const HOVER = window.matchMedia && window.matchMedia("(hover: hover)").matches;
if (HOVER) {
document.addEventListener("mouseover", (e) => { const b = e.target.closest && e.target.closest(".i-btn"); if (b && POP_BY !== b) { showPop(b); b.dataset.hovered = "1"; } });
document.addEventListener("mouseout", (e) => { const b = e.target.closest && e.target.closest(".i-btn"); if (b && POP_BY === b && !b.contains(e.relatedTarget)) { b.dataset.hovered = ""; hidePop(); } });
}