diff --git a/package.json b/package.json index 83740d67..fbeb6029 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ ] }, "dependencies": { + "@tanstack/react-form": "^1.27.0", "@tanstack/react-query": "^5.90.3", "@tanstack/react-query-devtools": "^5.90.2", "axios": "^1.13.2", @@ -50,7 +51,8 @@ "react": "19.2.0", "react-dom": "19.2.0", "swiper": "^12.0.3", - "tailwind-merge": "^3.3.1" + "tailwind-merge": "^3.3.1", + "zod": "^4.1.13" }, "devDependencies": { "@commitlint/cli": "^20.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7225477d..1df65272 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@tanstack/react-form': + specifier: ^1.27.0 + version: 1.27.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@tanstack/react-query': specifier: ^5.90.3 version: 5.90.3(react@19.2.0) @@ -41,6 +44,9 @@ importers: tailwind-merge: specifier: ^3.3.1 version: 3.3.1 + zod: + specifier: ^4.1.13 + version: 4.1.13 devDependencies: '@commitlint/cli': specifier: ^20.1.0 @@ -2103,12 +2109,32 @@ packages: '@tailwindcss/postcss@4.1.14': resolution: {integrity: sha512-BdMjIxy7HUNThK87C7BC8I1rE8BVUsfNQSI5siQ4JK3iIa3w0XyVvVL9SXLWO//CtYTcp1v7zci0fYwJOjB+Zg==} + '@tanstack/devtools-event-client@0.3.5': + resolution: {integrity: sha512-RL1f5ZlfZMpghrCIdzl6mLOFLTuhqmPNblZgBaeKfdtk5rfbjykurv+VfYydOFXj0vxVIoA2d/zT7xfD7Ph8fw==} + engines: {node: '>=18'} + + '@tanstack/form-core@1.27.0': + resolution: {integrity: sha512-QFEhg9/VcrwtpbcN7Qpl8JVVfEm2UJ+dzfDFGGMYub2J9jsgrp2HmaY7LSLlnkpTJlCIDxQiWDkiOFYQtK6yzw==} + + '@tanstack/pacer@0.15.4': + resolution: {integrity: sha512-vGY+CWsFZeac3dELgB6UZ4c7OacwsLb8hvL2gLS6hTgy8Fl0Bm/aLokHaeDIP+q9F9HUZTnp360z9uv78eg8pg==} + engines: {node: '>=18'} + '@tanstack/query-core@5.90.3': resolution: {integrity: sha512-HtPOnCwmx4dd35PfXU8jjkhwYrsHfuqgC8RCJIwWglmhIUIlzPP0ZcEkDAc+UtAWCiLm7T8rxeEfHZlz3hYMCA==} '@tanstack/query-devtools@5.90.1': resolution: {integrity: sha512-GtINOPjPUH0OegJExZ70UahT9ykmAhmtNVcmtdnOZbxLwT7R5OmRztR5Ahe3/Cu7LArEmR6/588tAycuaWb1xQ==} + '@tanstack/react-form@1.27.0': + resolution: {integrity: sha512-7MBOtvjlUwkGpvA9TIOs3YdLoyfJWZYtxuAQIdkLDZ9HLrRaRbxWQIZ2H6sRVA35sPvx6uiQMunGHOPKip5AZA==} + peerDependencies: + '@tanstack/react-start': '*' + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@tanstack/react-start': + optional: true + '@tanstack/react-query-devtools@5.90.2': resolution: {integrity: sha512-vAXJzZuBXtCQtrY3F/yUNJCV4obT/A/n81kb3+YqLbro5Z2+phdAbceO+deU3ywPw8B42oyJlp4FhO0SoivDFQ==} peerDependencies: @@ -2120,6 +2146,18 @@ packages: peerDependencies: react: ^18 || ^19 + '@tanstack/react-store@0.8.0': + resolution: {integrity: sha512-1vG9beLIuB7q69skxK9r5xiLN3ztzIPfSQSs0GfeqWGO2tGIyInZx0x1COhpx97RKaONSoAb8C3dxacWksm1ow==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tanstack/store@0.7.7': + resolution: {integrity: sha512-xa6pTan1bcaqYDS9BDpSiS63qa6EoDkPN9RsRaxHuDdVDNntzq3xNwR5YKTU/V3SkSyC9T4YVOPh2zRQN0nhIQ==} + + '@tanstack/store@0.8.0': + resolution: {integrity: sha512-Om+BO0YfMZe//X2z0uLF2j+75nQga6TpTJgLJQBiq85aOyZNIhkCgleNcud2KQg4k4v9Y9l+Uhru3qWMPGTOzQ==} + '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} @@ -6276,6 +6314,11 @@ packages: resolution: {integrity: sha512-oCwdVC7mTuWiPyjLUz/COz5TLk6wgp0RCsN+wHZ2Ekneac9w8uuV0njcbbie2ME+Vs+d6duwmYuR3HgQXs1fOg==} engines: {node: '>= 0.4'} + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -6498,8 +6541,8 @@ packages: peerDependencies: zod: ^3.25.0 || ^4.0.0 - zod@4.1.12: - resolution: {integrity: sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==} + zod@4.1.13: + resolution: {integrity: sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==} snapshots: @@ -8872,10 +8915,31 @@ snapshots: postcss: 8.5.6 tailwindcss: 4.1.14 + '@tanstack/devtools-event-client@0.3.5': {} + + '@tanstack/form-core@1.27.0': + dependencies: + '@tanstack/devtools-event-client': 0.3.5 + '@tanstack/pacer': 0.15.4 + '@tanstack/store': 0.7.7 + + '@tanstack/pacer@0.15.4': + dependencies: + '@tanstack/devtools-event-client': 0.3.5 + '@tanstack/store': 0.7.7 + '@tanstack/query-core@5.90.3': {} '@tanstack/query-devtools@5.90.1': {} + '@tanstack/react-form@1.27.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@tanstack/form-core': 1.27.0 + '@tanstack/react-store': 0.8.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + react: 19.2.0 + transitivePeerDependencies: + - react-dom + '@tanstack/react-query-devtools@5.90.2(@tanstack/react-query@5.90.3(react@19.2.0))(react@19.2.0)': dependencies: '@tanstack/query-devtools': 5.90.1 @@ -8887,6 +8951,17 @@ snapshots: '@tanstack/query-core': 5.90.3 react: 19.2.0 + '@tanstack/react-store@0.8.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@tanstack/store': 0.8.0 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + use-sync-external-store: 1.6.0(react@19.2.0) + + '@tanstack/store@0.7.7': {} + + '@tanstack/store@0.8.0': {} + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.27.1 @@ -10573,8 +10648,8 @@ snapshots: '@babel/parser': 7.28.4 eslint: 9.37.0(jiti@2.6.1) hermes-parser: 0.25.1 - zod: 4.1.12 - zod-validation-error: 4.0.2(zod@4.1.12) + zod: 4.1.13 + zod-validation-error: 4.0.2(zod@4.1.13) transitivePeerDependencies: - supports-color @@ -13634,6 +13709,10 @@ snapshots: punycode: 1.4.1 qs: 6.14.0 + use-sync-external-store@1.6.0(react@19.2.0): + dependencies: + react: 19.2.0 + util-deprecate@1.0.2: {} util@0.12.5: @@ -13890,8 +13969,8 @@ snapshots: yoctocolors-cjs@2.1.3: {} - zod-validation-error@4.0.2(zod@4.1.12): + zod-validation-error@4.0.2(zod@4.1.13): dependencies: - zod: 4.1.12 + zod: 4.1.13 - zod@4.1.12: {} + zod@4.1.13: {} diff --git a/src/components/pages/login/index.ts b/src/components/pages/login/index.ts new file mode 100644 index 00000000..61b7acac --- /dev/null +++ b/src/components/pages/login/index.ts @@ -0,0 +1 @@ +export { LoginForm } from './login-form'; diff --git a/src/components/pages/login/login-form/index.tsx b/src/components/pages/login/login-form/index.tsx new file mode 100644 index 00000000..774326fa --- /dev/null +++ b/src/components/pages/login/login-form/index.tsx @@ -0,0 +1,111 @@ +'use client'; + +import { useForm } from '@tanstack/react-form'; + +import { FormInput } from '@/components/shared'; +import { Button } from '@/components/ui'; +import { loginSchema } from '@/lib/schema/auth'; + +const getHintMessage = (errors: unknown[], isTouched: boolean, submissionAttempts: number) => { + const firstError = errors[0] as { message?: string } | undefined; + const showError = isTouched || submissionAttempts > 0; + + return showError ? firstError?.message : undefined; +}; + +export const LoginForm = () => { + const form = useForm({ + defaultValues: { + email: '', + password: '', + }, + validators: { + onSubmit: loginSchema, + onChange: loginSchema, + }, + onSubmit: async ({ value }) => { + // API 호출 + alert('login:' + value.email); + }, + }); + + return ( +
{ + e.preventDefault(); + void form.handleSubmit(); + }} + > +
+ + {(field) => { + const { + meta: { errors, isTouched }, + } = field.state; + const hintMessage = getHintMessage(errors, isTouched, form.state.submissionAttempts); + + return ( + field.handleChange(e.target.value), + }} + labelName='이메일' + /> + ); + }} + + + + {(field) => { + const { + meta: { errors, isTouched }, + } = field.state; + const hintMessage = getHintMessage(errors, isTouched, form.state.submissionAttempts); + + return ( + field.handleChange(e.target.value), + }} + labelName='비밀번호' + /> + ); + }} + +
+ + ({ + canSubmit: state.canSubmit, + isSubmitting: state.isSubmitting, + })} + > + {({ canSubmit, isSubmitting }) => { + const disabled = !canSubmit || isSubmitting; + + return ( + + ); + }} + +
+ ); +}; diff --git a/src/components/pages/signup/index.ts b/src/components/pages/signup/index.ts new file mode 100644 index 00000000..93e783fa --- /dev/null +++ b/src/components/pages/signup/index.ts @@ -0,0 +1 @@ +export { SignupForm } from './signup-form'; diff --git a/src/components/pages/signup/signup-form/index.tsx b/src/components/pages/signup/signup-form/index.tsx new file mode 100644 index 00000000..3ca1b20c --- /dev/null +++ b/src/components/pages/signup/signup-form/index.tsx @@ -0,0 +1,155 @@ +'use client'; + +import { useForm } from '@tanstack/react-form'; + +import { FormInput } from '@/components/shared'; +import { Button } from '@/components/ui'; +import { signupSchema } from '@/lib/schema/auth'; + +const getHintMessage = (errors: unknown[], isTouched: boolean, submissionAttempts: number) => { + const firstError = errors[0] as { message?: string } | undefined; + const showError = isTouched || submissionAttempts > 0; + + return showError ? firstError?.message : undefined; +}; + +export const SignupForm = () => { + const form = useForm({ + defaultValues: { + email: '', + nickname: '', + password: '', + confirmPassword: '', + }, + validators: { + onChange: signupSchema, + onSubmit: signupSchema, + }, + onSubmit: async ({ value }) => { + // api 호출 + alert('signup:' + value.nickname); + }, + }); + + return ( +
{ + e.preventDefault(); + void form.handleSubmit(); + }} + > +
+ + {(field) => { + const { + meta: { errors, isTouched }, + } = field.state; + const hintMessage = getHintMessage(errors, isTouched, form.state.submissionAttempts); + + return ( + field.handleChange(e.target.value), + }} + labelName='이메일' + /> + ); + }} + + + + {(field) => { + const { + meta: { errors, isTouched }, + } = field.state; + const hintMessage = getHintMessage(errors, isTouched, form.state.submissionAttempts); + + return ( + field.handleChange(e.target.value), + }} + labelName='닉네임' + /> + ); + }} + + + + {(field) => { + const { + meta: { errors, isTouched }, + } = field.state; + const hintMessage = getHintMessage(errors, isTouched, form.state.submissionAttempts); + + return ( + field.handleChange(e.target.value), + }} + labelName='비밀번호' + /> + ); + }} + + + + {(field) => { + const { + meta: { errors, isTouched }, + } = field.state; + const hintMessage = getHintMessage(errors, isTouched, form.state.submissionAttempts); + + return ( + field.handleChange(e.target.value), + }} + labelName='비밀번호 확인' + /> + ); + }} + +
+ + ({ + canSubmit: state.canSubmit, + isSubmitting: state.isSubmitting, + })} + > + {({ canSubmit, isSubmitting }) => { + const disabled = !canSubmit || isSubmitting; + + return ( + + ); + }} + +
+ ); +}; diff --git a/src/components/shared/form-input/index.stories.tsx b/src/components/shared/form-input/index.stories.tsx index 06164adc..902197c1 100644 --- a/src/components/shared/form-input/index.stories.tsx +++ b/src/components/shared/form-input/index.stories.tsx @@ -11,7 +11,11 @@ const meta = { }, args: { labelName: '라벨', - placeholder: '값을 입력하세요', + required: true, + inputProps: { + type: 'text', + placeholder: '값을 입력하세요', + }, }, } satisfies Meta; @@ -22,42 +26,48 @@ type Story = StoryObj; // 기본 텍스트 인풋 export const Default: Story = { args: { - type: 'text', hintMessage: '', - required: false, + required: true, + inputProps: { + type: 'text', + placeholder: '값을 입력하세요', + }, }, }; // 힌트가 있는 인풋 export const WithHint: Story = { args: { - type: 'text', labelName: '이메일', - placeholder: 'name@example.com', - hintMessage: '가입에 사용할 이메일을 입력하세요.', - required: true, + required: false, + inputProps: { + type: 'text', + placeholder: 'name@example.com', + }, }, }; // 비밀번호 인풋 (눈 아이콘 토글 동작 확인용) export const Password: Story = { args: { - type: 'password', labelName: '비밀번호', - placeholder: '비밀번호를 입력하세요', - hintMessage: '영문, 숫자, 특수문자를 포함해 주세요.', required: true, + inputProps: { + type: 'password', + placeholder: '비밀번호를 입력하세요', + }, }, }; // 에러 상태 예시 (스타일은 프로젝트 스타일에 맞춰 조정) export const ErrorState: Story = { args: { - type: 'text', labelName: '닉네임', - placeholder: '닉네임을 입력하세요', hintMessage: '이미 사용 중인 닉네임입니다.', required: true, - className: ' [&>input]:border-red-500', // 필요에 따라 Input에 에러 스타일 적용 + inputProps: { + type: 'text', + placeholder: '닉네임을 입력하세요', + }, }, }; diff --git a/src/components/shared/form-input/index.tsx b/src/components/shared/form-input/index.tsx index a2d96e90..b50abc69 100644 --- a/src/components/shared/form-input/index.tsx +++ b/src/components/shared/form-input/index.tsx @@ -1,59 +1,78 @@ 'use client'; -import { useState } from 'react'; +import { InputHTMLAttributes, useId, useState } from 'react'; import { Icon } from '@/components/icon'; import { Hint, Input, Label } from '@/components/ui'; import { cn } from '@/lib/utils'; -interface FormInputProps extends React.InputHTMLAttributes { +interface PasswordToggleButtonProps { + isVisible: boolean; + onToggle: () => void; +} + +const PasswordToggleButton = ({ isVisible, onToggle }: PasswordToggleButtonProps) => { + return ( + + ); +}; + +interface FormInputProps { + className?: string; labelName?: string; hintMessage?: string; + required?: boolean; + inputProps?: InputHTMLAttributes; } export const FormInput = ({ className, - required, labelName, hintMessage, - type, - id, - ...inputProps + required = true, + inputProps = {}, }: FormInputProps) => { + const { type = 'text', id, required: _, ...restInputProps } = inputProps; + + const generatedId = useId(); const [isVisible, setIsVisible] = useState(false); - const isPasswordField = type === 'password'; - const inputType = isVisible ? 'text' : type; - const inputId = id ?? `form-input-${labelName ?? 'field'}`; + const isPasswordField = type === 'password'; + const inputType = isPasswordField && isVisible ? 'text' : type; + const inputId = id ?? generatedId; const handleToggle = () => { - if (!isPasswordField) return; setIsVisible((prev) => !prev); }; - const passwordIconButton = isPasswordField ? ( - - ) : null; - return (
+ ) : undefined + } required={required} type={inputType} - {...inputProps} + {...restInputProps} /> {hintMessage && }
diff --git a/src/components/shared/search-bar/index.stories.tsx b/src/components/shared/search-bar/index.stories.tsx index d14295cb..6781ebb8 100644 --- a/src/components/shared/search-bar/index.stories.tsx +++ b/src/components/shared/search-bar/index.stories.tsx @@ -1,42 +1,62 @@ -// SearchBar.stories.tsx +import { useState } from 'react'; import type { Meta, StoryObj } from '@storybook/nextjs'; import { SearchBar } from './index'; -const meta = { +const meta: Meta = { title: 'Components/SearchBar', component: SearchBar, tags: ['autodocs'], - parameters: { - layout: 'centered', - }, argTypes: { - onSearch: { action: 'searched' }, - onChange: { action: 'changed' }, - }, - args: { - placeholder: '검색어를 입력하세요', + placeholder: { control: 'text' }, + defaultValue: { control: 'text' }, + className: { control: 'text' }, + onSearch: { action: 'search' }, + onChange: { action: 'change' }, }, -} satisfies Meta; +}; export default meta; -type Story = StoryObj; +type Story = StoryObj; -// 기본 검색바 -export const Default: Story = {}; +// 기본: 내부 상태(uncontrolled)로 동작 +export const Default: Story = { + args: { + placeholder: '원하는 모임을 검색해보세요.', + }, +}; -// 넓은 너비를 가진 검색바 예시 -export const FullWidth: Story = { +// 초기값이 있는 uncontrolled 사용 예시 +export const WithDefaultValue: Story = { args: { - className: 'w-[440px]', + placeholder: '검색어를 입력하세요.', + defaultValue: '초기 검색어', }, }; -// 긴 placeholder 예시 -export const WithLongPlaceholder: Story = { +// 부모가 value를 제어하는 controlled 사용 예시 +export const Controlled: Story = { args: { - placeholder: '검색어를 입력한 뒤 엔터를 누르거나 아이콘을 클릭하세요', + placeholder: '제어형 검색 바', + value: '초기 값', + }, + render: (args) => { + const [value, setValue] = useState(args.value ?? ''); + + return ( + { + setValue(nextValue); + // Storybook action(onChange)도 같이 호출 + if (typeof args.onChange === 'function') { + (args.onChange as (value: string) => void)(nextValue); + } + }} + /> + ); }, }; diff --git a/src/components/shared/search-bar/index.tsx b/src/components/shared/search-bar/index.tsx index c8796991..8c332f4e 100644 --- a/src/components/shared/search-bar/index.tsx +++ b/src/components/shared/search-bar/index.tsx @@ -1,54 +1,77 @@ 'use client'; -import { useState } from 'react'; +import { ChangeEvent, InputHTMLAttributes, KeyboardEvent, useState } from 'react'; import { Icon } from '@/components/icon'; import { Input } from '@/components/ui'; import { cn } from '@/lib/utils'; -interface SearchBarProps extends React.InputHTMLAttributes { +interface SearchBarProps extends Omit, 'value' | 'onChange'> { onSearch?: (value: string) => void; + onChange?: (value: string) => void; + value?: string; + defaultValue?: string; } export const SearchBar = ({ className, - placeholder, + placeholder = '검색어를 입력하세요', onSearch, onChange, + value, + defaultValue = '', ...props }: SearchBarProps) => { - const [value, setValue] = useState(''); + const isControlled = value !== undefined; - const handleChange = (event: React.ChangeEvent) => { - setValue(event.target.value); - onChange?.(event); + const [innerValue, setInnerValue] = useState(defaultValue); + + const currentValue = isControlled ? value : innerValue; + + const handleChange = (event: ChangeEvent) => { + const newValue = event.target.value; + + if (!isControlled) { + setInnerValue(newValue); + } + + onChange?.(newValue); }; - const handleIconClick = () => { - onSearch?.(value); + const handleSearch = () => { + onSearch?.(currentValue ?? ''); + + // 추가 동작 추천? + // 검색 실행 전 공백 제거 로직 }; - const searchIconButton = ( - - ); + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Enter') { + handleSearch(); + } + }; return ( -
- -
+ + + + } + placeholder={placeholder} + value={currentValue} + onChange={handleChange} + onKeyDown={handleKeyDown} + {...props} + /> ); }; diff --git a/src/components/ui/input/index.stories.tsx b/src/components/ui/input/index.stories.tsx index 84735b7a..0fec2544 100644 --- a/src/components/ui/input/index.stories.tsx +++ b/src/components/ui/input/index.stories.tsx @@ -1,93 +1,61 @@ -// Input.stories.tsx - import type { Meta, StoryObj } from '@storybook/nextjs'; import { Icon } from '@/components/icon'; import { Input } from './index'; -const meta = { +const meta: Meta = { title: 'Components/Input', component: Input, tags: ['autodocs'], - parameters: { - layout: 'centered', - }, argTypes: { + placeholder: { control: 'text' }, type: { - control: 'text', - }, - placeholder: { - control: 'text', - }, - disabled: { - control: 'boolean', + control: { type: 'select' }, + options: ['text', 'email', 'password', 'search'], }, - className: { - control: 'text', - }, - }, - args: { - placeholder: '값을 입력하세요', - type: 'text', + disabled: { control: 'boolean' }, + className: { control: 'text' }, }, -} satisfies Meta; +}; export default meta; -type Story = StoryObj; +type Story = StoryObj; -// 기본 인풋 +// 기본 텍스트 인풋 export const Default: Story = { - render: (args) => ( -
- -
- ), + args: { + placeholder: '텍스트를 입력하세요', + type: 'text', + }, }; // 비밀번호 인풋 export const Password: Story = { args: { - type: 'password', placeholder: '비밀번호를 입력하세요', + type: 'password', }, - render: (args) => ( -
- -
- ), }; -// 오른쪽에 검색 아이콘 버튼이 있는 인풋 -export const WithSearchIcon: Story = { +// 검색 아이콘이 포함된 인풋 +export const WithIconButton: Story = { args: { - type: 'search', placeholder: '검색어를 입력하세요', + type: 'search', + iconButton: ( + + ), }, - render: (args) => ( -
- - - - } - /> -
- ), }; // 비활성화된 인풋 export const Disabled: Story = { args: { + placeholder: '입력할 수 없습니다', disabled: true, - placeholder: '비활성 상태', }, - render: (args) => ( -
- -
- ), }; diff --git a/src/components/ui/input/index.tsx b/src/components/ui/input/index.tsx index 9c30ae24..f57b889f 100644 --- a/src/components/ui/input/index.tsx +++ b/src/components/ui/input/index.tsx @@ -15,8 +15,8 @@ export const Input = forwardRef( { export const Label = ({ children, className, required, ...props }: LabelProps) => { return ( + // label의 required는 부르는 곳에서 제어