From 4b23cd67c4467a2e0779de9985325e020b1a28e7 Mon Sep 17 00:00:00 2001 From: Hanbin Cho Date: Fri, 8 Aug 2025 14:30:01 +0900 Subject: [PATCH 1/9] Init Stepper comp --- packages/components/src/components/Stepper/Stepper.stories.tsx | 0 .../src/components/Stepper/__tests__/index.browser.test.tsx | 0 packages/components/src/components/Stepper/index.tsx | 3 +++ 3 files changed, 3 insertions(+) create mode 100644 packages/components/src/components/Stepper/Stepper.stories.tsx create mode 100644 packages/components/src/components/Stepper/__tests__/index.browser.test.tsx create mode 100644 packages/components/src/components/Stepper/index.tsx 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..e69de29b 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..e69de29b diff --git a/packages/components/src/components/Stepper/index.tsx b/packages/components/src/components/Stepper/index.tsx new file mode 100644 index 00000000..5c77c34d --- /dev/null +++ b/packages/components/src/components/Stepper/index.tsx @@ -0,0 +1,3 @@ +export function Stepper() { + return <> +} From ed6203cf1ceceb9754fbbc1b670c0152818684ff Mon Sep 17 00:00:00 2001 From: Hanbin Cho Date: Fri, 8 Aug 2025 15:35:38 +0900 Subject: [PATCH 2/9] Add Stepper story --- .../components/Stepper/Stepper.stories.tsx | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/packages/components/src/components/Stepper/Stepper.stories.tsx b/packages/components/src/components/Stepper/Stepper.stories.tsx index e69de29b..49b69513 100644 --- a/packages/components/src/components/Stepper/Stepper.stories.tsx +++ b/packages/components/src/components/Stepper/Stepper.stories.tsx @@ -0,0 +1,26 @@ +import { Meta, StoryObj } from '@storybook/react-vite' + +import { Stepper } 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: { + value: 1, + }, +} + +export default meta From 9dc782881d813290fb9b3f337134f417dbf07fc7 Mon Sep 17 00:00:00 2001 From: Hanbin Cho Date: Fri, 8 Aug 2025 15:58:51 +0900 Subject: [PATCH 3/9] Add Stepper --- .../src/components/Stepper/index.tsx | 167 +++++++++++++++++- 1 file changed, 165 insertions(+), 2 deletions(-) diff --git a/packages/components/src/components/Stepper/index.tsx b/packages/components/src/components/Stepper/index.tsx index 5c77c34d..2b435d87 100644 --- a/packages/components/src/components/Stepper/index.tsx +++ b/packages/components/src/components/Stepper/index.tsx @@ -1,3 +1,166 @@ -export function Stepper() { - return <> +'use client' + +import { css, Flex, Text } from '@devup-ui/react' +import clsx from 'clsx' +import { ChangeEvent, useState } from 'react' + +import { Button } from '../Button' +import { Input } from '../Input' + +type InputProps = Omit< + React.InputHTMLAttributes, + | 'value' + | 'onChange' + | 'defaultValue' + | 'checked' + | 'defaultChecked' + | 'min' + | 'max' +> & { + type?: 'text' | 'input' + value?: number + onChange?: (value: number) => void + defaultValue?: number + min?: number | null + max?: number | null + classNames?: { + button?: string + container?: string + input?: string + } + styles?: { + button?: React.CSSProperties + container?: React.CSSProperties + input?: React.CSSProperties + } +} + +function valid(min: number | null, max: number | null, inp: number): boolean { + return !((max !== null && inp > max) || (min !== null && inp < min)) +} + +export function Stepper({ + className, + classNames, + type = 'input', + value, + onChange, + defaultValue, + min = 0, + max = 100, + disabled = false, + styles, + ...props +}: InputProps) { + const [internalValue, setInternalValue] = useState(value ?? defaultValue ?? 0) + const resultValue = value ?? internalValue + function handleChange(_value: number) { + onChange?.(_value) + setInternalValue(_value) + } + function handleInputChange(event: ChangeEvent) { + if (!event.target.value.length) { + const _value = + min !== null && min > 0 ? min : max !== null && max < 0 ? max : 0 + handleChange(_value) + return + } + const _value = parseInt(event.target.value) + if (!valid(min, max, _value)) { + return + } + handleChange(_value) + } + + const handleClick = (type: 'add' | 'sub') => { + const targetValue = resultValue + (type === 'sub' ? -1 : 1) + handleChange(targetValue) + } + + return ( + + + {type === 'text' && ( + + {resultValue} + + )} + + + + ) } From 890e7ab5bd646cb88d644a87ae29b515504f363d Mon Sep 17 00:00:00 2001 From: Hanbin Cho Date: Fri, 8 Aug 2025 17:30:45 +0900 Subject: [PATCH 4/9] Fix Stepper --- .../src/components/Stepper/IconMinus.tsx | 21 ++ .../src/components/Stepper/IconPlus.tsx | 19 ++ .../components/Stepper/Stepper.stories.tsx | 21 +- .../src/components/Stepper/index.tsx | 303 ++++++++++-------- 4 files changed, 219 insertions(+), 145 deletions(-) create mode 100644 packages/components/src/components/Stepper/IconMinus.tsx create mode 100644 packages/components/src/components/Stepper/IconPlus.tsx 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 index 49b69513..772f6f4d 100644 --- a/packages/components/src/components/Stepper/Stepper.stories.tsx +++ b/packages/components/src/components/Stepper/Stepper.stories.tsx @@ -1,6 +1,12 @@ import { Meta, StoryObj } from '@storybook/react-vite' -import { Stepper } from './index' +import { + Stepper, + StepperContainer, + StepperDecreaseButton, + StepperIncreaseButton, + StepperInput, +} from './index' type Story = StoryObj @@ -18,9 +24,16 @@ const meta: Meta = { } export const Default: Story = { - args: { - value: 1, - }, + args: {}, + render: (args) => ( + + + + + + + + ), } export default meta diff --git a/packages/components/src/components/Stepper/index.tsx b/packages/components/src/components/Stepper/index.tsx index 2b435d87..b337e77d 100644 --- a/packages/components/src/components/Stepper/index.tsx +++ b/packages/components/src/components/Stepper/index.tsx @@ -1,166 +1,187 @@ 'use client' -import { css, Flex, Text } from '@devup-ui/react' +import { css, Flex } from '@devup-ui/react' import clsx from 'clsx' -import { ChangeEvent, useState } from 'react' +import { ComponentProps, createContext, useContext, useState } from 'react' import { Button } from '../Button' import { Input } from '../Input' +import { IconMinus } from './IconMinus' +import { IconPlus } from './IconPlus' -type InputProps = Omit< - React.InputHTMLAttributes, - | 'value' - | 'onChange' - | 'defaultValue' - | 'checked' - | 'defaultChecked' - | 'min' - | 'max' -> & { - type?: 'text' | 'input' - value?: number - onChange?: (value: number) => void - defaultValue?: number - min?: number | null - max?: number | null - classNames?: { - button?: string - container?: string - input?: string - } - styles?: { - button?: React.CSSProperties - container?: React.CSSProperties - input?: React.CSSProperties +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 } -function valid(min: number | null, max: number | null, inp: number): boolean { - return !((max !== null && inp > max) || (min !== null && inp < min)) +type StepperProps = { + children: React.ReactNode + defaultValue: number + value: number + onValueChange: (value: number) => void + min?: number + max?: number } -export function Stepper({ - className, - classNames, - type = 'input', - value, - onChange, +function Stepper({ + children, defaultValue, + value: valueProp, + onValueChange, min = 0, max = 100, - disabled = false, - styles, - ...props -}: InputProps) { - const [internalValue, setInternalValue] = useState(value ?? defaultValue ?? 0) - const resultValue = value ?? internalValue - function handleChange(_value: number) { - onChange?.(_value) - setInternalValue(_value) - } - function handleInputChange(event: ChangeEvent) { - if (!event.target.value.length) { - const _value = - min !== null && min > 0 ? min : max !== null && max < 0 ? max : 0 - handleChange(_value) - return - } - const _value = parseInt(event.target.value) - if (!valid(min, max, _value)) { +}: 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 } - handleChange(_value) + setValue(sanitized) } - const handleClick = (type: 'add' | 'sub') => { - const targetValue = resultValue + (type === 'sub' ? -1 : 1) - handleChange(targetValue) - } + return ( + + {children} + + ) +} +function StepperContainer(props: ComponentProps<'div'>) { + return +} + +function StepperDecreaseButton({ ...props }: ComponentProps) { + const { value, setValue, min } = useStepper() + const disabled = value <= min return ( - div>div': {}, + }, + })} + disabled={disabled} + onClick={() => setValue(value - 1)} + {...props} > - - {type === 'text' && ( - - {resultValue} - - )} - - - + ) } + +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, +} From f125cadd78d52e81a6ce97624383a6115fb7c0df Mon Sep 17 00:00:00 2001 From: Hanbin Cho Date: Fri, 8 Aug 2025 17:32:13 +0900 Subject: [PATCH 5/9] Fix Stepper prop types --- packages/components/src/components/Stepper/index.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/components/src/components/Stepper/index.tsx b/packages/components/src/components/Stepper/index.tsx index b337e77d..b0b62396 100644 --- a/packages/components/src/components/Stepper/index.tsx +++ b/packages/components/src/components/Stepper/index.tsx @@ -27,10 +27,10 @@ export const useStepper = () => { } type StepperProps = { - children: React.ReactNode - defaultValue: number - value: number - onValueChange: (value: number) => void + children?: React.ReactNode + defaultValue?: number + value?: number + onValueChange?: (value: number) => void min?: number max?: number } From 507326949a0d5cd218acd03161935025db6391a7 Mon Sep 17 00:00:00 2001 From: Hanbin Cho Date: Fri, 8 Aug 2025 17:49:10 +0900 Subject: [PATCH 6/9] Export Stepper --- packages/components/src/__tests__/index.browser.test.ts | 1 + packages/components/src/index.ts | 1 + 2 files changed, 2 insertions(+) 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/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' From 3fc373c261da494588724ae76cda49f75c2d8fc3 Mon Sep 17 00:00:00 2001 From: Hanbin Cho Date: Fri, 8 Aug 2025 17:49:18 +0900 Subject: [PATCH 7/9] Add test cases --- .../__snapshots__/index.browser.test.tsx.snap | 79 ++++++++++++++ .../Stepper/__tests__/index.browser.test.tsx | 103 ++++++++++++++++++ .../src/components/Stepper/index.tsx | 6 +- 3 files changed, 186 insertions(+), 2 deletions(-) create mode 100644 packages/components/src/components/Stepper/__tests__/__snapshots__/index.browser.test.tsx.snap 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 index e69de29b..8c1b62e0 100644 --- a/packages/components/src/components/Stepper/__tests__/index.browser.test.tsx +++ b/packages/components/src/components/Stepper/__tests__/index.browser.test.tsx @@ -0,0 +1,103 @@ +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), + }) + }) +}) diff --git a/packages/components/src/components/Stepper/index.tsx b/packages/components/src/components/Stepper/index.tsx index b0b62396..f7f427bf 100644 --- a/packages/components/src/components/Stepper/index.tsx +++ b/packages/components/src/components/Stepper/index.tsx @@ -72,7 +72,7 @@ function StepperDecreaseButton({ ...props }: ComponentProps) { const disabled = value <= min return (