diff --git a/lib/components/Counter/Counter.test.tsx b/lib/components/Counter/Counter.test.tsx index 3254e585..ee65f09d 100644 --- a/lib/components/Counter/Counter.test.tsx +++ b/lib/components/Counter/Counter.test.tsx @@ -111,4 +111,81 @@ describe('Counter', () => { expect(input).toHaveValue(-1); }); + + it('should accept multi-digit values typed into the input', async () => { + const { user, getInput } = setup(); + + const input = await getInput(); + + await user.click(input); + await user.type(input, '500'); + + expect(input).toHaveValue(500); + }); + + it('should display empty string while clearing and clamp to min on blur', async () => { + const { user, getInput } = setup({ init: 5, min: 1 }); + + const input = await getInput(); + + await user.click(input); + await user.clear(input); + + expect(input).toHaveValue(null); + + await user.tab(); + + expect(input).toHaveValue(1); + }); + + it('should not clamp while typing but clamp to max on blur', async () => { + const { user, getInput } = setup({ init: 0, max: 10 }); + + const input = await getInput(); + + await user.click(input); + await user.type(input, '999'); + + expect(input).toHaveValue(999); + + await user.tab(); + + expect(input).toHaveValue(10); + }); + + it('should clamp negative typed values to min on blur', async () => { + const { user, getInput } = setup({ init: 5, min: 0 }); + + const input = await getInput(); + + await user.click(input); + await user.clear(input); + await user.type(input, '-5'); + + expect(input).toHaveValue(-5); + + await user.tab(); + + expect(input).toHaveValue(0); + }); + + it('should keep increment/decrement buttons working after typing', async () => { + const { user, getInput, getIncrementButton, getDecrementButton } = setup({ + init: 0, + max: 100, + }); + + const input = await getInput(); + + await user.click(input); + await user.type(input, '7'); + + expect(input).toHaveValue(7); + + await user.click(getIncrementButton()); + expect(input).toHaveValue(8); + + await user.click(getDecrementButton()); + expect(input).toHaveValue(7); + }); }); diff --git a/lib/components/Counter/Counter.tsx b/lib/components/Counter/Counter.tsx index 34e69592..48ab6c10 100644 --- a/lib/components/Counter/Counter.tsx +++ b/lib/components/Counter/Counter.tsx @@ -1,6 +1,13 @@ 'use client'; import { Root as VisuallyHidden } from '@radix-ui/react-visually-hidden'; -import { FC, forwardRef, useCallback, useId } from 'react'; +import { + ChangeEvent, + FC, + forwardRef, + useCallback, + useId, + useState, +} from 'react'; import { Minus, Plus } from 'react-feather'; import { cn } from '@/utils'; @@ -69,6 +76,8 @@ export const Counter: FC = forwardRef( const id = useId(); const count = value ?? 0; + const [draft, setDraft] = useState(null); + const displayed = draft ?? String(count); const handleDecrement = useCallback(() => { let newValue: number = 0; @@ -79,6 +88,7 @@ export const Counter: FC = forwardRef( newValue = Math.max(min, count - 1); } + setDraft(null); onChange?.({ target: { value: newValue } }); }, [count, min, onChange]); @@ -91,9 +101,36 @@ export const Counter: FC = forwardRef( newValue = Math.min(max, count + 1); } + setDraft(null); onChange?.({ target: { value: newValue } }); }, [count, max, onChange]); + const handleChange = useCallback( + (event: ChangeEvent) => { + const raw = event.target.value; + setDraft(raw); + if (raw === '') { + return; + } + const parsed = Number(raw); + if (!Number.isFinite(parsed)) { + return; + } + onChange?.({ target: { value: parsed } }); + }, + [onChange], + ); + + const handleBlur = useCallback(() => { + setDraft(null); + const lowerBound = min === Infinity ? -Infinity : min; + const upperBound = max === -Infinity ? Infinity : max; + const clamped = Math.min(upperBound, Math.max(lowerBound, count)); + if (clamped !== count) { + onChange?.({ target: { value: clamped } }); + } + }, [count, min, max, onChange]); + return (
{label ? ( @@ -131,10 +168,11 @@ export const Counter: FC = forwardRef(