Files

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