From bd321aee54b8c23accf8063651de38bc168a2913 Mon Sep 17 00:00:00 2001 From: Hanbin Cho Date: Wed, 6 Aug 2025 12:27:14 +0900 Subject: [PATCH 01/11] Add Input component --- .../src/components/Input/Input.stories.tsx | 122 ++++++++++++++++ .../Input/__tests__/index.browser.test.tsx | 0 .../components/src/components/Input/index.tsx | 133 ++++++++++++++++++ 3 files changed, 255 insertions(+) create mode 100644 packages/components/src/components/Input/Input.stories.tsx create mode 100644 packages/components/src/components/Input/__tests__/index.browser.test.tsx create mode 100644 packages/components/src/components/Input/index.tsx diff --git a/packages/components/src/components/Input/Input.stories.tsx b/packages/components/src/components/Input/Input.stories.tsx new file mode 100644 index 00000000..35469ab8 --- /dev/null +++ b/packages/components/src/components/Input/Input.stories.tsx @@ -0,0 +1,122 @@ +import { Meta, StoryObj } from '@storybook/react-vite' + +import { Input } 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/Input', + component: Input, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} + +export const Default: Story = { + args: { + placeholder: 'Input text', + }, +} + +export const Error: Story = { + args: { + placeholder: 'Input text', + error: true, + errorMessage: 'Error message', + }, +} + +export const Disabled: Story = { + args: { + placeholder: 'Input text', + disabled: true, + }, +} + +// export const WithIcon: Story = { +// args: { +// disabled: true, +// icon: ( +// +// +// +// ), +// }, +// } + +// export const WithForm: Story = { +// args: { +// children: 'Input text', +// type: 'submit', +// }, +// decorators: [ +// (Story, { args }: { args: Story['args'] }) => { +// const [submitted, setSubmitted] = useState<{ text?: string }>({}) +// const [value, setValue] = useState('') +// const [error, setError] = useState('') + +// return ( +// <> +//
{submitted.text}
+//
{ +// e.preventDefault() +// const formData = new FormData(e.target as HTMLFormElement) +// const data = Object.fromEntries(formData) + +// setSubmitted({ +// text: data.text as string, +// }) +// }} +// > +// { +// setValue(e.target.value) +// setError( +// !/[0-9]/.test(e.target.value) && e.target.value.length >= 3 +// ? 'Include one or more numbers.' +// : '', +// ) +// }} +// placeholder="Include one or more numbers." +// required +// type="text" +// /> +// +// +// +// ) +// }, +// ], +// } + +export default meta diff --git a/packages/components/src/components/Input/__tests__/index.browser.test.tsx b/packages/components/src/components/Input/__tests__/index.browser.test.tsx new file mode 100644 index 00000000..e69de29b diff --git a/packages/components/src/components/Input/index.tsx b/packages/components/src/components/Input/index.tsx new file mode 100644 index 00000000..b83d375e --- /dev/null +++ b/packages/components/src/components/Input/index.tsx @@ -0,0 +1,133 @@ +'use client' + +import { + Box, + Button, + css, + DevupThemeTypography, + Input as DevupInput, +} from '@devup-ui/react' +import { ComponentProps, useState } from 'react' + +interface InputProps extends ComponentProps<'input'> { + typography?: keyof DevupThemeTypography + error?: boolean + errorMessage?: string + allowClear?: boolean +} + +export function Input({ + defaultValue, + value: valueProp, + onChange: onChangeProp, + typography, + error = false, + errorMessage, + allowClear = false, + ...props +}: InputProps) { + const [value, setValue] = useState(defaultValue ?? '') + const handleChange = (e: React.ChangeEvent) => { + setValue(e.target.value) + } + const handleClear = () => { + setValue('') + } + const isClearButtonVisible = value && !props.disabled + + return ( + + + {isClearButtonVisible && ( + + )} + + ) +} + +export function ClearButton(props: ComponentProps<'button'>) { + return ( + + ) +} From 6bec0a1c8215c60974c320305d9774808d275745 Mon Sep 17 00:00:00 2001 From: Hanbin Cho Date: Wed, 6 Aug 2025 13:53:23 +0900 Subject: [PATCH 02/11] Add icon prop in Input --- .../src/components/Input/Input.stories.tsx | 43 +++++++++---------- .../components/src/components/Input/index.tsx | 30 ++++++++++--- 2 files changed, 46 insertions(+), 27 deletions(-) diff --git a/packages/components/src/components/Input/Input.stories.tsx b/packages/components/src/components/Input/Input.stories.tsx index 35469ab8..03753f57 100644 --- a/packages/components/src/components/Input/Input.stories.tsx +++ b/packages/components/src/components/Input/Input.stories.tsx @@ -38,28 +38,27 @@ export const Disabled: Story = { }, } -// export const WithIcon: Story = { -// args: { -// disabled: true, -// icon: ( -// -// -// -// ), -// }, -// } +export const WithIcon: Story = { + args: { + placeholder: 'Input text', + icon: ( + + + + ), + }, +} // export const WithForm: Story = { // args: { diff --git a/packages/components/src/components/Input/index.tsx b/packages/components/src/components/Input/index.tsx index b83d375e..03c2aeb5 100644 --- a/packages/components/src/components/Input/index.tsx +++ b/packages/components/src/components/Input/index.tsx @@ -3,6 +3,7 @@ import { Box, Button, + Center, css, DevupThemeTypography, Input as DevupInput, @@ -14,6 +15,7 @@ interface InputProps extends ComponentProps<'input'> { error?: boolean errorMessage?: string allowClear?: boolean + icon?: React.ReactNode } export function Input({ @@ -24,6 +26,7 @@ export function Input({ error = false, errorMessage, allowClear = false, + icon, ...props }: InputProps) { const [value, setValue] = useState(defaultValue ?? '') @@ -33,10 +36,26 @@ export function Input({ const handleClear = () => { setValue('') } - const isClearButtonVisible = value && !props.disabled + const clearButtonVisible = value && !props.disabled return ( - + *': { boxSizing: 'border-box' } }} + w="fit-content" + > + {icon && ( +
+ {icon} +
+ )} - {isClearButtonVisible && ( + {clearButtonVisible && ( Date: Wed, 6 Aug 2025 15:05:21 +0900 Subject: [PATCH 03/11] Add color values --- .../src/components/Input/Input.stories.tsx | 61 +------------- .../components/src/components/Input/index.tsx | 80 +++++++++++++++---- 2 files changed, 66 insertions(+), 75 deletions(-) diff --git a/packages/components/src/components/Input/Input.stories.tsx b/packages/components/src/components/Input/Input.stories.tsx index 03753f57..1f5daab6 100644 --- a/packages/components/src/components/Input/Input.stories.tsx +++ b/packages/components/src/components/Input/Input.stories.tsx @@ -41,6 +41,7 @@ export const Disabled: Story = { export const WithIcon: Story = { args: { placeholder: 'Input text', + allowClear: true, icon: ( @@ -60,62 +61,4 @@ export const WithIcon: Story = { }, } -// export const WithForm: Story = { -// args: { -// children: 'Input text', -// type: 'submit', -// }, -// decorators: [ -// (Story, { args }: { args: Story['args'] }) => { -// const [submitted, setSubmitted] = useState<{ text?: string }>({}) -// const [value, setValue] = useState('') -// const [error, setError] = useState('') - -// return ( -// <> -//
{submitted.text}
-//
{ -// e.preventDefault() -// const formData = new FormData(e.target as HTMLFormElement) -// const data = Object.fromEntries(formData) - -// setSubmitted({ -// text: data.text as string, -// }) -// }} -// > -// { -// setValue(e.target.value) -// setError( -// !/[0-9]/.test(e.target.value) && e.target.value.length >= 3 -// ? 'Include one or more numbers.' -// : '', -// ) -// }} -// placeholder="Include one or more numbers." -// required -// type="text" -// /> -// -// -// -// ) -// }, -// ], -// } - export default meta diff --git a/packages/components/src/components/Input/index.tsx b/packages/components/src/components/Input/index.tsx index 03c2aeb5..7a59ddd5 100644 --- a/packages/components/src/components/Input/index.tsx +++ b/packages/components/src/components/Input/index.tsx @@ -7,14 +7,31 @@ import { css, DevupThemeTypography, Input as DevupInput, + Text, } from '@devup-ui/react' import { ComponentProps, useState } from 'react' -interface InputProps extends ComponentProps<'input'> { +interface InputProps extends Omit, 'className'> { typography?: keyof DevupThemeTypography error?: boolean errorMessage?: string allowClear?: boolean + classNames?: { + input?: string + icon?: string + errorMessage?: string + } + colors?: { + primary?: string + error?: string + text?: string + base?: string + iconBold?: string + border?: string + inputBackground?: string + primaryFocus?: string + negative20?: string + } icon?: React.ReactNode } @@ -27,6 +44,9 @@ export function Input({ errorMessage, allowClear = false, icon, + colors, + disabled, + classNames, ...props }: InputProps) { const [value, setValue] = useState(defaultValue ?? '') @@ -36,7 +56,7 @@ export function Input({ const handleClear = () => { setValue('') } - const clearButtonVisible = value && !props.disabled + const clearButtonVisible = value && !disabled return ( @@ -60,38 +86,46 @@ export function Input({ _disabled={{ selectors: { '&::placeholder': { - color: '$inputDisabledText', + color: 'var(--inputDisabledText, light-dark(#D6D7DE, #373737))', }, }, - bg: '$inputDisabledBg', - border: '1px solid $border', - color: '$inputDisabledText', + bg: 'var(--inputDisabledBg, light-dark(#F0F0F3, #414244))', + border: '1px solid var(--border, light-dark(#E4E4E4, #434343))', + color: 'var(--inputDisabledText, light-dark(#D6D7DE, #373737))', }} _focus={{ - bg: '$primaryBg', - border: '1px solid $primary', + bg: 'var(--primaryBg, light-dark(#F4F3FA, #F4F3FA0D))', + border: '1px solid var(--primary, light-dark(#674DC7, #8163E1))', outline: 'none', }} _hover={{ - border: '1px solid $primary', + border: '1px solid var(--primary, light-dark(red, blue))', }} - bg="$inputBg" - border={error ? '1px solid $error' : '1px solid $border'} + bg="var(--inputBg, light-dark(#FFFFFF, #2E2E2E))" + borderColor={ + error + ? 'var(--error, light-dark(#D52B2E, #FF5B5E))' + : 'var(--border, light-dark(#E4E4E4, #434343))' + } borderRadius="8px" + borderStyle="solid" + borderWidth="1px" + className={classNames?.input} + disabled={disabled} onChange={onChangeProp ?? handleChange} pl={icon ? '36px' : '12px'} pr={['36px', null, allowClear ? '36px' : '12px']} py="12px" selectors={{ '&::placeholder': { - color: '$inputPlaceholder', + color: 'var(--inputPlaceholder, light-dark(#A9A8AB, #CBCBCB))', }, }} styleOrder={1} + styleVars={Object.assign({}, colors)} transition="all 0.1s ease-in-out" typography={typography} value={valueProp ?? value} - w="200px" {...props} /> {clearButtonVisible && ( @@ -102,6 +136,20 @@ export function Input({ onClick={handleClear} /> )} + {errorMessage && ( + + {errorMessage} + + )} ) } @@ -110,11 +158,11 @@ export function ClearButton(props: ComponentProps<'button'>) { return ( + +`; + +exports[`Input > should render disabled icon style when disabled is true 1`] = ` +
+
+
+ + + +
+ +
+
+`; + +exports[`Input > should render error style when error is true 1`] = ` +
+
+ +
+
+`; + +exports[`Input > should render with allowClear prop 1`] = ` +
+
+ +
+
+`; + +exports[`Input > should render with default props 1`] = ` +
+
+ +
+
+`; + +exports[`Input > should render with disabled prop 1`] = ` +
+
+ +
+
+`; + +exports[`Input > should show clear button when value is not empty 1`] = ` +
+
+ +
+
+`; diff --git a/packages/components/src/components/Input/__tests__/index.browser.test.tsx b/packages/components/src/components/Input/__tests__/index.browser.test.tsx index e69de29b..69003faa 100644 --- a/packages/components/src/components/Input/__tests__/index.browser.test.tsx +++ b/packages/components/src/components/Input/__tests__/index.browser.test.tsx @@ -0,0 +1,137 @@ +import { fireEvent, render } from '@testing-library/react' +import { DevupThemeTypography } from 'node_modules/@devup-ui/react/dist/types/typography' + +import { ClearButton, Input } from '..' +import { GlassIcon } from '../GlassIcon' + +describe('Input', () => { + it('should render with default props', () => { + const { container } = render() + expect(container).toMatchSnapshot() + }) + + it('should render with disabled prop', () => { + const { container } = render() + expect(container).toMatchSnapshot() + }) + + it('should render with allowClear prop', () => { + const { container } = render() + expect(container).toMatchSnapshot() + }) + + it('should show clear button when value is not empty', () => { + const { container } = render() + expect(container).toMatchSnapshot() + fireEvent.change(container.querySelector('input')!, { + target: { value: 'test' }, + }) + expect(container.querySelector('button')).toBeInTheDocument() + }) + + it('should not show clear button when value is empty', () => { + const { container } = render() + expect(container).toMatchSnapshot() + }) + + it('should be able to clear value by clicking clear button', () => { + const { container } = render() + fireEvent.change(container.querySelector('input')!, { + target: { value: 'test' }, + }) + expect(container.querySelector('button')).toBeInTheDocument() + fireEvent.click(container.querySelector('button')!) + expect(container.querySelector('input')!.value).toBe('') + }) + + it('should be able to render with icon', () => { + const { container } = render( + } />, + ) + expect(container.querySelector('[data-testid="icon"]')).toBeInTheDocument() + }) + + it('should render error style when error is true', () => { + const { container } = render() + expect(container).toMatchSnapshot() + expect(container.querySelector('[aria-label="input"]')).toHaveClass( + 'border-color-0-var(--error,light-dark(#D52B2E,#FF5B5E))--1', + ) + }) + + it('should be able to render with error message', () => { + const { container } = render() + expect( + container.querySelector('[aria-label="error-message"]'), + ).toBeInTheDocument() + }) + + it('should pass colors prop', () => { + const { container } = render( + , + ) + const input = container.querySelector('[aria-label="input"]') + expect(input).toHaveStyle({ + '--primary': 'red', + '--error': 'blue', + '--text': 'green', + }) + }) + + it('should have typography when typography is provided', () => { + const { container } = render( + , + ) + expect(container).toMatchSnapshot() + expect(container.querySelector('input')).toHaveClass('typo-inlineLabelS') + }) + + it('should pass className prop to error message component', () => { + const { container } = render( + , + ) + expect(container).toMatchSnapshot() + expect(container.querySelector('[aria-label="error-message"]')).toHaveClass( + 'error-message', + ) + }) + + it('should pass className prop to icon component', () => { + const { container } = render( + } + />, + ) + expect(container).toMatchSnapshot() + expect(container.querySelector('[aria-label="icon"]')).toHaveClass('icon') + }) + + it('should pass props to ClearButton component', async () => { + const { container } = render() + expect(container).toMatchSnapshot() + const clearButton = container.querySelector('[aria-label="clear-button"]') + expect(clearButton).toBeInTheDocument() + }) + + it('should render disabled icon style when disabled is true', () => { + const { container } = render(} />) + expect(container).toMatchSnapshot() + expect(container.querySelector('[aria-label="icon"]')).toHaveClass( + 'color-0-var(--inputDisabledText,light-dark(#D6D7DE,#373737))--1', + ) + }) +}) diff --git a/packages/components/src/components/Input/index.tsx b/packages/components/src/components/Input/index.tsx index 7a59ddd5..e6c911fc 100644 --- a/packages/components/src/components/Input/index.tsx +++ b/packages/components/src/components/Input/index.tsx @@ -66,6 +66,7 @@ export function Input({ > {icon && (
) { return (