diff --git a/apps/readest-app/src/__tests__/components/NumberInput.test.tsx b/apps/readest-app/src/__tests__/components/NumberInput.test.tsx new file mode 100644 index 00000000..13f5dad0 --- /dev/null +++ b/apps/readest-app/src/__tests__/components/NumberInput.test.tsx @@ -0,0 +1,54 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup, fireEvent } from '@testing-library/react'; +import React from 'react'; + +import NumberInput from '@/components/settings/NumberInput'; + +vi.mock('@/hooks/useTranslation', () => ({ + useTranslation: () => (s: string) => s, +})); + +afterEach(cleanup); + +describe('NumberInput', () => { + const defaultProps = { + label: 'Font Size', + value: 16, + min: 8, + max: 72, + onChange: vi.fn(), + }; + + it('commits value on Enter key (form submit)', () => { + const onChange = vi.fn(); + render(); + + const input = screen.getByDisplayValue('16'); + fireEvent.change(input, { target: { value: '24' } }); + expect(onChange).not.toHaveBeenCalled(); + + // Enter triggers form submit which commits the value + fireEvent.submit(input.closest('form')!); + expect(onChange).toHaveBeenCalledWith(24); + }); + + it('commits value on blur', () => { + const onChange = vi.fn(); + render(); + + const input = screen.getByDisplayValue('16'); + fireEvent.change(input, { target: { value: '20' } }); + fireEvent.blur(input); + expect(onChange).toHaveBeenCalledWith(20); + }); + + it('clamps value to min/max on commit', () => { + const onChange = vi.fn(); + render(); + + const input = screen.getByDisplayValue('16'); + fireEvent.change(input, { target: { value: '100' } }); + fireEvent.submit(input.closest('form')!); + expect(onChange).toHaveBeenCalledWith(72); + }); +}); diff --git a/apps/readest-app/src/components/settings/NumberInput.tsx b/apps/readest-app/src/components/settings/NumberInput.tsx index 141ce1c3..f0f14e58 100644 --- a/apps/readest-app/src/components/settings/NumberInput.tsx +++ b/apps/readest-app/src/components/settings/NumberInput.tsx @@ -31,46 +31,36 @@ const NumberInput: React.FC = ({ 'data-setting-id': settingId, }) => { const _ = useTranslation(); - const [localValue, setLocalValue] = useState(value); + const [displayValue, setDisplayValue] = useState(String(value)); const numberStep = step || 1; useEffect(() => { - setLocalValue(value); + setDisplayValue(String(value)); }, [value]); const handleChange = (e: React.ChangeEvent) => { - const value = e.target.value; - - // Allow empty string or valid numbers without leading zeros - if (value === '' || /^[1-9]\d*\.?\d*$|^0?\.?\d*$/.test(value)) { - const newValue = value === '' ? 0 : parseFloat(value); - setLocalValue(newValue); - - if (!isNaN(newValue)) { - const roundedValue = Math.round(newValue * 10) / 10; - onChange(Math.max(min, Math.min(max, roundedValue))); - } + const raw = e.target.value; + // Allow empty string or valid number fragments while typing + if (raw === '' || /^[0-9]*\.?[0-9]*$/.test(raw)) { + setDisplayValue(raw); } }; - const increment = () => { - const newValue = Math.min(max, localValue + numberStep); - const roundedValue = Math.round(newValue * 10) / 10; - setLocalValue(roundedValue); - onChange(roundedValue); + const commitValue = (v: number) => { + const clamped = Math.round(Math.max(min, Math.min(max, v)) * 10) / 10; + setDisplayValue(String(clamped)); + onChange(clamped); }; - const decrement = () => { - const newValue = Math.max(min, localValue - numberStep); - const roundedValue = Math.round(newValue * 10) / 10; - setLocalValue(roundedValue); - onChange(roundedValue); - }; + const currentNumericValue = parseFloat(displayValue) || 0; - const handleOnBlur = () => { - const newValue = Math.max(min, Math.min(max, localValue)); - setLocalValue(newValue); - onChange(newValue); + const increment = () => commitValue(currentNumericValue + numberStep); + const decrement = () => commitValue(currentNumericValue - numberStep); + const handleOnBlur = () => commitValue(currentNumericValue); + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + commitValue(currentNumericValue); + (document.activeElement as HTMLElement)?.blur(); }; return ( @@ -78,26 +68,28 @@ const NumberInput: React.FC = ({ {label} {iconSize && }
- e.target.select()} - /> +
+ e.target.select()} + /> +
@@ -105,7 +97,7 @@ const NumberInput: React.FC = ({ tabIndex={disabled ? -1 : 0} aria-label={_('Increase')} onClick={increment} - className={`btn btn-circle btn-sm ${localValue >= max || disabled ? 'btn-disabled !bg-opacity-5' : ''}`} + className={`btn btn-circle btn-sm ${currentNumericValue >= max || disabled ? 'btn-disabled !bg-opacity-5' : ''}`} >