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