/** * NumberInput keeps what was typed as a draft, so a number field can be * cleared and retyped (#3182). Clamping inside onChange used to snap an * emptied field straight back to its minimum. */ import { useState } from 'react'; import { describe, it, expect, vi } from 'vitest'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { NumberInput } from '../../components/NumberInput'; function Harness({ initial, onCommit, ...props }: { initial: number; onCommit?: (v: number) => void; min?: number; max?: number; fallback?: number; integer?: boolean; }) { const [value, setValue] = useState(initial); return ( <> { setValue(v); onCommit?.(v); }} {...props} /> {value} ); } const input = () => screen.getByLabelText('qty') as HTMLInputElement; const committed = () => screen.getByTestId('committed').textContent; describe('NumberInput', () => { it('can be emptied and retyped without snapping back to the minimum', async () => { const user = userEvent.setup(); render(); await user.click(input()); await user.keyboard('{Backspace}'); expect(input().value).toBe(''); expect(committed()).toBe('1'); await user.keyboard('6'); expect(input().value).toBe('6'); expect(committed()).toBe('6'); }); it('passes nothing up while the draft is below the minimum, then the finished number', async () => { const user = userEvent.setup(); const onCommit = vi.fn(); render(); await user.clear(input()); await user.keyboard('6'); expect(input().value).toBe('6'); expect(onCommit).not.toHaveBeenCalled(); await user.keyboard('0'); expect(input().value).toBe('60'); expect(onCommit).toHaveBeenCalledTimes(1); expect(onCommit).toHaveBeenLastCalledWith(60); }); it('clamps an out-of-range draft into range on blur', async () => { const user = userEvent.setup(); render(); await user.clear(input()); await user.keyboard('120'); await user.tab(); expect(input().value).toBe('90'); expect(committed()).toBe('90'); await user.clear(input()); await user.keyboard('3'); await user.tab(); expect(input().value).toBe('45'); expect(committed()).toBe('45'); }); it('commits the fallback when left empty', async () => { const user = userEvent.setup(); render(); await user.clear(input()); await user.tab(); expect(input().value).toBe('7'); expect(committed()).toBe('7'); }); it('reverts to the current value when left empty without a fallback', async () => { const user = userEvent.setup(); const onCommit = vi.fn(); render(); await user.clear(input()); await user.tab(); expect(input().value).toBe('40'); expect(onCommit).not.toHaveBeenCalled(); }); it('accepts 0 where the range allows it', async () => { const user = userEvent.setup(); render(); await user.clear(input()); await user.keyboard('0'); await user.tab(); expect(input().value).toBe('0'); expect(committed()).toBe('0'); }); it('keeps fractions when integer is false', async () => { const user = userEvent.setup(); render(); await user.clear(input()); await user.keyboard('27.5'); await user.tab(); expect(input().value).toBe('27.5'); expect(committed()).toBe('27.5'); }); it('follows a value changed from outside while not being edited', () => { const { rerender } = render( {}} />); expect(input().value).toBe('3'); rerender( {}} />); expect(input().value).toBe('8'); }); it('still calls a caller-supplied onBlur', async () => { const user = userEvent.setup(); const onBlur = vi.fn(); render( {}} onBlur={onBlur} />); await user.click(input()); await user.tab(); expect(onBlur).toHaveBeenCalledTimes(1); }); });