diff --git a/app/ui/composer/elements/button.css b/app/ui/composer/elements/button.css index 04a74a25..233f9d98 100644 --- a/app/ui/composer/elements/button.css +++ b/app/ui/composer/elements/button.css @@ -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); } diff --git a/app/ui/composer/elements/choice.css b/app/ui/composer/elements/choice.css index db04b574..2c824688 100644 --- a/app/ui/composer/elements/choice.css +++ b/app/ui/composer/elements/choice.css @@ -132,3 +132,5 @@ inline-size: 10ch; font-family: var(--font-mono); } + +.cx-segment.cx-on { font-weight: var(--fw-semibold); } diff --git a/app/ui/composer/elements/input.css b/app/ui/composer/elements/input.css index 3e72c713..fb954274 100644 --- a/app/ui/composer/elements/input.css +++ b/app/ui/composer/elements/input.css @@ -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); } diff --git a/app/ui/composer/elements/slider.css b/app/ui/composer/elements/slider.css index 767edc4c..14575810 100644 --- a/app/ui/composer/elements/slider.css +++ b/app/ui/composer/elements/slider.css @@ -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; } diff --git a/app/ui/composer/elements/slider.js b/app/ui/composer/elements/slider.js index 6552bf0a..edba0a0a 100644 --- a/app/ui/composer/elements/slider.js +++ b/app/ui/composer/elements/slider.js @@ -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(), }; }); diff --git a/app/ui/composer/tests/elements_behaviour.test.js b/app/ui/composer/tests/elements_behaviour.test.js index d6966188..b4fc3c20 100644 --- a/app/ui/composer/tests/elements_behaviour.test.js +++ b/app/ui/composer/tests/elements_behaviour.test.js @@ -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", () => {