-
Notifications
You must be signed in to change notification settings - Fork 233
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
Showing
3 changed files
with
401 additions
and
0 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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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'); | ||
}); | ||
}); |
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,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> | ||
); | ||
} |
Oops, something went wrong.