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; function StepperArrows({ onUp, onDown, canUp, canDown, disabled, }: { onUp: () => void; onDown: () => void; canUp: boolean; canDown: boolean; disabled?: boolean; }) { return (
); } // ---- List variant ---- type ChoiceStepperListProps = { value: T; values: readonly T[]; formatLabel: (value: T) => string; onChange: (value: T) => CommitResult; id?: string; disabled?: boolean; className?: string; }; function ChoiceStepperList({ value, values, formatLabel, onChange, id, disabled, className, }: ChoiceStepperListProps) { 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(null); const wheelTimeout = useRef | null>(null); const fallbackTimeout = useRef | 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 (
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); }} /> stepAndCommit(1)} onDown={() => stepAndCommit(-1)} canUp={canUp} canDown={canDown} disabled={disabled} />
); } // ---- 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(null); const wheelTimeout = useRef | null>(null); const fallbackTimeout = useRef | 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 (
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); }} /> stepAndCommit(1)} onDown={() => stepAndCommit(-1)} canUp={canUp} canDown={canDown} disabled={disabled} />
); } function ChoiceStepper(props: ChoiceStepperListProps): JSX.Element; function ChoiceStepper(props: ChoiceStepperRangeProps): JSX.Element; function ChoiceStepper(props: ChoiceStepperListProps | ChoiceStepperRangeProps) { if ('values' in props) { return ; } return ; } export default ChoiceStepper;