diff --git a/gbajs3/src/components/modals/save-states.spec.tsx b/gbajs3/src/components/modals/save-states.spec.tsx
index b9808b11..4b25f58b 100644
--- a/gbajs3/src/components/modals/save-states.spec.tsx
+++ b/gbajs3/src/components/modals/save-states.spec.tsx
@@ -201,24 +201,6 @@ describe('', () => {
expect(screen.getByText('Failed to load save state')).toBeVisible();
});
- it('renders form validations', async () => {
- renderWithContext();
-
- const currentSlot = screen.getByLabelText('Current Save State Slot');
-
- expect(currentSlot).toBeVisible();
-
- await userEvent.type(currentSlot, '{backspace}');
-
- await userEvent.click(screen.getByRole('button', { name: 'Update Slot' }));
-
- expect(screen.getByText('Slot is required')).toBeVisible();
-
- await userEvent.type(currentSlot, '-1');
-
- expect(await screen.findByText('Slot must be >= 0')).toBeVisible();
- });
-
it('closes modal using the close button', async () => {
const setIsModalOpenSpy = vi.fn();
const { useModalContext: original } = await vi.importActual<
diff --git a/gbajs3/src/components/modals/save-states.tsx b/gbajs3/src/components/modals/save-states.tsx
index 9e7edf1d..00f4df71 100644
--- a/gbajs3/src/components/modals/save-states.tsx
+++ b/gbajs3/src/components/modals/save-states.tsx
@@ -1,4 +1,4 @@
-import { Button, IconButton, TextField } from '@mui/material';
+import { Button, IconButton } from '@mui/material';
import { useLocalStorage } from '@uidotdev/usehooks';
import { useCallback, useEffect, useId, useState } from 'react';
import { useForm, type SubmitHandler } from 'react-hook-form';
@@ -17,6 +17,7 @@ import {
} from '../product-tour/embedded-product-tour.tsx';
import { CircleCheckButton } from '../shared/circle-check-button.tsx';
import { ErrorWithIcon } from '../shared/error-with-icon.tsx';
+import { NumberInput } from '../shared/number-input.tsx';
import { CenteredText, StyledBiPlus } from '../shared/styled.tsx';
type InputProps = {
@@ -170,16 +171,14 @@ export const SaveStatesModal = () => {
id={`${baseId}--save-state-slot-form`}
onSubmit={handleSubmit(onSubmit)}
>
- = 0' },
valueAsNumber: true
})}
/>
diff --git a/gbajs3/src/components/shared/number-input.spec.tsx b/gbajs3/src/components/shared/number-input.spec.tsx
new file mode 100644
index 00000000..7b7274ab
--- /dev/null
+++ b/gbajs3/src/components/shared/number-input.spec.tsx
@@ -0,0 +1,103 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import { userEvent } from '@testing-library/user-event';
+import { describe, expect, it } from 'vitest';
+
+import { NumberInput } from './number-input.tsx';
+
+describe('', () => {
+ it('renders correctly with default props', () => {
+ render();
+
+ const inputElement = screen.getByRole('spinbutton');
+
+ expect(inputElement).toBeInTheDocument();
+ expect(inputElement).toBeEnabled();
+ });
+
+ it('increments value when increment button is clicked', async () => {
+ render();
+
+ const inputElement = screen.getByRole('spinbutton');
+ const incrementButton = screen.getByLabelText('Increment');
+
+ await userEvent.click(incrementButton);
+
+ expect(inputElement).toHaveValue(7);
+ });
+
+ it('decrements value when decrement button is clicked', async () => {
+ render();
+
+ const inputElement = screen.getByRole('spinbutton');
+ const decrementButton = screen.getByLabelText('Decrement');
+
+ await userEvent.click(decrementButton);
+
+ expect(inputElement).toHaveValue(3);
+ });
+
+ it('clamps value to min when below range', async () => {
+ render();
+
+ const inputElement = screen.getByRole('spinbutton');
+ const decrementButton = screen.getByLabelText('Decrement');
+
+ await userEvent.click(decrementButton);
+ await userEvent.click(decrementButton);
+
+ expect(inputElement).toHaveValue(4);
+ });
+
+ it('clamps value to max when above range', async () => {
+ render();
+
+ const inputElement = screen.getByRole('spinbutton');
+ const incrementButton = screen.getByLabelText('Increment');
+
+ await userEvent.click(incrementButton);
+ await userEvent.click(incrementButton);
+
+ expect(inputElement).toHaveValue(6);
+ });
+
+ it('clamps value to min when empty', async () => {
+ render();
+
+ const inputElement = screen.getByRole('spinbutton');
+
+ await userEvent.type(inputElement, '{backspace}');
+ fireEvent.blur(inputElement);
+
+ expect(inputElement).toHaveValue(1);
+ });
+
+ it('clamps value to 0 when empty with no min', async () => {
+ render();
+
+ const inputElement = screen.getByRole('spinbutton');
+
+ await userEvent.type(inputElement, '{backspace}');
+ fireEvent.blur(inputElement);
+
+ expect(inputElement).toHaveValue(0);
+ });
+
+ it('respects disabled prop', () => {
+ render();
+
+ expect(screen.getByRole('spinbutton')).toBeDisabled();
+ expect(screen.getByLabelText('Increment')).toBeDisabled();
+ expect(screen.getByLabelText('Decrement')).toBeDisabled();
+ });
+
+ it('forwards ref', () => {
+ const ref = { current: null } as React.RefObject;
+
+ render();
+
+ const inputElement = screen.getByRole('spinbutton');
+
+ expect(ref.current).toBe(inputElement);
+ expect(ref.current).toBeInTheDocument();
+ });
+});
diff --git a/gbajs3/src/components/shared/number-input.tsx b/gbajs3/src/components/shared/number-input.tsx
new file mode 100644
index 00000000..fac5ca0b
--- /dev/null
+++ b/gbajs3/src/components/shared/number-input.tsx
@@ -0,0 +1,152 @@
+import {
+ IconButton,
+ InputAdornment,
+ Stack,
+ TextField,
+ type IconButtonProps,
+ type TextFieldProps
+} from '@mui/material';
+import { forwardRef, type MouseEvent, useRef } from 'react';
+import { BiSolidUpArrow, BiSolidDownArrow } from 'react-icons/bi';
+
+type NumberInputProps = TextFieldProps & {
+ max?: number | string;
+ min?: number | string;
+ step?: number;
+};
+
+const commonAdornmentButtonProps: IconButtonProps = {
+ edge: 'end',
+ sx: { p: '1px' }
+};
+
+const preventDefault = (event: MouseEvent) =>
+ event.preventDefault();
+
+export const NumberInput = forwardRef(
+ (
+ { disabled = false, size, slotProps, step = 1, min, max, sx, ...rest },
+ externalRef
+ ) => {
+ const internalRef = useRef(null);
+
+ const callbackRef = (element: HTMLInputElement | null) => {
+ internalRef.current = element;
+ if (typeof externalRef === 'function') externalRef(element);
+ else if (externalRef) externalRef.current = element;
+ };
+
+ const setValue = (currentValue: number, adjustment: number): string => {
+ const newValue = currentValue + adjustment;
+ return clampValue(newValue);
+ };
+
+ const clampValue = (value: number): string => {
+ if (min !== undefined && value < Number(min)) return min.toString();
+ if (max !== undefined && value > Number(max)) return max.toString();
+ return value.toString();
+ };
+
+ const enforceRange = () => {
+ if (internalRef.current) {
+ const currentValue = Number(internalRef.current.valueAsNumber || 0);
+ internalRef.current.value = clampValue(currentValue);
+ internalRef.current.dispatchEvent(
+ new Event('input', { bubbles: true })
+ );
+ }
+ };
+
+ return (
+
+
+ {
+ preventDefault(e);
+ if (internalRef.current) {
+ const setter = Object.getOwnPropertyDescriptor(
+ window.HTMLInputElement.prototype,
+ 'value'
+ )?.set;
+ setter?.call(
+ internalRef.current,
+ setValue(internalRef.current.valueAsNumber, step)
+ );
+ internalRef.current.dispatchEvent(
+ new Event('input', { bubbles: true })
+ );
+ }
+ }}
+ {...commonAdornmentButtonProps}
+ >
+
+
+ {
+ preventDefault(e);
+ if (internalRef.current) {
+ const setter = Object.getOwnPropertyDescriptor(
+ window.HTMLInputElement.prototype,
+ 'value'
+ )?.set;
+ setter?.call(
+ internalRef.current,
+ setValue(internalRef.current.valueAsNumber, -step)
+ );
+ internalRef.current.dispatchEvent(
+ new Event('input', { bubbles: true })
+ );
+ }
+ }}
+ {...commonAdornmentButtonProps}
+ >
+
+
+
+
+ ),
+ 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();
+ }
+ },
+ onBlur: enforceRange,
+ ...slotProps?.input
+ },
+ htmlInput: {
+ min: min,
+ max: max,
+ step: step,
+ ...slotProps?.htmlInput
+ }
+ }}
+ sx={{
+ 'input::-webkit-outer-spin-button, input::-webkit-inner-spin-button':
+ {
+ WebkitAppearance: 'none',
+ margin: 0
+ },
+ ...sx
+ }}
+ {...rest}
+ />
+ );
+ }
+);