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