405 lines
11 KiB
TypeScript
405 lines
11 KiB
TypeScript
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,
|
|
canUp,
|
|
canDown,
|
|
disabled,
|
|
}: {
|
|
onUp: () => void;
|
|
onDown: () => void;
|
|
canUp: boolean;
|
|
canDown: boolean;
|
|
disabled?: boolean;
|
|
}) {
|
|
return (
|
|
<div className="choice-stepper__arrows">
|
|
<Button
|
|
variant="toggle"
|
|
className="choice-stepper__arrow-btn"
|
|
onClick={onUp}
|
|
disabled={disabled || !canUp}
|
|
>
|
|
<TriangleUpIcon />
|
|
</Button>
|
|
<Button
|
|
variant="toggle"
|
|
className="choice-stepper__arrow-btn"
|
|
onClick={onDown}
|
|
disabled={disabled || !canDown}
|
|
>
|
|
<TriangleDownIcon />
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---- List variant ----
|
|
|
|
type ChoiceStepperListProps<T> = {
|
|
value: T;
|
|
values: readonly T[];
|
|
formatLabel: (value: T) => string;
|
|
onChange: (value: T) => CommitResult;
|
|
id?: string;
|
|
disabled?: boolean;
|
|
className?: string;
|
|
};
|
|
|
|
function ChoiceStepperList<T>({
|
|
value,
|
|
values,
|
|
formatLabel,
|
|
onChange,
|
|
id,
|
|
disabled,
|
|
className,
|
|
}: ChoiceStepperListProps<T>) {
|
|
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 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;
|
|
|
|
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) {
|
|
setLocalValue(match);
|
|
void commit(match);
|
|
} else {
|
|
setText(formatLabel(value));
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className={`choice-stepper ${className ?? ''}`} data-disabled={disabled || undefined}>
|
|
<input
|
|
id={id}
|
|
className="choice-stepper__input"
|
|
value={text}
|
|
disabled={disabled}
|
|
onChange={(e) => setText(e.target.value)}
|
|
onBlur={commitText}
|
|
onWheel={(e) => {
|
|
e.preventDefault();
|
|
|
|
if (e.deltaY < 0 && canUp) {
|
|
stepWheel(1);
|
|
} else if (e.deltaY > 0 && canDown) {
|
|
stepWheel(-1);
|
|
}
|
|
}}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') {
|
|
e.currentTarget.blur();
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
interacting.current = true;
|
|
stepLocal(1);
|
|
} else if (e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
interacting.current = true;
|
|
stepLocal(-1);
|
|
}
|
|
}}
|
|
onKeyUp={(e) => {
|
|
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
|
|
void commit(localValue);
|
|
}}
|
|
/>
|
|
<StepperArrows
|
|
onUp={() => stepAndCommit(1)}
|
|
onDown={() => stepAndCommit(-1)}
|
|
canUp={canUp}
|
|
canDown={canDown}
|
|
disabled={disabled}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---- Range variant (concrete, no generic) ----
|
|
|
|
type ChoiceStepperRangeProps = {
|
|
value: number;
|
|
min: number;
|
|
max: number;
|
|
step?: number;
|
|
formatLabel: (value: number) => string;
|
|
onChange: (value: number) => CommitResult;
|
|
id?: string;
|
|
disabled?: boolean;
|
|
className?: string;
|
|
};
|
|
|
|
function ChoiceStepperRange({
|
|
value,
|
|
min,
|
|
max,
|
|
step: stepSize = 1,
|
|
formatLabel,
|
|
onChange,
|
|
id,
|
|
disabled,
|
|
className,
|
|
}: ChoiceStepperRangeProps) {
|
|
const [localValue, setLocalValue] = useState(value);
|
|
const [text, setText] = useState(formatLabel(value));
|
|
|
|
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 = localValue < max;
|
|
const canDown = localValue > min;
|
|
|
|
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)) {
|
|
const next = clamp(parsed);
|
|
setLocalValue(next);
|
|
void commit(next);
|
|
} else {
|
|
setText(formatLabel(value));
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className={`choice-stepper ${className ?? ''}`} data-disabled={disabled || undefined}>
|
|
<input
|
|
id={id}
|
|
className="choice-stepper__input"
|
|
value={text}
|
|
disabled={disabled}
|
|
onChange={(e) => setText(e.target.value)}
|
|
onBlur={commitText}
|
|
onWheel={(e) => {
|
|
e.preventDefault();
|
|
|
|
if (e.deltaY < 0 && canUp) {
|
|
stepWheel(1);
|
|
} else if (e.deltaY > 0 && canDown) {
|
|
stepWheel(-1);
|
|
}
|
|
}}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') {
|
|
e.currentTarget.blur();
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
if (canUp) {
|
|
interacting.current = true;
|
|
stepLocal(1);
|
|
}
|
|
} else if (e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
if (canDown) {
|
|
interacting.current = true;
|
|
stepLocal(-1);
|
|
}
|
|
}
|
|
}}
|
|
onKeyUp={(e) => {
|
|
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
|
|
void commit(localValue);
|
|
}}
|
|
/>
|
|
<StepperArrows
|
|
onUp={() => stepAndCommit(1)}
|
|
onDown={() => stepAndCommit(-1)}
|
|
canUp={canUp}
|
|
canDown={canDown}
|
|
disabled={disabled}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ChoiceStepper<T>(props: ChoiceStepperListProps<T>): JSX.Element;
|
|
function ChoiceStepper(props: ChoiceStepperRangeProps): JSX.Element;
|
|
function ChoiceStepper(props: ChoiceStepperListProps<unknown> | ChoiceStepperRangeProps) {
|
|
if ('values' in props) {
|
|
return <ChoiceStepperList {...props} />;
|
|
}
|
|
return <ChoiceStepperRange {...props} />;
|
|
}
|
|
|
|
export default ChoiceStepper;
|