// 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(b)}`; else if (b != null) s += `${esc(b)}`; else s += ``; } 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(); } }); }