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.
- ensures that stepper buttons are always visible
- Loading branch information
1 parent
7881dff
commit b3521bd
Showing
4 changed files
with
259 additions
and
23 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,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<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 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 ( | ||
<TextField | ||
inputRef={callbackRef} | ||
type="number" | ||
disabled={disabled} | ||
size={size} | ||
slotProps={{ | ||
...slotProps, | ||
input: { | ||
sx: { paddingRight: '8px' }, | ||
endAdornment: ( | ||
<InputAdornment position="end"> | ||
<Stack spacing={0.1}> | ||
<IconButton | ||
aria-label="Increment" | ||
disabled={disabled} | ||
onClick={(e) => { | ||
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} | ||
> | ||
<BiSolidUpArrow fontSize={size} /> | ||
</IconButton> | ||
<IconButton | ||
aria-label="Decrement" | ||
disabled={disabled} | ||
onClick={(e) => { | ||
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} | ||
> | ||
<BiSolidDownArrow fontSize={size} /> | ||
</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} | ||
/> | ||
); | ||
} | ||
); |