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}
     />