Download controls_v2.js from cardef/grid-lab: direct link, hf CLI and curl.
- Browser
- Download file 12.2 kB
-
https://huggingface.co/spaces/cardef/grid-lab/resolve/main/controls_v2.js
- Command line
-
hf download hf://spaces/cardef/grid-lab/controls_v2.js
-
curl -L -o controls_v2.js https://huggingface.co/spaces/cardef/grid-lab/resolve/main/controls_v2.js
12.2 kB
| // 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 (<small>/h</small>) 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) => `<span class="g${fade ? " fade" : ""}">${esc(c)}</span>`).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 += `<span class="g">${esc(b)}</span>`; | |
| else if (a != null && b != null) s += `<span class="g roll ${how}"><i class="o" aria-hidden="true">${esc(a)}</i><i class="n">${esc(b)}</i></span>`; | |
| else if (b != null) s += `<span class="g roll ${how} in"><i class="n">${esc(b)}</i></span>`; | |
| else s += `<span class="g roll ${how} gone" aria-hidden="true"><i class="o">${esc(a)}</i></span>`; | |
| } | |
| return s; | |
| } | |
| function paintValue(node, P, old, how, fade) { | |
| let k = 0; | |
| node.innerHTML = `<span class="rl">${P.map((p) => (p.text != null ? glyphRun(p.text, old ? old[k++] : null, how, fade) : p.el)).join("")}</span>`; | |
| // 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(); } }); | |
| } | |