fix: matrix choice selector

This commit is contained in:
2026-09-13 10:50:43 +02:00
parent f6bc46dd98
commit faa019d52b
6 changed files with 254 additions and 72 deletions
+2 -6
View File
@@ -28,17 +28,13 @@ function ChoiceSelect<T extends string>({
if (currentIndex === -1 || options.length === 0) return;
const nextIndex =
(currentIndex + direction + options.length) % options.length;
const nextIndex = (currentIndex + direction + options.length) % options.length;
onChange(options[nextIndex]);
};
return (
<div
className={`choice-select ${className}`}
data-disabled={disabled || undefined}
>
<div className={`choice-select ${className}`} data-disabled={disabled || undefined}>
<select
id={id}
className="choice-select__input"
@@ -36,6 +36,7 @@
.choice-stepper__arrows {
flex: 0 0 auto;
align-self: stretch;
display: flex;
flex-direction: column;
+220 -45
View File
@@ -1,8 +1,17 @@
import { JSX, useEffect, useState } from 'react';
import { JSX, useEffect, useRef, useState } from 'react';
import Button from '../Button/Button';
import { TriangleUpIcon, TriangleDownIcon } from '../../assets/icons/Triangle';
import './ChoiceStepper.css';
const WHEEL_COMMIT_DELAY = 250;
// Safety net: if the parent's `value` prop never comes back exactly equal
// to what we committed (rounding, an intermediate transform, etc.), stop
// waiting after this long so the stepper doesn't get stuck ignoring
// external updates.
const PENDING_FALLBACK_MS = 1000;
type CommitResult = Promise<boolean | void> | boolean | void;
function StepperArrows({
onUp,
onDown,
@@ -44,7 +53,7 @@ type ChoiceStepperListProps<T> = {
value: T;
values: readonly T[];
formatLabel: (value: T) => string;
onChange: (value: T) => void;
onChange: (value: T) => CommitResult;
id?: string;
disabled?: boolean;
className?: string;
@@ -59,38 +68,117 @@ function ChoiceStepperList<T>({
disabled,
className,
}: ChoiceStepperListProps<T>) {
const index = values.indexOf(value);
const [localValue, setLocalValue] = useState(value);
const [text, setText] = useState(formatLabel(value));
// True from the moment a commit starts until the prop `value` we get from
// the parent actually reflects it (not just until onChange resolves —
// those are two different moments and the gap between them is what was
// causing the old-value blink).
const interacting = useRef(false);
// The value we're waiting to see echoed back via the `value` prop.
const pendingValue = useRef<T | null>(null);
const wheelTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const fallbackTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (interacting.current) {
// Only release control once the prop actually catches up to what we
// committed — otherwise keep showing localValue as-is (no flash back
// to the stale old value in between).
if (pendingValue.current !== null && Object.is(value, pendingValue.current)) {
interacting.current = false;
pendingValue.current = null;
if (fallbackTimeout.current) clearTimeout(fallbackTimeout.current);
} else {
return;
}
}
setLocalValue(value);
setText(formatLabel(value));
}, [value, formatLabel]);
useEffect(() => {
return () => {
if (wheelTimeout.current) clearTimeout(wheelTimeout.current);
if (fallbackTimeout.current) clearTimeout(fallbackTimeout.current);
};
}, []);
const index = values.indexOf(localValue);
const canUp = index === -1 || index < values.length - 1;
const canDown = index > 0;
const [text, setText] = useState(formatLabel(value));
useEffect(() => setText(formatLabel(value)), [value, formatLabel]);
const step = (direction: 1 | -1) => {
const currentIndex = values.indexOf(value);
if (currentIndex === -1) return;
const stepLocal = (direction: 1 | -1): T | null => {
const currentIndex = values.indexOf(localValue);
if (currentIndex === -1) return null;
const nextIndex = currentIndex + direction;
if (nextIndex < 0 || nextIndex >= values.length) return null;
if (nextIndex >= 0 && nextIndex < values.length) {
onChange(values[nextIndex]);
const next = values[nextIndex];
setLocalValue(next);
setText(formatLabel(next));
return next;
};
async function commit(next: T) {
interacting.current = true;
pendingValue.current = next;
const result = await onChange(next);
if (result === false) {
interacting.current = false;
pendingValue.current = null;
if (fallbackTimeout.current) clearTimeout(fallbackTimeout.current);
setLocalValue(value);
setText(formatLabel(value));
return;
}
// Accepted: don't clear `interacting` here. Wait for the prop-sync
// effect above to see `value === next` and release it, so we never
// hand control back to a still-stale prop. Guard with a timeout in
// case it never matches exactly.
if (fallbackTimeout.current) clearTimeout(fallbackTimeout.current);
fallbackTimeout.current = setTimeout(() => {
interacting.current = false;
pendingValue.current = null;
}, PENDING_FALLBACK_MS);
}
const stepAndCommit = (direction: 1 | -1) => {
const next = stepLocal(direction);
if (next !== null) void commit(next);
};
const stepWheel = (direction: 1 | -1) => {
const next = stepLocal(direction);
if (next === null) return;
interacting.current = true;
if (wheelTimeout.current) clearTimeout(wheelTimeout.current);
wheelTimeout.current = setTimeout(() => {
wheelTimeout.current = null;
void commit(next);
}, WHEEL_COMMIT_DELAY);
};
const commitText = () => {
const match = values.find(
(v) => formatLabel(v).trim().toLowerCase() === text.trim().toLowerCase()
);
if (match !== undefined) onChange(match);
else setText(formatLabel(value));
if (match !== undefined) {
setLocalValue(match);
void commit(match);
} else {
setText(formatLabel(value));
}
};
return (
<div
className={`choice-stepper ${className ?? ''}`}
data-disabled={disabled || undefined}
>
<div className={`choice-stepper ${className ?? ''}`} data-disabled={disabled || undefined}>
<input
id={id}
className="choice-stepper__input"
@@ -102,25 +190,32 @@ function ChoiceStepperList<T>({
e.preventDefault();
if (e.deltaY < 0 && canUp) {
step(1);
stepWheel(1);
} else if (e.deltaY > 0 && canDown) {
step(-1);
stepWheel(-1);
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter') e.currentTarget.blur();
else if (e.key === 'ArrowUp') {
if (e.key === 'Enter') {
e.currentTarget.blur();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
step(1);
interacting.current = true;
stepLocal(1);
} else if (e.key === 'ArrowDown') {
e.preventDefault();
step(-1);
interacting.current = true;
stepLocal(-1);
}
}}
onKeyUp={(e) => {
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
void commit(localValue);
}}
/>
<StepperArrows
onUp={() => step(1)}
onDown={() => step(-1)}
onUp={() => stepAndCommit(1)}
onDown={() => stepAndCommit(-1)}
canUp={canUp}
canDown={canDown}
disabled={disabled}
@@ -137,7 +232,7 @@ type ChoiceStepperRangeProps = {
max: number;
step?: number;
formatLabel: (value: number) => string;
onChange: (value: number) => void;
onChange: (value: number) => CommitResult;
id?: string;
disabled?: boolean;
className?: string;
@@ -154,30 +249,100 @@ function ChoiceStepperRange({
disabled,
className,
}: ChoiceStepperRangeProps) {
const [localValue, setLocalValue] = useState(value);
const [text, setText] = useState(formatLabel(value));
useEffect(() => setText(formatLabel(value)), [value, formatLabel]);
const interacting = useRef(false);
const pendingValue = useRef<number | null>(null);
const wheelTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const fallbackTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (interacting.current) {
if (pendingValue.current !== null && Object.is(value, pendingValue.current)) {
interacting.current = false;
pendingValue.current = null;
if (fallbackTimeout.current) clearTimeout(fallbackTimeout.current);
} else {
return;
}
}
setLocalValue(value);
setText(formatLabel(value));
}, [value, formatLabel]);
useEffect(() => {
return () => {
if (wheelTimeout.current) clearTimeout(wheelTimeout.current);
if (fallbackTimeout.current) clearTimeout(fallbackTimeout.current);
};
}, []);
const clamp = (n: number) => Math.min(max, Math.max(min, n));
const canUp = value < max;
const canDown = value > min;
const canUp = localValue < max;
const canDown = localValue > min;
const step = (direction: 1 | -1) => {
const next = clamp(value + direction * stepSize);
if (next !== value) onChange(next);
const stepLocal = (direction: 1 | -1): number | null => {
const next = clamp(localValue + direction * stepSize);
if (next === localValue) return null;
setLocalValue(next);
setText(formatLabel(next));
return next;
};
async function commit(next: number) {
interacting.current = true;
pendingValue.current = next;
const result = await onChange(next);
if (result === false) {
interacting.current = false;
pendingValue.current = null;
if (fallbackTimeout.current) clearTimeout(fallbackTimeout.current);
setLocalValue(value);
setText(formatLabel(value));
return;
}
if (fallbackTimeout.current) clearTimeout(fallbackTimeout.current);
fallbackTimeout.current = setTimeout(() => {
interacting.current = false;
pendingValue.current = null;
}, PENDING_FALLBACK_MS);
}
const stepAndCommit = (direction: 1 | -1) => {
const next = stepLocal(direction);
if (next !== null) void commit(next);
};
const stepWheel = (direction: 1 | -1) => {
const next = stepLocal(direction);
if (next === null) return;
interacting.current = true;
if (wheelTimeout.current) clearTimeout(wheelTimeout.current);
wheelTimeout.current = setTimeout(() => {
wheelTimeout.current = null;
void commit(next);
}, WHEEL_COMMIT_DELAY);
};
const commitText = () => {
const parsed = Number(text);
if (Number.isFinite(parsed)) onChange(clamp(parsed));
else setText(formatLabel(value));
if (Number.isFinite(parsed)) {
const next = clamp(parsed);
setLocalValue(next);
void commit(next);
} else {
setText(formatLabel(value));
}
};
return (
<div
className={`choice-stepper ${className ?? ''}`}
data-disabled={disabled || undefined}
>
<div className={`choice-stepper ${className ?? ''}`} data-disabled={disabled || undefined}>
<input
id={id}
className="choice-stepper__input"
@@ -189,9 +354,9 @@ function ChoiceStepperRange({
e.preventDefault();
if (e.deltaY < 0 && canUp) {
step(1);
stepWheel(1);
} else if (e.deltaY > 0 && canDown) {
step(-1);
stepWheel(-1);
}
}}
onKeyDown={(e) => {
@@ -199,16 +364,26 @@ function ChoiceStepperRange({
e.currentTarget.blur();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
if (canUp) step(1);
if (canUp) {
interacting.current = true;
stepLocal(1);
}
} else if (e.key === 'ArrowDown') {
e.preventDefault();
if (canDown) step(-1);
if (canDown) {
interacting.current = true;
stepLocal(-1);
}
}
}}
onKeyUp={(e) => {
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
void commit(localValue);
}}
/>
<StepperArrows
onUp={() => step(1)}
onDown={() => step(-1)}
onUp={() => stepAndCommit(1)}
onDown={() => stepAndCommit(-1)}
canUp={canUp}
canDown={canDown}
disabled={disabled}
@@ -89,7 +89,7 @@ function CompressorPanel({
onLiveValue(channel, 'ratio', value);
commit({ ratio: value });
}}
/>
/>
<div className="compressor-panel__cell-label">Ratio</div>
</div>
@@ -103,7 +103,7 @@ function CompressorPanel({
onLiveValue(channel, 'knee', value);
commit({ knee: value });
}}
/>
/>
<div className="compressor-panel__cell-label">Knee</div>
</div>
</div>
@@ -206,10 +206,13 @@ function LimiterSection({ dsp, set_limiter }: { dsp: DSP; set_limiter: SetLimite
<div className="limiter-graph__meters">
{outputs.map((output) => {
const outputState = dsp.state.current_config.output_states[output];
const idx = OUTPUT_METER_INDEX[output];
return (
<MeterBar key={output} label={outputState.name} meter={dsp.meters.channels[idx]} />
<MeterBar
key={output}
label={outputState.name}
meter={dsp.output_meters.meters[output]}
/>
);
})}
</div>
@@ -92,7 +92,7 @@
.matrix-panel__row .choice-stepper {
align-self: center;
height: auto;
aspect-ratio: 3 / 1;
flex: 0 0 auto;
}
@@ -109,4 +109,11 @@
.matrix-gain-stepper .choice-stepper__input {
font-size: 2.8cqh;
text-align: center;
}
.matrix-gain-stepper .choice-stepper__arrows {
flex: 0 0 20%;
width: 20%;
padding-right: 0;
}