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:
@@ -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); }
|
||||
|
||||
@@ -132,3 +132,5 @@
|
||||
inline-size: 10ch;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.cx-segment.cx-on { font-weight: var(--fw-semibold); }
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user