Skip to content

Commit

Permalink
fix: intermediate state
Browse files Browse the repository at this point in the history
  • Loading branch information
thenick775 committed Dec 29, 2024
1 parent 2ea8097 commit d9e2312
Show file tree
Hide file tree
Showing 3 changed files with 79 additions and 27 deletions.
3 changes: 1 addition & 2 deletions gbajs3/src/components/modals/save-states.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -122,9 +122,8 @@ export const SaveStatesModal = () => {
setValue('saveStateSlot', currentSlot);
}, [currentSlot, setValue]);

const onSubmit: SubmitHandler<InputProps> = async (formData) => {
const onSubmit: SubmitHandler<InputProps> = async (formData) =>
setCurrentSlot(formData.saveStateSlot);
};

const renderedSaveStates =
currentSaveStates ??
Expand Down
43 changes: 39 additions & 4 deletions gbajs3/src/components/shared/number-input.spec.tsx
Original file line number Diff line number Diff line change
@@ -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('<NumberInput />', () => {
it('renders correctly with default props', () => {
render(<NumberInput />);
Expand Down Expand Up @@ -60,7 +62,7 @@ describe('<NumberInput />', () => {
expect(inputElement).toHaveValue(6);
});

it('clamps value to min when empty', async () => {
it('clamps value to min when empty on blur', async () => {
render(<NumberInput defaultValue="5" min={1} />);

const inputElement = screen.getByRole('spinbutton');
Expand All @@ -71,7 +73,7 @@ describe('<NumberInput />', () => {
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(<NumberInput defaultValue="5" />);

const inputElement = screen.getByRole('spinbutton');
Expand All @@ -82,6 +84,28 @@ describe('<NumberInput />', () => {
expect(inputElement).toHaveValue(0);
});

it('prevents typing negative sign if min is greater than or equal to 0', async () => {
render(<NumberInput defaultValue="5" min={0} />);

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(<NumberInput defaultValue="5" min={-5} />);

const inputElement = screen.getByRole('spinbutton');

await userEvent.type(inputElement, '{backspace}');
await userEvent.type(inputElement, '-1');

expect(inputElement).toHaveValue(-1);
});

it('respects disabled prop', () => {
render(<NumberInput disabled />);

Expand All @@ -91,7 +115,7 @@ describe('<NumberInput />', () => {
});

it('forwards ref', () => {
const ref = { current: null } as React.RefObject<HTMLInputElement>;
const ref = { current: null } as RefObject<HTMLInputElement>;

render(<NumberInput ref={ref} />);

Expand All @@ -100,4 +124,15 @@ describe('<NumberInput />', () => {
expect(ref.current).toBe(inputElement);
expect(ref.current).toBeInTheDocument();
});

it('accepts callback ref', () => {
const refSpy = vi.fn();

render(<NumberInput ref={refSpy} />);

const inputElement = screen.getByRole('spinbutton');

expect(refSpy).toHaveBeenCalledOnce();
expect(refSpy).toHaveBeenCalledWith(inputElement);
});
});
60 changes: 39 additions & 21 deletions gbajs3/src/components/shared/number-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 & {
Expand All @@ -23,12 +23,15 @@ const commonAdornmentButtonProps: IconButtonProps = {
const preventDefault = (event: MouseEvent<HTMLButtonElement>) =>
event.preventDefault();

const replaceLeadingZeros = (value: string) => value.replace(/^0+/, '');

export const NumberInput = forwardRef<HTMLInputElement, NumberInputProps>(
(
{ disabled = false, size, slotProps, step = 1, min, max, sx, ...rest },
externalRef
) => {
const internalRef = useRef<HTMLInputElement | null>(null);
const isIntermediateValue = useRef(false);

const callbackRef = (element: HTMLInputElement | null) => {
internalRef.current = element;
Expand All @@ -54,23 +57,21 @@ export const NumberInput = forwardRef<HTMLInputElement, NumberInputProps>(
const increment = (e: MouseEvent<HTMLButtonElement>) => {
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<HTMLButtonElement>) => {
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 = () => {
Expand All @@ -83,6 +84,29 @@ export const NumberInput = forwardRef<HTMLInputElement, NumberInputProps>(
}
};

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<HTMLInputElement>) => {
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 (
<TextField
inputRef={callbackRef}
Expand Down Expand Up @@ -124,14 +148,8 @@ export const NumberInput = forwardRef<HTMLInputElement, NumberInputProps>(
</Stack>
</InputAdornment>
),
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
},
Expand Down

0 comments on commit d9e2312

Please sign in to comment.