From 05106df47bdb7bbf108b459ec15d3ca09cf86f5a Mon Sep 17 00:00:00 2001 From: Ryan Kiley Date: Sun, 11 Oct 2026 17:18:50 -0700 Subject: [PATCH] Let a slider format its readout A slider takes format, which draws its readout from the value in place of its digits (a clock for minutes of the day), and parse, which reads typed text back, so the inline editor shows and takes the same text. Display only, like unit. A format that fails falls back to the digits, only a finite number from parse commits, and an editor left untouched commits nothing; without a parse, typed text must all be a number (less the unit), so "1,000" no longer commits 1. The editor sizes to its text, and the label to the room the readout leaves. The Numbers page has an example, and test/slider-format covers it. --- site/pages/numbers.mjs | 41 ++++++++++++- src/tweaks.css | 2 +- src/tweaks/controls/basic.ts | 31 ++++++++-- src/tweaks/schema.ts | 5 +- src/tweaks/types.ts | 9 ++- test/docs-snapshot.json | 8 +++ test/slider-format.test.mjs | 112 +++++++++++++++++++++++++++++++++++ 7 files changed, 200 insertions(+), 8 deletions(-) create mode 100644 test/slider-format.test.mjs diff --git a/site/pages/numbers.mjs b/site/pages/numbers.mjs index f712845..c26185c 100644 --- a/site/pages/numbers.mjs +++ b/site/pages/numbers.mjs @@ -21,7 +21,8 @@ export const examples = [ or press Enter on the focused track. With six or fewer stops the track snaps and shows rule lines. soft: true lets a typed or scripted value run past min/max; dragging stays within them. unit: "px" - puts a unit after the readout; the param stays the bare number.

`, + puts a unit after the readout; the param stays the bare number. For a readout of + your own, see the next example.

`, target: `
`, css: ` .num-tile { width: 132px; height: 132px; background: #7C5CFF; @@ -44,6 +45,44 @@ export const examples = [ apply(panel.params); }, }, + { + id: "slider-format", + title: "Formatted readout", + prose: `

format draws a slider's readout from its value, in place of its + digits, and parse reads a typed readout back, so the inline editor shows + and takes the same text. Here the value is minutes of the day and the readout is a + clock. Like unit, it is display only: the param stays the number. A + format that throws falls back to the digits. When parse can't read the + text, have it return NaN, and the slider keeps its value.

`, + target: `
7:30 AM
`, + css: ` + .num-clock { display: grid; place-items: center; width: 200px; height: 120px; border-radius: 18px; + background: #7C5CFF; color: #fff; font-weight: 600; font-size: 28px; + font-variant-numeric: tabular-nums; box-shadow: 0 14px 44px var(--demo-shadow); }`, + run: ({ tweaks, mount, target }) => { + const face = target.querySelector(".num-clock"); + // Minutes of the day as a clock, and a typed clock back to minutes. Text that + // isn't a clock like the readout's is NaN, which leaves the slider where it was. + const clock = (m) => { + const h = Math.floor(m / 60) % 24; + return (h % 12 || 12) + ":" + String(m % 60).padStart(2, "0") + (h < 12 ? " AM" : " PM"); + }; + const unclock = (text) => { + const [hm, ap] = text.trim().toUpperCase().split(" "); + const [h, m] = hm.split(":").map(Number); + if (!(h >= 1 && h <= 12 && m >= 0 && m < 60) || (ap !== "AM" && ap !== "PM")) return NaN; + return ((h % 12) + (ap === "PM" ? 12 : 0)) * 60 + m; + }; + const panel = tweaks("Formatted", { + time: { type: "slider", value: 450, min: 0, max: 1425, step: 15, format: clock, parse: unclock }, + }); + mount.append(panel.el); + + const apply = (p) => { face.textContent = clock(p.time); }; + panel.on(apply); + apply(panel.params); + }, + }, { id: "number", title: "Number", diff --git a/src/tweaks.css b/src/tweaks.css index 48464e1..8751948 100644 --- a/src/tweaks.css +++ b/src/tweaks.css @@ -425,7 +425,7 @@ .tw-slider-value.is-editable { cursor: text; color: var(--tw-text-focus); text-decoration: underline; text-decoration-color: var(--tw-text-tertiary); text-underline-offset: 3px; } .tw-slider-input { position: absolute; right: 10px; top: 50%; transform: translateY(-50%); - width: 4ch; min-width: 3ch; max-width: 6ch; + min-width: 4ch; max-width: 60%; /* the editor sizes its width to the text it opens on */ font-size: 12px; font-weight: 500; font-family: var(--tw-font-sans); color: var(--tw-text-label); background: transparent; border: none; padding: 0; outline: none; text-align: right; diff --git a/src/tweaks/controls/basic.ts b/src/tweaks/controls/basic.ts index 570dcbf..8988065 100644 --- a/src/tweaks/controls/basic.ts +++ b/src/tweaks/controls/basic.ts @@ -44,6 +44,11 @@ function createSlider(meta: Meta, onChange: OnChange): Control { const unit = typeof meta.unit === "string" ? meta.unit.trim() : "", numText = document.createTextNode(""); const joined = !unit ? "" : /^[%°]$/.test(unit) ? unit : " " + unit; // "50%", "90°", but "24 px", "300 ms" valueEl.append(numText); if (unit) valueEl.append(txt("span", "tw-slider-unit", joined)); + // `format` draws the readout from the value in place of its digits (and `parse`, in the + // editor below, reads a typed one back). A format that throws or returns anything but a + // non-empty string falls back to the digits, so a bad formatter can't blank the slider. + const format = meta.format, parse = meta.parse; + const show = (v: number) => { if (format) { try { const s = format(v); if (typeof s === "string" && s) return s; } catch {} } return v.toFixed(decimals); }; track.append(hashes, fill, handle, labelEl, valueEl); wrap.append(track); @@ -72,7 +77,7 @@ function createSlider(meta: Meta, onChange: OnChange): Control { const edge = `${pct}%${off}`; fill.style.width = pull ? `calc(${edge})` : pct + "%"; handle.style.left = `max(5px, calc(${edge} - 9px))`; // the inset hairline rides just inside the fill edge - const qv = q(value), qvText = qv.toFixed(decimals); // q(value) is pure — compute once (render() runs every drag frame) + const qv = q(value), qvText = show(qv); // q(value) is pure — compute once (render() runs every drag frame) numText.data = qvText; track.setAttribute("aria-valuenow", String(qv)); track.setAttribute("aria-valuetext", qvText + joined); @@ -80,8 +85,13 @@ function createSlider(meta: Meta, onChange: OnChange): Control { // value (right) text — its real pixel span (it renders at pct% − 9px, 3px wide) tested // against each text's live-measured edge (overlapsText, shared with the interval), so it // dims right as it reaches the number and re-shows the instant it clears. - const trackW = wrap.offsetWidth; + const trackW = wrap.offsetWidth, valueW = valueEl.offsetWidth; if (trackW) { + // The label's room is what the readout leaves, measured as the interval does: a format + // can make the readout long. 12px label inset + 8px gap + the readout's 10px inset. The + // stylesheet's fixed reserve covers the first paint, and while the editor has the + // readout out of the page the last room stands. + if (valueW) labelEl.style.maxWidth = Math.max(0, trackW - valueW - 30) + "px"; const hx = Math.max(5, (pct / 100) * trackW + pull - 9); track.classList.toggle("is-dodge", overlapsText(labelEl, valueEl, hx, 3)); } @@ -253,10 +263,23 @@ function createSlider(meta: Meta, onChange: OnChange): Control { // and leaves focus where the user sent it. const openEditor = () => { if (valueEl.classList.contains("is-editing")) return; - const input = el("input", "tw-slider-input"); input.type = "text"; input.inputMode = "decimal"; quietFocus(input); /* click-to-type stays ringless; Enter from the track rings it */ input.value = q(value).toFixed(decimals); + const input = el("input", "tw-slider-input"); input.type = "text"; input.inputMode = parse ? "text" : "decimal"; quietFocus(input); /* click-to-type stays ringless; Enter from the track rings it */ + input.value = parse ? show(q(value)) : q(value).toFixed(decimals); // with a parse, the field edits the formatted text; with only a format, the digits + const opened = input.value; input.style.width = opened.length + 1 + "ch"; // read back (a text field drops line breaks), and sized to its text, which a format can make long valueEl.classList.add("is-editing"); valueEl.replaceWith(input); input.focus(); input.select(); const restore = () => { input.replaceWith(valueEl); valueEl.classList.remove("is-editing", "is-editable"); }; - const commit = () => { const p = parseFloat(input.value); if (!isNaN(p)) set(meta.soft ? p : clamp(p, min, max)); restore(); }; + // Typed text is read by `parse`, or else strictly, all of it a number (the unit dropped, + // so "24px" reads 24; blank is no number): not parseFloat, which reads "1,000" as 1 and + // "7:30" as 7. Only a finite number commits: a parse that throws or can't read the text + // leaves the value where it was. So does text left as it opened, or a format coarser + // than the step (whole numbers on a 0.1 step) would move the value just by opening and + // closing the editor. + const commit = () => { + const t = input.value; let p = NaN; + if (t !== opened) try { p = parse ? parse(t) : Number(t.replace(unit, "").trim() || NaN); } catch {} + if (Number.isFinite(p)) set(meta.soft ? p : clamp(p, min, max)); + restore(); + }; input.addEventListener("pointerdown", (e) => e.stopPropagation()); input.addEventListener("blur", commit); // Both key paths move focus to the track BEFORE the input leaves the DOM (with the diff --git a/src/tweaks/schema.ts b/src/tweaks/schema.ts index 386ccd1..134497c 100644 --- a/src/tweaks/schema.ts +++ b/src/tweaks/schema.ts @@ -25,6 +25,7 @@ interface Meta { render?: (get: Get) => boolean; disabled?: boolean | ((get: Get) => boolean); hint?: string; // Ranges — slider, number, interval, monitor. min?: number; max?: number; step?: number; soft?: boolean; unit?: string; corners?: boolean; // unit: slider / number, display only; corners: the sides control labels its four as corners + format?: (value: number) => string; parse?: (text: string) => number; // slider: its readout drawn from the value, and a typed one read back // Single-select — list, radiogrid. options?: Option[]; cols?: number; // Text. @@ -112,6 +113,8 @@ const rangeOf = (v: any, ...seeds: unknown[]) => { // A verbose form's own `label` wins over the title-cased key — `??` semantics, so an // explicit "" (= no label) survives where `||` fell back to the key. const ownLabel = (v: any, label: string) => (v.label == null ? label : String(v.label)); +// A function option (a slider's format and parse) is kept only if it is one. +const fnOf = (f: unknown) => (typeof f === "function" ? (f as any) : undefined); // A unit is a short string shown after the value ("px", "ms", "%"); anything else is dropped. const unitOf = (u: unknown) => (typeof u === "string" && u.trim() ? u.trim().slice(0, 12) : undefined); // Typed against the public SchemaObject union, so tsc itself flags a control type @@ -121,7 +124,7 @@ const unitOf = (u: unknown) => (typeof u === "string" && u.trim() ? u.trim().sli // inspected as metaFor's `value` is.) const SPRING_KEYS = ["stiffness", "damping", "mass", "visualDuration", "bounce"]; // a spring's fields, as the spring and motion controls take them const TYPED_META: Partial MetaFields>> = { - slider: (v) => { const r = rangeOf(v, v.value ?? v.min ?? 0); return { value: v.value ?? r.min, ...r, soft: v.soft, unit: unitOf(v.unit) }; }, + slider: (v) => { const r = rangeOf(v, v.value ?? v.min ?? 0); return { value: v.value ?? r.min, ...r, soft: v.soft, unit: unitOf(v.unit), format: fnOf(v.format), parse: fnOf(v.parse) }; }, number: (v) => ({ value: v.value ?? 0, min: v.min, max: v.max, step: v.step ?? 1, soft: v.soft, unit: unitOf(v.unit) }), checkbox: (v) => ({ value: !!v.value }), radiogrid: radiogridMeta, diff --git a/src/tweaks/types.ts b/src/tweaks/types.ts index 1a32777..0fad2d7 100644 --- a/src/tweaks/types.ts +++ b/src/tweaks/types.ts @@ -71,7 +71,14 @@ export interface ControlOptions { /** The verbose object forms a schema value can take: `{ type: "…", … }`. */ export type SchemaObject = - | { type: "slider"; value?: number; min?: number; max?: number; step?: number; soft?: boolean; unit?: string } + | { type: "slider"; value?: number; min?: number; max?: number; step?: number; soft?: boolean; unit?: string; + /** Draws the readout from the value ("7:30 AM" for minutes of the day), in place of its + * digits; `unit` still follows it. Display only, like `unit`: the param stays the number. */ + format?: (value: number) => string; + /** Reads a typed readout back into a value, so the inline editor shows and takes the + * formatted text (without one, it edits the digits). Text it can't read (it throws, or + * returns anything but a finite number) leaves the value where it was. */ + parse?: (text: string) => number } | { type: "number"; value?: number; min?: number; max?: number; step?: number; soft?: boolean; unit?: string } | { type: "checkbox"; value?: boolean } | { type: "list"; options: Option[]; value?: string } diff --git a/test/docs-snapshot.json b/test/docs-snapshot.json index ba5dc6a..dfacc2d 100644 --- a/test/docs-snapshot.json +++ b/test/docs-snapshot.json @@ -264,6 +264,14 @@ "ui": {} } ], + "numbers.mjs › slider-format": [ + { + "values": { + "time": 450 + }, + "ui": {} + } + ], "numbers.mjs › number": [ { "values": { diff --git a/test/slider-format.test.mjs b/test/slider-format.test.mjs new file mode 100644 index 0000000..ca47875 --- /dev/null +++ b/test/slider-format.test.mjs @@ -0,0 +1,112 @@ +/* A slider's `format` and `parse`: the readout drawn from the value and a typed one read + * back, display only (the param stays the number). A format that fails falls back to the + * digits, and text the parse can't read leaves the value where it was. Runs against the + * built single-file bundle under jsdom. */ +import test from "node:test"; +import assert from "node:assert/strict"; +import "./_setup-dom.mjs"; + +const { tweaks } = await import(new URL("../dist/tweaks.js", import.meta.url)); + +// Minutes of the day as a clock, and back. +const clock = (m) => { const h = Math.floor(m / 60) % 24; return `${h % 12 || 12}:${String(m % 60).padStart(2, "0")} ${h < 12 ? "AM" : "PM"}`; }; +const unclock = (s) => { const [hm, ap] = s.trim().toUpperCase().split(/\s+/); const [h, m = "0"] = hm.split(":"); return ((+h % 12) + (ap === "PM" ? 12 : 0)) * 60 + +m; }; + +const build = async (opts) => { + const p = tweaks("Format", { time: { type: "slider", value: 450, min: 0, max: 1425, step: 15, ...opts } }); + document.body.append(p.el); await p.ready; + const track = p.el.querySelector(".tw-slider"), readout = () => p.el.querySelector(".tw-slider-value").textContent; + return { p, track, readout }; +}; +const typeIn = (track, text) => { + track.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + const input = track.querySelector(".tw-slider-input"); + const opened = { shown: input.value, mode: input.inputMode, width: input.style.width }; + input.value = text; + input.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + return opened; +}; + +test("format draws the readout and its aria-valuetext; the param stays the number", async () => { + const { p, track, readout } = await build({ format: clock, parse: unclock }); + assert.equal(readout(), "7:30 AM"); + assert.equal(track.getAttribute("aria-valuetext"), "7:30 AM"); + assert.equal(track.getAttribute("aria-valuenow"), "450"); + assert.equal(p.params.time, 450); + p.set("time", 1147); + assert.equal(readout(), "7:00 PM", "format sees the stepped value, as the param does"); + p.destroy(); +}); + +test("with parse, the editor shows the formatted text and reads a typed one back", async () => { + const { p, track, readout } = await build({ format: clock, parse: unclock }); + assert.deepEqual(typeIn(track, "9:15 am"), { shown: "7:30 AM", mode: "text", width: "8ch" }, "the field opens on the readout, sized to it"); + assert.equal(p.params.time, 555); + assert.equal(readout(), "9:15 AM"); + p.destroy(); +}); + +test("with only format, the editor edits the digits", async () => { + const { p, track, readout } = await build({ format: clock }); + assert.deepEqual(typeIn(track, "600"), { shown: "450", mode: "decimal", width: "4ch" }); + assert.equal(p.params.time, 600); + assert.equal(readout(), "10:00 AM"); + p.destroy(); +}); + +test("a format that throws, or returns anything but a non-empty string, falls back to the digits", async () => { + for (const format of [() => { throw new Error("no"); }, () => 12, () => ""]) { + const { p, track, readout } = await build({ format }); + assert.equal(readout(), "450", `format ${format}`); + assert.equal(track.getAttribute("aria-valuetext"), "450"); + p.destroy(); + } +}); + +test("text the parse can't read leaves the value where it was", async () => { + // A parse's answer is final: a throw, NaN, Infinity, a null "can't tell" or a numeric + // string is not a value, and the typed text is not read as a plain number instead. + for (const parse of [() => { throw new Error("no"); }, () => NaN, () => Infinity, () => null, () => "300"]) { + const { p, track, readout } = await build({ format: clock, parse }); + typeIn(track, "300"); + assert.equal(p.params.time, 450, `parse ${parse}`); + assert.equal(readout(), "7:30 AM"); + p.destroy(); + } +}); + +test("opening the editor and leaving it untouched doesn't move the value", async () => { + // A format coarser than the step: 450 minutes reads "8 h", which parses back to 480. + const { p, track } = await build({ format: (m) => Math.round(m / 60) + " h", parse: (s) => parseFloat(s) * 60 }); + assert.equal(typeIn(track, "8 h").shown, "8 h"); + assert.equal(p.params.time, 450); + p.destroy(); +}); + +test("a format that isn't a function is ignored", async () => { + const { p, track, readout } = await build({ format: "h:mm", parse: 3 }); + assert.equal(readout(), "450"); + assert.deepEqual(typeIn(track, "600"), { shown: "450", mode: "decimal", width: "4ch" }); + assert.equal(p.params.time, 600); + p.destroy(); +}); + +test("without a parse, typed text must all be a number; the unit is dropped", async () => { + const { p, track } = await build({ unit: "min" }); + for (const text of ["1,000", "7:30", "12abc", "", " "]) { + typeIn(track, text); + assert.equal(p.params.time, 450, JSON.stringify(text)); + } + typeIn(track, " 615 min "); + assert.equal(p.params.time, 615); + typeIn(track, "600"); + assert.equal(p.params.time, 600); + p.destroy(); +}); + +test("a unit still follows a formatted readout", async () => { + const { p, track, readout } = await build({ format: (v) => v.toLocaleString("en-US"), unit: "ms", value: 1500, max: 3000, step: 100 }); + assert.equal(readout(), "1,500 ms"); + assert.equal(track.getAttribute("aria-valuetext"), "1,500 ms"); + p.destroy(); +});