ui kit: slider value is typeable, selected states carry more than colour

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E6LjCrZ8KMcHGZyRWjkPA5
This commit is contained in:
Claude
2026-10-22 13:04:39 +00:00
parent dc99611b49
commit a8c8d4d5ac
6 changed files with 62 additions and 11 deletions
+2
View File
@@ -84,3 +84,5 @@
color: var(--on-teal); border-color: var(--teal); font-weight: var(--fw-semibold);
}
.cx-btn-render:hover:not(:disabled) { filter: brightness(1.08); }
.cx-btn.cx-on, .cx-rail-item.cx-on { font-weight: var(--fw-semibold); }
+2
View File
@@ -132,3 +132,5 @@
inline-size: 10ch;
font-family: var(--font-mono);
}
.cx-segment.cx-on { font-weight: var(--fw-semibold); }
+5
View File
@@ -102,3 +102,8 @@
border: 0; background: transparent; padding: 0;
}
.cx-filter-full .cx-filter-row { padding: var(--sp-3) var(--sp-3); }
/* Selected is never colour alone (Apple HIG, Color): a picker row also carries a
check mark, and the label is bolder. Navigation lists (the full-pane variant)
keep the highlight only, as a sidebar does. */
.cx-filter-row.cx-on:not(.cx-filter-full *)::after { content: "✓"; margin-inline-start: auto; font-weight: var(--fw-bold); }
+5 -2
View File
@@ -36,9 +36,12 @@
.cx-readout {
font-family: var(--font-mono); font-variant-numeric: tabular-nums;
font-size: var(--fs-6); color: var(--muted);
min-width: 4ch; text-align: right;
font-size: var(--fs-5); color: var(--text);
flex: none; width: 8ch; min-width: 8ch; height: var(--ctl-h-md); padding: 0 var(--sp-3);
text-align: right; background: var(--elev-2);
border: var(--bw) solid var(--line); border-radius: var(--ctl-r);
}
.cx-readout:hover:not(:disabled) { border-color: var(--line-hover); }
.cx-macro { display: flex; flex-direction: column; gap: var(--sp-3); width: 100%; }
.cx-macro-presets { display: flex; gap: var(--sp-2); flex-wrap: wrap; }
+34 -7
View File
@@ -38,6 +38,32 @@ function sliderBase(cls, { value = 0, min = 0, max = 1, step = 0.01, onChange, o
};
}
/**
* 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));
}
@@ -45,13 +71,14 @@ for (const size of ["md", "sm"]) {
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 = el("output", { cls: "cx-readout", text: format(base.value) });
base.input.addEventListener("input", () => { out.textContent = format(base.input.value); });
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,
setValue(v) { setValue(v); out.textContent = format(v); },
get value() { return base.value; }, // a spread would freeze the value at creation
setValue(v) { setValue(v); out.value = format(v); },
node: base.node,
};
});
@@ -63,7 +90,7 @@ define("slider", "readout", ({ unit = "", precision = 2, ...props } = {}) => {
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 = el("output", { cls: "cx-readout", text: format(base.value) });
const out = valueField(base, props, format);
base.node.append(out);
const row = el("div", { cls: "cx-macro-presets" });
@@ -74,7 +101,7 @@ define("slider", "macro", ({ presets = [], unit = "", precision = 2, ...props }
attrs: { type: "button" },
on: { click: () => {
base.setValue(preset.value);
out.textContent = format(preset.value);
out.value = format(preset.value);
sync();
if (props.onChange) props.onChange(preset.value);
if (props.onCommit) props.onCommit(preset.value);
@@ -87,14 +114,14 @@ define("slider", "macro", ({ presets = [], unit = "", precision = 2, ...props }
function sync() {
for (const { preset, b } of buttons) b.classList.toggle("cx-on", Number(base.value) === preset.value);
}
base.input.addEventListener("input", () => { out.textContent = format(base.input.value); sync(); });
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.textContent = format(v); sync(); },
setValue(v) { base.setValue(v); out.value = format(v); sync(); },
destroy: () => node.remove(),
};
});
@@ -277,9 +277,21 @@ test("the slider paints its fill from the value", () => {
test("slider.readout shows the value and its unit", () => {
const s = mount(composer.slider.readout({ value: 0.65, min: 0, max: 1, step: 0.05, unit: "x", precision: 2 }));
assert.equal(s.node.querySelector("output").textContent, "0.65x");
assert.equal(s.node.querySelector(".cx-readout").value, "0.65x");
s.setValue(0.3);
assert.equal(s.node.querySelector("output").textContent, "0.30x");
assert.equal(s.node.querySelector(".cx-readout").value, "0.30x");
});
test("slider.readout value can be typed, clamped, and commits", () => {
const commits = [];
const s = mount(composer.slider.readout({ value: 0.5, min: 0, max: 1, step: 0.05, onCommit: (v) => commits.push(v) }));
const field = s.node.querySelector(".cx-readout");
field.value = "7"; fire(field, "change");
assert.equal(s.value, 1);
assert.equal(field.value, "1.00");
assert.deepEqual(commits, [1]);
field.value = "abc"; fire(field, "change");
assert.equal(s.value, 1);
});
test("slider.macro jumps to a preset", () => {