From b3521bddbeda08b40a41eb7af8706a22a2e30afb Mon Sep 17 00:00:00 2001 From: thenick775 Date: Tue, 24 Dec 2024 13:02:53 -0800 Subject: [PATCH] feat: number input - ensures that stepper buttons are always visible --- .../components/modals/save-states.spec.tsx | 18 --- gbajs3/src/components/modals/save-states.tsx | 9 +- .../components/shared/number-input.spec.tsx | 103 ++++++++++++ gbajs3/src/components/shared/number-input.tsx | 152 ++++++++++++++++++ 4 files changed, 259 insertions(+), 23 deletions(-) create mode 100644 gbajs3/src/components/shared/number-input.spec.tsx create mode 100644 gbajs3/src/components/shared/number-input.tsx 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} + /> + ); + } +);