diff --git a/.changeset/free-bats-create.md b/.changeset/free-bats-create.md new file mode 100644 index 00000000..0c921be7 --- /dev/null +++ b/.changeset/free-bats-create.md @@ -0,0 +1,5 @@ +--- +"@devup-ui/components": patch +--- + +Add Stepper component diff --git a/packages/components/src/__tests__/index.browser.test.ts b/packages/components/src/__tests__/index.browser.test.ts index e19fbb2f..483b8f2c 100644 --- a/packages/components/src/__tests__/index.browser.test.ts +++ b/packages/components/src/__tests__/index.browser.test.ts @@ -4,6 +4,7 @@ describe('export', () => { expect({ ...index }).toEqual({ Button: expect.any(Function), Input: expect.any(Function), + Stepper: expect.any(Function), }) }) }) diff --git a/packages/components/src/components/Stepper/IconMinus.tsx b/packages/components/src/components/Stepper/IconMinus.tsx new file mode 100644 index 00000000..e533ea5a --- /dev/null +++ b/packages/components/src/components/Stepper/IconMinus.tsx @@ -0,0 +1,21 @@ +import { SVGProps } from 'react' + +export function IconMinus({ ...props }: SVGProps) { + return ( + + + + ) +} diff --git a/packages/components/src/components/Stepper/IconPlus.tsx b/packages/components/src/components/Stepper/IconPlus.tsx new file mode 100644 index 00000000..1d08f99c --- /dev/null +++ b/packages/components/src/components/Stepper/IconPlus.tsx @@ -0,0 +1,19 @@ +import { SVGProps } from 'react' + +export function IconPlus({ ...props }: SVGProps) { + return ( + + + + ) +} diff --git a/packages/components/src/components/Stepper/Stepper.stories.tsx b/packages/components/src/components/Stepper/Stepper.stories.tsx new file mode 100644 index 00000000..772f6f4d --- /dev/null +++ b/packages/components/src/components/Stepper/Stepper.stories.tsx @@ -0,0 +1,39 @@ +import { Meta, StoryObj } from '@storybook/react-vite' + +import { + Stepper, + StepperContainer, + StepperDecreaseButton, + StepperIncreaseButton, + StepperInput, +} from './index' + +type Story = StoryObj + +// More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export +const meta: Meta = { + title: 'Devfive/Stepper', + component: Stepper, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} + +export const Default: Story = { + args: {}, + render: (args) => ( + + + + + + + + ), +} + +export default meta diff --git a/packages/components/src/components/Stepper/__tests__/__snapshots__/index.browser.test.tsx.snap b/packages/components/src/components/Stepper/__tests__/__snapshots__/index.browser.test.tsx.snap new file mode 100644 index 00000000..045b35b7 --- /dev/null +++ b/packages/components/src/components/Stepper/__tests__/__snapshots__/index.browser.test.tsx.snap @@ -0,0 +1,79 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Stepper > should render 1`] = ` +
+
+ +
+ 0 +
+ +
+
+`; diff --git a/packages/components/src/components/Stepper/__tests__/index.browser.test.tsx b/packages/components/src/components/Stepper/__tests__/index.browser.test.tsx new file mode 100644 index 00000000..ffaa03b2 --- /dev/null +++ b/packages/components/src/components/Stepper/__tests__/index.browser.test.tsx @@ -0,0 +1,137 @@ +import { fireEvent, render } from '@testing-library/react' + +import { + Stepper, + StepperContainer, + StepperDecreaseButton, + StepperIncreaseButton, + StepperInput, +} from '..' + +describe('Stepper', () => { + it('should render', () => { + const { container } = render( + + + + + + + , + ) + expect(container).toMatchSnapshot() + }) + + it('should throw error if children are used outside of StepperProvider', () => { + expect(() => { + render() + }).toThrow('useStepper must be used within a StepperProvider') + }) + + it('should call onValueChange when value is changed', () => { + const onValueChange = vi.fn() + const { container } = render( + + + , + ) + const input = container.querySelector('[aria-label="Stepper value"]') + fireEvent.change(input!, { target: { value: '10' } }) + expect(onValueChange).toHaveBeenCalledWith(10) + }) + + it('should change inner value when onValueChange is not provided', () => { + const { container } = render( + + + , + ) + const input = container.querySelector('[aria-label="Stepper value"]') + fireEvent.change(input!, { target: { value: '10' } }) + expect(input).toHaveAttribute('data-value', '10') + }) + + it('should have disabled decrease button when value is at min', () => { + const { container } = render( + + + + + , + ) + const decreaseButton = container.querySelector( + '[aria-label="Decrease button"] svg', + ) + fireEvent.change(container.querySelector('[aria-label="Stepper value"]')!, { + target: { value: '0' }, + }) + expect(decreaseButton).toHaveClass( + 'color-0-var(--base10,light-dark(#0000001A,#FFFFFF1A))--255', + ) + }) + + it('should have disabled increase button when value is at max', () => { + const { container } = render( + + + + + , + ) + const increaseButton = container.querySelector( + '[aria-label="Increase button"] svg', + ) + fireEvent.change(container.querySelector('[aria-label="Stepper value"]')!, { + target: { value: '100' }, + }) + expect(increaseButton).toHaveClass( + 'color-0-var(--base10,light-dark(#0000001A,#FFFFFF1A))--255', + ) + }) + + it('should export components', async () => { + const index = await import('../index') + expect({ ...index }).toEqual({ + Stepper: expect.any(Function), + StepperContainer: expect.any(Function), + StepperDecreaseButton: expect.any(Function), + StepperIncreaseButton: expect.any(Function), + StepperInput: expect.any(Function), + useStepper: expect.any(Function), + }) + }) + + it('should increase value when increase button is clicked', () => { + const { container } = render( + + + + + , + ) + const increaseButton = container.querySelector( + '[aria-label="Increase button"]', + ) + fireEvent.click(increaseButton!) + expect( + container.querySelector('[aria-label="Stepper value"]'), + ).toHaveAttribute('data-value', '1') + }) + + it('should decrease value when decrease button is clicked', () => { + const { container } = render( + + + + + , + ) + const decreaseButton = container.querySelector( + '[aria-label="Decrease button"]', + ) + fireEvent.click(decreaseButton!) + expect( + container.querySelector('[aria-label="Stepper value"]'), + ).toHaveAttribute('data-value', '0') + }) +}) diff --git a/packages/components/src/components/Stepper/index.tsx b/packages/components/src/components/Stepper/index.tsx new file mode 100644 index 00000000..f7f427bf --- /dev/null +++ b/packages/components/src/components/Stepper/index.tsx @@ -0,0 +1,189 @@ +'use client' + +import { css, Flex } from '@devup-ui/react' +import clsx from 'clsx' +import { ComponentProps, createContext, useContext, useState } from 'react' + +import { Button } from '../Button' +import { Input } from '../Input' +import { IconMinus } from './IconMinus' +import { IconPlus } from './IconPlus' + +type StepperContextType = { + value: number + setValue: (value: number) => void + min: number + max: number +} + +const StepperContext = createContext(null) + +export const useStepper = () => { + const context = useContext(StepperContext) + if (!context) { + throw new Error('useStepper must be used within a StepperProvider') + } + return context +} + +type StepperProps = { + children?: React.ReactNode + defaultValue?: number + value?: number + onValueChange?: (value: number) => void + min?: number + max?: number +} + +function Stepper({ + children, + defaultValue, + value: valueProp, + onValueChange, + min = 0, + max = 100, +}: StepperProps) { + const [value, setValue] = useState(defaultValue ?? 0) + + const handleChange = (nextValue: number) => { + const sanitized = Math.min(Math.max(nextValue, min), max) + if (onValueChange) { + onValueChange(sanitized) + return + } + setValue(sanitized) + } + + return ( + + {children} + + ) +} + +function StepperContainer(props: ComponentProps<'div'>) { + return +} + +function StepperDecreaseButton({ ...props }: ComponentProps) { + const { value, setValue, min } = useStepper() + const disabled = value <= min + return ( + + ) +} + +function StepperIncreaseButton({ ...props }: ComponentProps) { + const { value, setValue, max } = useStepper() + const disabled = value >= max + return ( + + ) +} + +interface StepperInputProps extends ComponentProps { + editable?: boolean +} + +function StepperInput({ + editable = true, + className, + ...props +}: StepperInputProps) { + const { value, setValue } = useStepper() + const notEditableClass = css({ + p: '0', + border: 'none', + w: 'fit-content', + h: 'fit-content', + styleOrder: 3, + }) + + const Comp = editable ? Input : 'div' + + return ( + setValue(Number(e.target.value))} + readOnly={!editable} + type="number" + value={value} + {...props} + /> + ) +} + +export { + Stepper, + StepperContainer, + StepperDecreaseButton, + StepperIncreaseButton, + StepperInput, +} diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index 819f4919..d7d1663e 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -1,2 +1,3 @@ export { Button } from './components/Button' export { Input } from './components/Input' +export { Stepper } from './components/Stepper'