From d93de538d1f360a0eb3abcbf1434bfe91d6be6b0 Mon Sep 17 00:00:00 2001 From: Adam <alessey@gmail.com> Date: Fri, 11 Oct 2024 16:48:08 -0400 Subject: [PATCH] feat: add quantity selector (#1403) --- .../components/QuantitySelector.test.tsx | 271 ++++++++++++++++++ src/internal/components/QuantitySelector.tsx | 124 ++++++++ src/internal/components/TextInput.tsx | 6 + 3 files changed, 401 insertions(+) create mode 100644 src/internal/components/QuantitySelector.test.tsx create mode 100644 src/internal/components/QuantitySelector.tsx diff --git a/src/internal/components/QuantitySelector.test.tsx b/src/internal/components/QuantitySelector.test.tsx new file mode 100644 index 0000000000..67383fd9e8 --- /dev/null +++ b/src/internal/components/QuantitySelector.test.tsx @@ -0,0 +1,271 @@ +import { fireEvent, render } from '@testing-library/react'; +import { act } from 'react'; +import '@testing-library/jest-dom'; +import { + type Mock, + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; +import { pressable } from '../../styles/theme'; +import { DELAY_MS, QuantitySelector } from './QuantitySelector'; + +describe('QuantitySelector component', () => { + let mockOnChange: Mock; + beforeEach(() => { + vi.useFakeTimers(); + mockOnChange = vi.fn(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should render', () => { + const { getByTestId } = render( + <QuantitySelector + minQuantity={1} + onChange={mockOnChange} + placeholder="" + />, + ); + + const input = getByTestId('ockQuantitySelector'); + expect(input).toBeInTheDocument(); + }); + + it('should render disabled', () => { + const { getByTestId } = render( + <QuantitySelector + minQuantity={1} + onChange={mockOnChange} + disabled={true} + placeholder="" + />, + ); + + const decrementButton = getByTestId('ockQuantitySelector_decrement'); + const input = getByTestId('ockTextInput_Input'); + const incrementButton = getByTestId('ockQuantitySelector_increment'); + + expect(decrementButton).toHaveClass(pressable.disabled); + expect(input).toHaveClass(pressable.disabled); + expect(incrementButton).toHaveClass(pressable.disabled); + }); + + it('should render default value', () => { + const { getByTestId } = render( + <QuantitySelector + minQuantity={1} + onChange={mockOnChange} + placeholder="" + />, + ); + + const input = getByTestId('ockTextInput_Input'); + expect(input).toHaveValue('1'); + }); + + it('should increment value', () => { + const { getByTestId } = render( + <QuantitySelector + minQuantity={1} + onChange={mockOnChange} + placeholder="" + />, + ); + + const incrementButton = getByTestId('ockQuantitySelector_increment'); + act(() => { + incrementButton.click(); + }); + + const input = getByTestId('ockTextInput_Input'); + expect(input).toHaveValue('2'); + expect(mockOnChange).toHaveBeenCalledWith('2'); + }); + + it('should not increment above maxQuantity', () => { + const { getByTestId } = render( + <QuantitySelector + minQuantity={1} + onChange={mockOnChange} + maxQuantity={1} + placeholder="" + />, + ); + + const incrementButton = getByTestId('ockQuantitySelector_increment'); + act(() => { + incrementButton.click(); + }); + + const input = getByTestId('ockTextInput_Input'); + expect(input).toHaveValue('1'); + expect(mockOnChange).toHaveBeenCalledWith('1'); + }); + + it('should decrement value', () => { + const { getByTestId } = render( + <QuantitySelector + minQuantity={1} + onChange={mockOnChange} + placeholder="" + />, + ); + + const input = getByTestId('ockTextInput_Input'); + const incrementButton = getByTestId('ockQuantitySelector_increment'); + const decrementButton = getByTestId('ockQuantitySelector_decrement'); + + act(() => { + incrementButton.click(); + }); + + expect(input).toHaveValue('2'); + + act(() => { + decrementButton.click(); + }); + + expect(input).toHaveValue('1'); + expect(mockOnChange).toHaveBeenCalledWith('1'); + }); + + it('should not decrement below minQuantity', () => { + const { getByTestId } = render( + <QuantitySelector + minQuantity={1} + onChange={mockOnChange} + placeholder="" + />, + ); + + const decrementButton = getByTestId('ockQuantitySelector_decrement'); + act(() => { + decrementButton.click(); + }); + + const input = getByTestId('ockTextInput_Input'); + expect(input).toHaveValue('1'); + expect(mockOnChange).toHaveBeenCalledWith('1'); + }); + + it('should not fire onChange on empty string', () => { + const { getByTestId } = render( + <QuantitySelector + onChange={mockOnChange} + minQuantity={1} + placeholder="" + />, + ); + + const input = getByTestId('ockTextInput_Input'); + act(() => { + fireEvent.change(input, { target: { value: '' } }); + }); + + vi.advanceTimersByTime(DELAY_MS); + + expect(mockOnChange).not.toHaveBeenCalled(); + }); + + it('should not fire onChange if value is not a number', () => { + const { getByTestId } = render( + <QuantitySelector + onChange={mockOnChange} + minQuantity={1} + placeholder="" + />, + ); + + const input = getByTestId('ockTextInput_Input'); + act(() => { + fireEvent.change(input, { target: { value: 'a' } }); + }); + + vi.advanceTimersByTime(DELAY_MS); + + expect(mockOnChange).not.toHaveBeenCalled(); + }); + + it('should not fire onChange if value < minQuantity', () => { + const { getByTestId } = render( + <QuantitySelector + onChange={mockOnChange} + minQuantity={5} + placeholder="" + />, + ); + + const input = getByTestId('ockTextInput_Input'); + act(() => { + fireEvent.change(input, { target: { value: '4' } }); + }); + + vi.advanceTimersByTime(DELAY_MS); + + expect(mockOnChange).not.toHaveBeenCalled(); + }); + + it('should not fire onChange if value > maxQuantity', () => { + const { getByTestId } = render( + <QuantitySelector + onChange={mockOnChange} + maxQuantity={5} + placeholder="" + />, + ); + + const input = getByTestId('ockTextInput_Input'); + act(() => { + fireEvent.change(input, { target: { value: '6' } }); + }); + + vi.advanceTimersByTime(DELAY_MS); + + expect(mockOnChange).not.toHaveBeenCalled(); + }); + + it('should fire onChange on valid value', () => { + const { getByTestId } = render( + <QuantitySelector + onChange={mockOnChange} + minQuantity={1} + placeholder="" + />, + ); + + const input = getByTestId('ockTextInput_Input'); + act(() => { + fireEvent.change(input, { target: { value: '2' } }); + }); + + vi.advanceTimersByTime(DELAY_MS); + + expect(mockOnChange).toHaveBeenCalledWith('2'); + }); + + it('should reset to minQuantity on blur if no value', () => { + const { getByTestId } = render( + <QuantitySelector + onChange={mockOnChange} + minQuantity={1} + placeholder="" + />, + ); + + const input = getByTestId('ockTextInput_Input'); + act(() => { + input.focus(); + fireEvent.change(input, { target: { value: '' } }); + input.blur(); + }); + + expect(input).toHaveValue('1'); + expect(mockOnChange).toHaveBeenCalledWith('1'); + }); +}); diff --git a/src/internal/components/QuantitySelector.tsx b/src/internal/components/QuantitySelector.tsx new file mode 100644 index 0000000000..f751d0bcb4 --- /dev/null +++ b/src/internal/components/QuantitySelector.tsx @@ -0,0 +1,124 @@ +import { useCallback, useState } from 'react'; +import { TextInput } from '../../internal/components/TextInput'; +import { background, border, cn, color, pressable } from '../../styles/theme'; + +export const DELAY_MS = 200; + +type QuantitySelectorReact = { + className?: string; + disabled?: boolean; + minQuantity?: number; + maxQuantity?: number; + onChange: (s: string) => void; + placeholder: string; +}; + +export function QuantitySelector({ + className, + disabled, + minQuantity = 1, + maxQuantity = Number.MAX_SAFE_INTEGER, + onChange, + placeholder, +}: QuantitySelectorReact) { + const [value, setValue] = useState(`${minQuantity}`); + + // allow entering '' to enable backspace + new value, fix empty string on blur + const isValidQuantity = useCallback( + (v: string) => { + if (Number.parseInt(v, 10) < minQuantity) { + return false; + } + + if (Number.parseInt(v, 10) > maxQuantity) { + return false; + } + // only numbers are valid + const regex = /^[0-9]*$/; + return regex.test(v); + }, + [maxQuantity, minQuantity], + ); + + const handleIncrement = useCallback(() => { + const next = `${Math.min(maxQuantity, Number.parseInt(value, 10) + 1)}`; + setValue(next); + onChange(next); + }, [onChange, maxQuantity, value]); + + const handleDecrement = useCallback(() => { + const next = `${Math.max(minQuantity, Number.parseInt(value, 10) - 1)}`; + setValue(next); + onChange(next); + }, [onChange, minQuantity, value]); + + const handleOnChange = useCallback( + (v: string) => { + if (v === '') { + return; + } + + onChange(v); + }, + [onChange], + ); + + const handleBlur = useCallback(() => { + if (value === '') { + setValue(minQuantity.toString()); + onChange(minQuantity.toString()); + } + }, [onChange, minQuantity, value]); + + const classNames = cn( + 'h-11 w-11 rounded-lg border', + border.defaultActive, + color.foreground, + background.default, + disabled && pressable.disabled, + ); + + return ( + <div + className={cn('relative flex items-center gap-1', className)} + data-testid="ockQuantitySelector" + > + <div> + <button + aria-label="decrement" + className={cn(classNames, pressable.default)} + data-testid="ockQuantitySelector_decrement" + disabled={disabled} + onClick={handleDecrement} + type="button" + > + - + </button> + </div> + <TextInput + aria-label="quantity" + className={cn(classNames, 'w-full text-center')} + delayMs={DELAY_MS} + disabled={disabled} + inputValidator={isValidQuantity} + onBlur={handleBlur} + onChange={handleOnChange} + placeholder={placeholder} + setValue={setValue} + value={value} + /> + <div> + <button + aria-label="increment" + className={cn(classNames, pressable.default)} + data-testid="ockQuantitySelector_increment" + disabled={disabled} + onClick={handleIncrement} + type="button" + > + + + </button> + </div> + </div> + ); +} diff --git a/src/internal/components/TextInput.tsx b/src/internal/components/TextInput.tsx index a88107977a..9fae128aef 100644 --- a/src/internal/components/TextInput.tsx +++ b/src/internal/components/TextInput.tsx @@ -3,9 +3,11 @@ import type { ChangeEvent } from 'react'; import { useDebounce } from '../hooks/useDebounce'; type TextInputReact = { + 'aria-label'?: string; className: string; delayMs: number; disabled?: boolean; + onBlur?: () => void; onChange: (s: string) => void; placeholder: string; setValue: (s: string) => void; @@ -14,9 +16,11 @@ type TextInputReact = { }; export function TextInput({ + 'aria-label': ariaLabel, className, delayMs, disabled = false, + onBlur, onChange, placeholder, setValue, @@ -45,11 +49,13 @@ export function TextInput({ return ( <input + aria-label={ariaLabel} data-testid="ockTextInput_Input" type="text" className={className} placeholder={placeholder} value={value} + onBlur={onBlur} onChange={handleChange} disabled={disabled} />