Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E6LjCrZ8KMcHGZyRWjkPA5
163 lines
6.4 KiB
JavaScript
163 lines
6.4 KiB
JavaScript
// Sliders.
|
|
//
|
|
// The track and thumb are drawn. v4 left them fully native with only
|
|
// accent-color, so a slider looked like the operating system rather than like
|
|
// the app, and its hit target was whatever the browser decided.
|
|
|
|
import { define } from "../internals/register.js";
|
|
import { el } from "../internals/el.js";
|
|
import { uid } from "../internals/ids.js";
|
|
|
|
const pct = (v, min, max) => (max === min ? 0 : ((v - min) / (max - min)) * 100);
|
|
|
|
function makeRange({ value, min, max, step, disabled, label, describedBy }) {
|
|
const input = el("input", {
|
|
cls: ["cx-range", "cx-focusable"],
|
|
attrs: { type: "range", min, max, step, disabled, "aria-label": label, "aria-describedby": describedBy },
|
|
});
|
|
input.value = String(value);
|
|
// The filled portion is drawn from a variable rather than a background image,
|
|
// so it follows the theme's accent without the JS knowing a colour.
|
|
let top = max;
|
|
const paint = () => input.style.setProperty("--fill", `${pct(Number(input.value), min, top)}%`);
|
|
paint();
|
|
input.addEventListener("input", paint);
|
|
return { input, paint, setMax(next) { top = next; input.max = String(next); paint(); } };
|
|
}
|
|
|
|
function sliderBase(cls, { value = 0, min = 0, max = 1, step = 0.01, onChange, onCommit, disabled, label } = {}) {
|
|
const { input, paint, setMax } = makeRange({ value, min, max, step, disabled, label });
|
|
if (onChange) input.addEventListener("input", () => onChange(Number(input.value)));
|
|
if (onCommit) input.addEventListener("change", () => onCommit(Number(input.value)));
|
|
const node = el("div", { cls, children: input });
|
|
return {
|
|
node, input, paint, setMax,
|
|
get value() { return Number(input.value); },
|
|
setValue(v) { input.value = String(v); paint(); },
|
|
destroy: () => node.remove(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The number beside a slider. Apple: a wide range wants its exact value shown and
|
|
* typeable. Typing sets the slider (clamped to its range) and reports like a drag.
|
|
*/
|
|
function valueField(base, props, format) {
|
|
const out = el("input", {
|
|
cls: ["cx-readout", "cx-focusable"],
|
|
attrs: { type: "text", inputmode: "decimal", "aria-label": props.label ? `${props.label} value` : "Value",
|
|
disabled: props.disabled },
|
|
});
|
|
out.value = format(base.value);
|
|
const apply = () => {
|
|
const typed = parseFloat(String(out.value).replace(",", "."));
|
|
if (!Number.isFinite(typed)) { out.value = format(base.value); return; }
|
|
const lo = Number(base.input.min), hi = Number(base.input.max);
|
|
const v = Math.min(hi, Math.max(lo, typed));
|
|
base.setValue(v);
|
|
out.value = format(v);
|
|
base.input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
base.input.dispatchEvent(new Event("change", { bubbles: true }));
|
|
};
|
|
out.addEventListener("change", apply);
|
|
out.addEventListener("keydown", (e) => { if (e.key === "Enter") apply(); });
|
|
return out;
|
|
}
|
|
|
|
for (const size of ["md", "sm"]) {
|
|
define("slider", size, (props = {}) => sliderBase(`cx-slider cx-slider-${size}`, props));
|
|
}
|
|
|
|
define("slider", "readout", ({ unit = "", precision = 2, ...props } = {}) => {
|
|
const base = sliderBase("cx-slider cx-slider-md cx-slider-readout", props);
|
|
const format = (v) => `${Number(v).toFixed(precision)}${unit}`;
|
|
const out = valueField(base, props, format);
|
|
base.input.addEventListener("input", () => { out.value = format(base.input.value); });
|
|
base.node.append(out);
|
|
const setValue = base.setValue;
|
|
return {
|
|
...base,
|
|
get value() { return base.value; }, // a spread would freeze the value at creation
|
|
setValue(v) { setValue(v); out.value = format(v); },
|
|
node: base.node,
|
|
};
|
|
});
|
|
|
|
/**
|
|
* A slider with named landing points. The presets are the point: most people
|
|
* want "Chill" or "Chaos", not 0.62, and the number is there for the ones who do.
|
|
*/
|
|
define("slider", "macro", ({ presets = [], unit = "", precision = 2, ...props } = {}) => {
|
|
const base = sliderBase("cx-slider cx-slider-md cx-slider-macro", props);
|
|
const format = (v) => `${Number(v).toFixed(precision)}${unit}`;
|
|
const out = valueField(base, props, format);
|
|
base.node.append(out);
|
|
|
|
const row = el("div", { cls: "cx-macro-presets" });
|
|
const buttons = presets.map((preset) => {
|
|
const b = el("button", {
|
|
cls: ["cx-btn", "cx-btn-sm", "cx-btn-ghost", "cx-focusable"],
|
|
text: preset.label,
|
|
attrs: { type: "button" },
|
|
on: { click: () => {
|
|
base.setValue(preset.value);
|
|
out.value = format(preset.value);
|
|
sync();
|
|
if (props.onChange) props.onChange(preset.value);
|
|
if (props.onCommit) props.onCommit(preset.value);
|
|
} },
|
|
});
|
|
row.append(b);
|
|
return { preset, b };
|
|
});
|
|
|
|
function sync() {
|
|
for (const { preset, b } of buttons) b.classList.toggle("cx-on", Number(base.value) === preset.value);
|
|
}
|
|
base.input.addEventListener("input", () => { out.value = format(base.input.value); sync(); });
|
|
sync();
|
|
|
|
const node = el("div", { cls: "cx-macro", children: [base.node, row] });
|
|
return {
|
|
node,
|
|
get value() { return base.value; },
|
|
setValue(v) { base.setValue(v); out.value = format(v); sync(); },
|
|
destroy: () => node.remove(),
|
|
};
|
|
});
|
|
|
|
/** Two thumbs. Trim handles and any "between x and y" setting are built on this. */
|
|
define("range", "md", ({ from = 0, to = 1, min = 0, max = 1, step = 0.01, onChange, label } = {}) => {
|
|
const lo = makeRange({ value: from, min, max, step, label: label ? `${label} from` : "from" });
|
|
const hi = makeRange({ value: to, min, max, step, label: label ? `${label} to` : "to" });
|
|
|
|
// Thumbs must not pass each other: a range whose start is after its end is a
|
|
// state every consumer would then have to defend against.
|
|
const clampPair = (moved) => {
|
|
let a = Number(lo.input.value);
|
|
let b = Number(hi.input.value);
|
|
if (a > b) {
|
|
if (moved === "lo") a = b; else b = a;
|
|
lo.input.value = String(a);
|
|
hi.input.value = String(b);
|
|
lo.paint(); hi.paint();
|
|
}
|
|
if (onChange) onChange({ from: a, to: b });
|
|
};
|
|
lo.input.addEventListener("input", () => clampPair("lo"));
|
|
hi.input.addEventListener("input", () => clampPair("hi"));
|
|
|
|
const node = el("div", { cls: "cx-range-pair", attrs: { role: "group", "aria-label": label },
|
|
children: [lo.input, hi.input] });
|
|
|
|
return {
|
|
node,
|
|
get value() { return { from: Number(lo.input.value), to: Number(hi.input.value) }; },
|
|
setValue({ from: a, to: b }) {
|
|
lo.input.value = String(a); hi.input.value = String(b);
|
|
lo.paint(); hi.paint();
|
|
},
|
|
destroy: () => node.remove(),
|
|
};
|
|
});
|