forked from andychase/gbajs2
-
Notifications
You must be signed in to change notification settings - Fork 30
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
7881dff
commit 31a4bcc
Showing
4 changed files
with
265 additions
and
24 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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} | ||
/> | ||
); | ||
} | ||
); |