diff --git a/gbajs3/src/components/modals/save-states.tsx b/gbajs3/src/components/modals/save-states.tsx index cd7dadbe..8f4e8077 100644 --- a/gbajs3/src/components/modals/save-states.tsx +++ b/gbajs3/src/components/modals/save-states.tsx @@ -122,9 +122,8 @@ export const SaveStatesModal = () => { setValue('saveStateSlot', currentSlot); }, [currentSlot, setValue]); - const onSubmit: SubmitHandler = async (formData) => { + const onSubmit: SubmitHandler = async (formData) => setCurrentSlot(formData.saveStateSlot); - }; const renderedSaveStates = currentSaveStates ?? diff --git a/gbajs3/src/components/shared/number-input.spec.tsx b/gbajs3/src/components/shared/number-input.spec.tsx index 7b7274ab..83fd9197 100644 --- a/gbajs3/src/components/shared/number-input.spec.tsx +++ b/gbajs3/src/components/shared/number-input.spec.tsx @@ -1,9 +1,11 @@ import { fireEvent, render, screen } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { NumberInput } from './number-input.tsx'; +import type { RefObject } from 'react'; + describe('', () => { it('renders correctly with default props', () => { render(); @@ -60,7 +62,7 @@ describe('', () => { expect(inputElement).toHaveValue(6); }); - it('clamps value to min when empty', async () => { + it('clamps value to min when empty on blur', async () => { render(); const inputElement = screen.getByRole('spinbutton'); @@ -71,7 +73,7 @@ describe('', () => { expect(inputElement).toHaveValue(1); }); - it('clamps value to 0 when empty with no min', async () => { + it('clamps value to 0 when empty with no min on blur', async () => { render(); const inputElement = screen.getByRole('spinbutton'); @@ -82,6 +84,28 @@ describe('', () => { expect(inputElement).toHaveValue(0); }); + it('prevents typing negative sign if min is greater than or equal to 0', async () => { + render(); + + const inputElement = screen.getByRole('spinbutton'); + + await userEvent.type(inputElement, '{backspace}'); + await userEvent.type(inputElement, '-1'); + + expect(inputElement).toHaveValue(1); + }); + + it('allows typing negative sign if min is less than 0', async () => { + render(); + + const inputElement = screen.getByRole('spinbutton'); + + await userEvent.type(inputElement, '{backspace}'); + await userEvent.type(inputElement, '-1'); + + expect(inputElement).toHaveValue(-1); + }); + it('respects disabled prop', () => { render(); @@ -91,7 +115,7 @@ describe('', () => { }); it('forwards ref', () => { - const ref = { current: null } as React.RefObject; + const ref = { current: null } as RefObject; render(); @@ -100,4 +124,15 @@ describe('', () => { expect(ref.current).toBe(inputElement); expect(ref.current).toBeInTheDocument(); }); + + it('accepts callback ref', () => { + const refSpy = vi.fn(); + + render(); + + const inputElement = screen.getByRole('spinbutton'); + + expect(refSpy).toHaveBeenCalledOnce(); + expect(refSpy).toHaveBeenCalledWith(inputElement); + }); }); diff --git a/gbajs3/src/components/shared/number-input.tsx b/gbajs3/src/components/shared/number-input.tsx index 8ee132c2..64e91bee 100644 --- a/gbajs3/src/components/shared/number-input.tsx +++ b/gbajs3/src/components/shared/number-input.tsx @@ -6,7 +6,7 @@ import { type IconButtonProps, type TextFieldProps } from '@mui/material'; -import { forwardRef, type MouseEvent, useRef } from 'react'; +import { forwardRef, useRef, type MouseEvent, type KeyboardEvent } from 'react'; import { BiSolidUpArrow, BiSolidDownArrow } from 'react-icons/bi'; type NumberInputProps = TextFieldProps & { @@ -23,12 +23,15 @@ const commonAdornmentButtonProps: IconButtonProps = { const preventDefault = (event: MouseEvent) => event.preventDefault(); +const replaceLeadingZeros = (value: string) => value.replace(/^0+/, ''); + export const NumberInput = forwardRef( ( { disabled = false, size, slotProps, step = 1, min, max, sx, ...rest }, externalRef ) => { const internalRef = useRef(null); + const isIntermediateValue = useRef(false); const callbackRef = (element: HTMLInputElement | null) => { internalRef.current = element; @@ -54,23 +57,21 @@ export const NumberInput = forwardRef( const increment = (e: MouseEvent) => { preventDefault(e); - if (!internalRef.current) return; - - const currentValue = internalRef.current.valueAsNumber; - const newValue = clamp(currentValue + step); - - dispatchEvent(newValue); + if (internalRef.current) { + const currentValue = internalRef.current?.valueAsNumber; + const newValue = clamp(currentValue + step); + dispatchEvent(newValue); + } }; const decrement = (e: MouseEvent) => { preventDefault(e); - if (!internalRef.current) return; - - const currentValue = internalRef.current.valueAsNumber; - const newValue = clamp(currentValue - step); - - dispatchEvent(newValue); + if (internalRef.current) { + const currentValue = internalRef.current?.valueAsNumber; + const newValue = clamp(currentValue - step); + dispatchEvent(newValue); + } }; const enforceRange = () => { @@ -83,6 +84,29 @@ export const NumberInput = forwardRef( } }; + const sanitizeInput = () => { + if (internalRef.current && !isIntermediateValue.current) { + const value = internalRef.current.valueAsNumber; + + if (isNaN(value) || value === undefined) + internalRef.current.value = '0'; + else internalRef.current.value = clamp(value).toString(); + } + }; + + const handleIntermediateValue = (e: KeyboardEvent) => { + if (e.key === '-') { + if (min !== undefined && Number(min) >= 0) { + e.preventDefault(); + } else if (internalRef.current) { + internalRef.current.value = replaceLeadingZeros( + internalRef.current.value + ); + isIntermediateValue.current = true; + } + } else isIntermediateValue.current = false; + }; + return ( ( ), - onInput: () => { - if (internalRef.current) { - const value = internalRef?.current.valueAsNumber; - if (isNaN(value) || value === undefined) - internalRef.current.value = min ? min.toString() : '0'; - else internalRef.current.value = value.toString(); - } - }, + onInput: sanitizeInput, + onKeyDown: handleIntermediateValue, onBlur: enforceRange, ...slotProps?.input },