Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 40 additions & 1 deletion site/pages/numbers.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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. <code>soft: true</code> lets a typed or scripted value run past
<code>min</code>/<code>max</code>; dragging stays within them. <code>unit: "px"</code>
puts a unit after the readout; the param stays the bare number.</p>`,
puts a unit after the readout; the param stays the bare number. For a readout of
your own, see the next example.</p>`,
target: `<div class="num-tile"></div>`,
css: `
.num-tile { width: 132px; height: 132px; background: #7C5CFF;
Expand All @@ -44,6 +45,44 @@ export const examples = [
apply(panel.params);
},
},
{
id: "slider-format",
title: "Formatted readout",
prose: `<p><code>format</code> draws a slider's readout from its value, in place of its
digits, and <code>parse</code> 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 <code>unit</code>, it is display only: the param stays the number. A
format that throws falls back to the digits. When <code>parse</code> can't read the
text, have it return <code>NaN</code>, and the slider keeps its value.</p>`,
target: `<div class="num-clock">7:30 AM</div>`,
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",
Expand Down
2 changes: 1 addition & 1 deletion src/tweaks.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
31 changes: 27 additions & 4 deletions src/tweaks/controls/basic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -72,16 +77,21 @@ 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);
// Value-dodge: the handle yields only while it actually overlaps the label (left) or
// 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));
}
Expand Down Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion src/tweaks/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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<Record<SchemaObject["type"], (v: any, depth: number) => 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,
Expand Down
9 changes: 8 additions & 1 deletion src/tweaks/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
8 changes: 8 additions & 0 deletions test/docs-snapshot.json
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,14 @@
"ui": {}
}
],
"numbers.mjs › slider-format": [
{
"values": {
"time": 450
},
"ui": {}
}
],
"numbers.mjs › number": [
{
"values": {
Expand Down
112 changes: 112 additions & 0 deletions test/slider-format.test.mjs
Original file line number Diff line number Diff line change
@@ -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();
});
Loading