Skip to content

Commit

Permalink
feat: add quantity selector (#1403)
Browse files Browse the repository at this point in the history
  • Loading branch information
alessey authored Oct 11, 2024
1 parent 79c8960 commit d93de53
Show file tree
Hide file tree
Showing 3 changed files with 401 additions and 0 deletions.
271 changes: 271 additions & 0 deletions src/internal/components/QuantitySelector.test.tsx
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');
});
});
124 changes: 124 additions & 0 deletions src/internal/components/QuantitySelector.tsx
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>
);
}
Loading

0 comments on commit d93de53

Please sign in to comment.