diff --git a/src/tweaks/controls/interval.ts b/src/tweaks/controls/interval.ts index eb2f3cb..849b5e8 100644 --- a/src/tweaks/controls/interval.ts +++ b/src/tweaks/controls/interval.ts @@ -1,5 +1,5 @@ // ── Interval / range slider — dual-handle [lo,hi]. Lazy. -import { el, txt, clamp, roundToStep, stepPrecision, gridEnds, normalizeRange, rangeStep, overlapsText, wireHoverClass, onReady, onLive, registerControl } from "../shared.js"; +import { el, txt, clamp, roundToStep, stepPrecision, gridEnds, normalizeRange, rangeStep, defaultRange, overlapsText, wireHoverClass, onReady, onLive, registerControl } from "../shared.js"; import { dragGesture } from "../heavy.js"; import type { OnChange } from "../shared.js"; import type { Meta } from "../schema.js"; @@ -16,7 +16,13 @@ function createInterval(meta: Meta, onChange: OnChange): Control { // swaps, and a degenerate step re-infers — so markup like data-min="abc" // can't ride in as NaN ("NaN – NaN"). const t0 = +(meta.value && meta.value[0]), t1 = +(meta.value && meta.value[1]); - let { min, max, step } = normalizeRange(meta.min, meta.max, meta.step, Number.isFinite(t0) ? t0 : 0, Number.isFinite(t1) ? t1 : 1); + const lo0 = Number.isFinite(t0) ? t0 : 0, hi0 = Number.isFinite(t1) ? t1 : 1; + // An absent bound derives from the values the range must hold, as the shorthand and a verbose + // slider do: each end widens it, so [20, 80] spans 0–240, not just 20–80 (which pinned both + // handles at the ends). + const [a, b] = [defaultRange(lo0), defaultRange(hi0)]; + const given = (n: unknown) => n != null && n !== "" && Number.isFinite(+n!); + let { min, max, step } = normalizeRange(given(meta.min) ? meta.min : Math.min(a[0], b[0]), given(meta.max) ? meta.max : Math.max(a[1], b[1]), meta.step, lo0, hi0); [min, max] = gridEnds(min, max, step); // as the slider: the range is its reachable grid, so an off-grid bound never reports a value past it and every surface shares one lattice const decimals = stepPrecision(step); const q = (v: number) => roundToStep(v, min, step); diff --git a/src/tweaks/schema.ts b/src/tweaks/schema.ts index 18c3ff4..386ccd1 100644 --- a/src/tweaks/schema.ts +++ b/src/tweaks/schema.ts @@ -100,13 +100,10 @@ const restoreDefault = (ctrl: Pick, raw: unknown, def: unknown) // "segmented" is kept as an alias: picking one of a list renders as the radio grid (the // nicer-looking single-select). The inline pill is reserved for booleans. const radiogridMeta = (v: any): MetaFields => Array.isArray(v.options) && { type: "radiogrid", options: v.options, value: v.value ?? optValue(v.options[0]), cols: v.cols }; -// The colour a `{ type: "color" }` / [data-tw="color"] opens on when none is given — it -// lives on the meta (not only in the picker's own fallback) so reset() restores it rather -// than handing the control `undefined`, which parsed as black. -// The verbose range bounds (slider + interval): an absent min/max derives from the value(s) -// the range must contain, the way the bare-number shorthand does (defaultRange) — so -// `{ type: "slider", value: 50 }` spans 0–150 like `size: 50`, and an interval's ends each -// widen it. (Both used to default to 0–1 and clamp a 50 to 1.) Step defaults to the grain. +// A verbose slider's range bounds: an absent min/max derives from the value the range must +// contain, the way the bare-number shorthand does (defaultRange), so `{ type: "slider", +// value: 50 }` spans 0–150 like `size: 50`. (The interval does the same in its own +// constructor, since its verbose form passes through as given.) Step defaults to the grain. const rangeOf = (v: any, ...seeds: unknown[]) => { const ranges = (seeds.length ? seeds : [0]).map((s) => defaultRange(Number.isFinite(+s) ? +s : 0)); const min = v.min ?? Math.min(...ranges.map((r) => r[0])), max = v.max ?? Math.max(...ranges.map((r) => r[1])); diff --git a/test/schema-defaults.test.mjs b/test/schema-defaults.test.mjs index 22304d9..4a3103f 100644 --- a/test/schema-defaults.test.mjs +++ b/test/schema-defaults.test.mjs @@ -27,9 +27,14 @@ test("a verbose slider / interval without min/max derives its range from the val assert.deepEqual(p.params.i, [20, 80]); assert.equal(p.params.neg, -5); assert.equal(p.params.unit, 0.5); - const [s] = p.el.querySelectorAll("[role=slider]"); + const [s, i0, i1] = p.el.querySelectorAll("[role=slider]"); assert.equal(s.getAttribute("aria-valuemin"), "0"); assert.equal(s.getAttribute("aria-valuemax"), "150"); // the shorthand's 0–3×value + // The interval's ends each widen its range (0–240 for [20, 80]), so both handles can move out; + // a range locked to the value tuple pinned them at the ends. + for (const h of [i0, i1]) { assert.equal(h.getAttribute("aria-valuemin"), "0"); assert.equal(h.getAttribute("aria-valuemax"), "240"); } + p.set("i", [0, 200]); + assert.deepEqual(p.params.i, [0, 200]); // The markup path agrees: one meta derivation for every entry point. const host = document.createElement("div"); host.innerHTML = '
'; @@ -38,6 +43,11 @@ test("a verbose slider / interval without min/max derives its range from the val assert.equal(m.getAttribute("aria-valuenow"), "50"); assert.equal(m.getAttribute("aria-valuemax"), "150"); host.remove(); + const ih = document.createElement("div"); + ih.innerHTML = '
'; + document.body.append(ih); enhance(ih); + for (const h of ih.querySelectorAll("[role=slider]")) assert.equal(h.getAttribute("aria-valuemax"), "240"); + ih.remove(); }); test("an explicit min/max still wins over the derived range", () => {