Skip to content

Commit

Permalink
feat: number input
Browse files Browse the repository at this point in the history
  • Loading branch information
thenick775 committed Dec 25, 2024
1 parent 7881dff commit 31a4bcc
Show file tree
Hide file tree
Showing 4 changed files with 265 additions and 24 deletions.
18 changes: 0 additions & 18 deletions gbajs3/src/components/modals/save-states.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -201,24 +201,6 @@ describe('<SaveStatesModal />', () => {
expect(screen.getByText('Failed to load save state')).toBeVisible();
});

it('renders form validations', async () => {
renderWithContext(<SaveStatesModal />);

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<
Expand Down
11 changes: 5 additions & 6 deletions gbajs3/src/components/modals/save-states.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 = {
Expand Down Expand Up @@ -170,16 +171,14 @@ export const SaveStatesModal = () => {
id={`${baseId}--save-state-slot-form`}
onSubmit={handleSubmit(onSubmit)}
>
<TextField
<NumberInput
label="Current Save State Slot"
type="number"
size="small"
error={!!errors?.saveStateSlot}
helperText={errors?.saveStateSlot?.message}
min={0}
slotProps={{ inputLabel: { shrink: true } }}
{...register('saveStateSlot', {
required: { value: true, message: 'Slot is required' },
min: { value: 0, message: 'Slot must be >= 0' },
valueAsNumber: true
})}
/>
Expand All @@ -188,7 +187,7 @@ export const SaveStatesModal = () => {
showSuccess={isSubmitSuccessful}
size="small"
type="submit"
sx={{ maxHeight: '40px' }}
sx={{ maxHeight: '40px', minWidth: 'fit-content' }}
/>
</StyledForm>

Expand Down
103 changes: 103 additions & 0 deletions gbajs3/src/components/shared/number-input.spec.tsx
Original file line number Diff line number Diff line change
@@ -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('<NumberInput />', () => {
it('renders correctly with default props', () => {
render(<NumberInput />);

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

expect(inputElement).toBeInTheDocument();
expect(inputElement).toBeEnabled();
});

it('increments value when increment button is clicked', async () => {
render(<NumberInput defaultValue="5" step={2} />);

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

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

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

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

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

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

await userEvent.type(inputElement, '{backspace}');
fireEvent.blur(inputElement);

expect(inputElement).toHaveValue(0);
});

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

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<HTMLInputElement>;

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

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

expect(ref.current).toBe(inputElement);
expect(ref.current).toBeInTheDocument();
});
});
157 changes: 157 additions & 0 deletions gbajs3/src/components/shared/number-input.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
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<HTMLButtonElement>) =>
event.preventDefault();

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

const callbackRef = (element: HTMLInputElement | null) => {
internalRef.current = element;
if (typeof externalRef === 'function') externalRef(element);
else if (externalRef) externalRef.current = element;
};

const clamp = (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 dispatchEvent = (value: string) => {
const setter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
'value'
)?.set;
setter?.call(internalRef.current, value);
internalRef.current?.dispatchEvent(new Event('input', { bubbles: true }));
};

const increment = (e: MouseEvent<HTMLButtonElement>) => {
preventDefault(e);

if (!internalRef.current) return;

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);
};

const enforceRange = () => {
if (internalRef.current) {
const currentValue = Number(internalRef.current.valueAsNumber || 0);
internalRef.current.value = clamp(currentValue);
internalRef.current.dispatchEvent(
new Event('input', { bubbles: true })
);
}
};

return (
<TextField
inputRef={callbackRef}
type="number"
disabled={disabled}
size={size}
slotProps={{
...slotProps,
input: {
sx: { paddingRight: '8px' },
endAdornment: (
<InputAdornment
position="end"
sx={{
position: 'absolute',
paddingRight: '8px',
right: '0px',
height: '100%',
maxHeight: '100%'
}}
>
<Stack spacing={0.1}>
<IconButton
aria-label="Increment"
disabled={disabled}
onClick={increment}
{...commonAdornmentButtonProps}
>
<BiSolidUpArrow fontSize={16} />
</IconButton>
<IconButton
aria-label="Decrement"
disabled={disabled}
onClick={decrement}
{...commonAdornmentButtonProps}
>
<BiSolidDownArrow fontSize={16} />
</IconButton>
</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();
}
},
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}
/>
);
}
);

0 comments on commit 31a4bcc

Please sign in to comment.