From 8f13bdab52c2e72b0847f0dcc502801d8c52c699 Mon Sep 17 00:00:00 2001 From: Leeseojeong Date: Mon, 22 Jun 2026 15:27:22 +0900 Subject: [PATCH 1/8] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Refactor:=20=ED=8F=BC?= =?UTF-8?q?=20=EC=BB=A8=ED=8A=B8=EB=A1=A4=20=EA=B3=B5=ED=86=B5=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Input과 TextArea가 라벨, 오류 상태, 필드 스타일을 공유할 수 있도록 FormControl 기반을 분리했습니다. Input.Control은 역할이 드러나는 Input.FieldGroup으로 정리했습니다. --- .../ui/form-control/FormControl.types.ts | 22 +++++++ .../ui/form-control/FormControlContext.ts | 39 +++++++++++++ .../form-control/FormControlErrorMessage.tsx | 57 +++++++++++++++++++ .../ui/form-control/FormControlLabel.tsx | 53 +++++++++++++++++ .../ui/form-control/formControlStyles.ts | 9 +++ src/shared/ui/input/Input.stories.tsx | 6 +- src/shared/ui/input/Input.tsx | 21 +++---- src/shared/ui/input/Input.types.ts | 14 +---- src/shared/ui/input/InputContext.ts | 17 ------ src/shared/ui/input/InputErrorMessage.tsx | 19 +------ src/shared/ui/input/InputField.tsx | 18 +++--- .../{InputControl.tsx => InputFieldGroup.tsx} | 10 ++-- src/shared/ui/input/InputLabel.tsx | 20 +------ 13 files changed, 216 insertions(+), 89 deletions(-) create mode 100644 src/shared/ui/form-control/FormControl.types.ts create mode 100644 src/shared/ui/form-control/FormControlContext.ts create mode 100644 src/shared/ui/form-control/FormControlErrorMessage.tsx create mode 100644 src/shared/ui/form-control/FormControlLabel.tsx create mode 100644 src/shared/ui/form-control/formControlStyles.ts delete mode 100644 src/shared/ui/input/InputContext.ts rename src/shared/ui/input/{InputControl.tsx => InputFieldGroup.tsx} (78%) diff --git a/src/shared/ui/form-control/FormControl.types.ts b/src/shared/ui/form-control/FormControl.types.ts new file mode 100644 index 0000000..829f587 --- /dev/null +++ b/src/shared/ui/form-control/FormControl.types.ts @@ -0,0 +1,22 @@ +import type { ComponentPropsWithoutRef, ReactNode } from 'react'; + +/** Input과 TextArea Root가 Label, Field, ErrorMessage에 제공하는 공통 상태입니다. */ +interface FormControlContextValue { + disabled: boolean; + errorMessageId: string; + fieldId: string; + invalid: boolean; + required: boolean; +} + +/** Root의 fieldId와 required 상태를 상속하는 공통 Label props입니다. */ +interface FormControlLabelProps extends Omit, 'htmlFor'> { + children: ReactNode; +} + +/** Root의 invalid 상태와 errorMessageId를 상속하는 공통 오류 문구 props입니다. */ +interface FormControlErrorMessageProps extends Omit, 'id'> { + children?: ReactNode; +} + +export type { FormControlContextValue, FormControlErrorMessageProps, FormControlLabelProps }; diff --git a/src/shared/ui/form-control/FormControlContext.ts b/src/shared/ui/form-control/FormControlContext.ts new file mode 100644 index 0000000..0da6c0d --- /dev/null +++ b/src/shared/ui/form-control/FormControlContext.ts @@ -0,0 +1,39 @@ +import { createContext, use } from 'react'; + +import type { FormControlContextValue } from './FormControl.types'; + +const FormControlContext = createContext(null); + +/** + * ## useFormControlContext + * + * @description + * Input과 TextArea의 compound 하위 컴포넌트가 Root의 id 및 폼 상태를 읽는 내부 훅입니다. + * Label, Field, ErrorMessage가 동일한 접근성 연결 정보와 상태를 사용하도록 합니다. + * + * ### 주의할 점 + * + * 반드시 `FormControlContext`를 제공하는 Input 또는 TextArea Root 내부에서 사용해야 합니다. + * 일반 화면 컴포넌트에서는 이 훅을 직접 사용하지 않고 `Input.*` 또는 `TextArea.*` API를 + * 통해 FormControl 기반 컴포넌트를 조합합니다. + * + * @example + * ```tsx + * function CustomField() { + * const { disabled, fieldId } = useFormControlContext(); + * + * return ; + * } + * ``` + */ +export function useFormControlContext() { + const context = use(FormControlContext); + + if (!context) { + throw new Error('Form control compound components must be used within a form control root.'); + } + + return context; +} + +export { FormControlContext }; diff --git a/src/shared/ui/form-control/FormControlErrorMessage.tsx b/src/shared/ui/form-control/FormControlErrorMessage.tsx new file mode 100644 index 0000000..34236db --- /dev/null +++ b/src/shared/ui/form-control/FormControlErrorMessage.tsx @@ -0,0 +1,57 @@ +import { cn } from '@/shared/styles/utils/cn'; + +import { useFormControlContext } from './FormControlContext'; + +import type { FormControlErrorMessageProps } from './FormControl.types'; + +/** + * ## FormControlErrorMessage + * + * @description + * Input과 TextArea가 공유하는 유효성 오류 문구 기반 컴포넌트입니다. Root의 `invalid`가 + * true이고 오류 문구가 있을 때만 렌더링하여 정상 상태에서 불필요한 빈 영역을 만들지 않습니다. + * + * ### 주의할 점 + * + * 이 컴포넌트는 FormControl의 내부 기반이므로 직접 사용하기보다 `Input.ErrorMessage` 또는 + * `TextArea.ErrorMessage`를 사용합니다. 오류 여부는 사용하는 폼에서 Root의 `invalid`로 전달합니다. + * + * ### 접근성 + * + * Root가 생성한 `errorMessageId`를 사용하며 `role="alert"`로 오류를 알립니다. 실제 Field는 + * 동일한 id를 `aria-describedby`와 `aria-errormessage`에 사용해 오류 문구와 연결합니다. + * + * @param children - 유효성 검증 실패 원인과 해결 방법을 설명하는 문구 + * @param className - 공통 오류 문구 스타일을 확장하는 클래스 이름 + * + * @example + * ```tsx + * + * ``` + */ +export function FormControlErrorMessage({ + children, + className, + ...props +}: FormControlErrorMessageProps) { + const { errorMessageId, invalid } = useFormControlContext(); + + if (!invalid || !children) { + return null; + } + + return ( + + ); +} diff --git a/src/shared/ui/form-control/FormControlLabel.tsx b/src/shared/ui/form-control/FormControlLabel.tsx new file mode 100644 index 0000000..c2561c9 --- /dev/null +++ b/src/shared/ui/form-control/FormControlLabel.tsx @@ -0,0 +1,53 @@ +import { cn } from '@/shared/styles/utils/cn'; + +import { useFormControlContext } from './FormControlContext'; + +import type { FormControlLabelProps } from './FormControl.types'; + +/** + * ## FormControlLabel + * + * @description + * Input과 TextArea가 공유하는 Label 기반 컴포넌트입니다. Root가 제공하는 `fieldId`를 + * `htmlFor`에 적용하고 `required` 상태에 따라 시각적인 필수 표시를 추가합니다. + * + * ### 주의할 점 + * + * 이 컴포넌트는 FormControl의 내부 기반이므로 직접 사용하기보다 `Input.Label` 또는 + * `TextArea.Label`을 사용합니다. `htmlFor`는 Root에서 관리하므로 외부에서 전달하지 않습니다. + * + * ### 접근성 + * + * 필수 표시 `*`는 장식 요소이므로 스크린 리더에서 제외합니다. 실제 필수 상태는 Root와 + * 연결된 Field의 네이티브 `required` 및 `aria-required` 속성으로 전달합니다. + * + * @param children - 입력 요소의 목적을 설명하는 Label 문구 + * @param className - 공통 Label 스타일을 확장하는 클래스 이름 + * + * @example + * ```tsx + * + * 회사명 + * + * + * ``` + */ +export function FormControlLabel({ children, className, ...props }: FormControlLabelProps) { + const { fieldId, required } = useFormControlContext(); + + return ( + + ); +} diff --git a/src/shared/ui/form-control/formControlStyles.ts b/src/shared/ui/form-control/formControlStyles.ts new file mode 100644 index 0000000..9c69979 --- /dev/null +++ b/src/shared/ui/form-control/formControlStyles.ts @@ -0,0 +1,9 @@ +/** Input과 TextArea Field가 공유하는 크기, 배경, 테두리, 글자 스타일입니다. */ +const fieldBaseClassName = + 'min-w-0 w-full rounded-lg border border-transparent bg-gray-600 px-5 body-16 text-white outline-none'; + +/** Input과 TextArea Field가 공유하는 focus, invalid, disabled 상태 스타일입니다. */ +const fieldInteractionClassName = + 'transition-[border-color,box-shadow,background-color,color] duration-150 placeholder:text-gray-200 focus-visible:border-primary-500 focus-visible:shadow-[0_0_12px_2px] focus-visible:shadow-primary-500/25 data-[invalid=true]:border-error-500 data-[invalid=true]:focus-visible:shadow-error-500/25 disabled:cursor-not-allowed disabled:bg-gray-700 disabled:text-gray-300 disabled:placeholder:text-gray-500'; + +export { fieldBaseClassName, fieldInteractionClassName }; diff --git a/src/shared/ui/input/Input.stories.tsx b/src/shared/ui/input/Input.stories.tsx index 3d76620..cf7b528 100644 --- a/src/shared/ui/input/Input.stories.tsx +++ b/src/shared/ui/input/Input.stories.tsx @@ -189,7 +189,7 @@ const meta = { docs: { description: { component: - 'Label, Field, ErrorMessage를 조합하는 Compound Input입니다. 버튼은 Input.Control 안에서 함께 구성하며, 폼 상태는 FormField를 통해 React Hook Form과 연결합니다.', + 'Label, Field, ErrorMessage를 조합하는 Compound Input입니다. 버튼은 Input.FieldGroup 안에서 함께 구성하며, 폼 상태는 FormField를 통해 React Hook Form과 연결합니다.', }, }, }, @@ -251,12 +251,12 @@ export const WithButton: Story = { render: () => ( 제한 글자 수 - + - + ), }; diff --git a/src/shared/ui/input/Input.tsx b/src/shared/ui/input/Input.tsx index 0f5f1c8..ae821be 100644 --- a/src/shared/ui/input/Input.tsx +++ b/src/shared/ui/input/Input.tsx @@ -3,14 +3,15 @@ import { useId } from 'react'; import { cn } from '@/shared/styles/utils/cn'; +import type { FormControlContextValue } from '@/shared/ui/form-control/FormControl.types'; +import { FormControlContext } from '@/shared/ui/form-control/FormControlContext'; -import { InputContext } from './InputContext'; -import { InputControl } from './InputControl'; import { InputErrorMessage } from './InputErrorMessage'; import { InputField } from './InputField'; +import { InputFieldGroup } from './InputFieldGroup'; import { InputLabel } from './InputLabel'; -import type { InputContextValue, InputProps } from './Input.types'; +import type { InputProps } from './Input.types'; /** * ## Input @@ -23,7 +24,7 @@ import type { InputContextValue, InputProps } from './Input.types'; * * `required`, `disabled`, `invalid` 상태를 하위 컴포넌트에 Context로 전달합니다. * `id`를 생략하면 고유한 Field id를 생성하며 Label과 보조 문구의 접근성 연결에도 사용합니다. - * Field 내부 오른쪽에 버튼이 필요하면 `Input.Control` 안에서 함께 구성합니다. + * Field 내부 오른쪽에 버튼이 필요하면 `Input.FieldGroup` 안에서 함께 구성합니다. * * ### 접근성 * @@ -59,10 +60,10 @@ import type { InputContextValue, InputProps } from './Input.types'; * ```tsx * * 제한 글자 수 - * + * * * - * + * * 0 이상의 글자 수를 입력해 주세요. * * ``` @@ -88,7 +89,7 @@ function InputRoot({ }: InputProps) { const generatedId = useId(); const fieldId = id ?? `input-${generatedId}`; - const contextValue: InputContextValue = { + const contextValue: FormControlContextValue = { disabled, errorMessageId: `${fieldId}-error-message`, fieldId, @@ -97,7 +98,7 @@ function InputRoot({ }; return ( - +
{children}
-
+ ); } const Input = Object.assign(InputRoot, { - Control: InputControl, ErrorMessage: InputErrorMessage, Field: InputField, + FieldGroup: InputFieldGroup, Label: InputLabel, }); diff --git a/src/shared/ui/input/Input.types.ts b/src/shared/ui/input/Input.types.ts index 2703a60..bd3b69a 100644 --- a/src/shared/ui/input/Input.types.ts +++ b/src/shared/ui/input/Input.types.ts @@ -28,7 +28,7 @@ interface InputLabelProps extends Omit, 'htmlF } /** Field와 Button 같은 부가 동작을 나란히 합성하는 레이아웃 컨테이너입니다. */ -interface InputControlProps extends ComponentPropsWithoutRef<'div'> { +interface InputFieldGroupProps extends ComponentPropsWithoutRef<'div'> { children: ReactNode; } @@ -50,20 +50,10 @@ interface InputErrorMessageProps extends Omit, 'id children?: ReactNode; } -/** Compound 하위 컴포넌트가 Root에서 전달받는 내부 계약입니다. */ -interface InputContextValue { - disabled: boolean; - errorMessageId: string; - fieldId: string; - invalid: boolean; - required: boolean; -} - export type { - InputContextValue, - InputControlProps, InputErrorMessageProps, InputFieldProps, + InputFieldGroupProps, InputLabelProps, InputProps, InputType, diff --git a/src/shared/ui/input/InputContext.ts b/src/shared/ui/input/InputContext.ts deleted file mode 100644 index 4ae0744..0000000 --- a/src/shared/ui/input/InputContext.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { createContext, use } from 'react'; - -import type { InputContextValue } from './Input.types'; - -const InputContext = createContext(null); - -export function useInputContext() { - const context = use(InputContext); - - if (!context) { - throw new Error('Input compound components must be used within .'); - } - - return context; -} - -export { InputContext }; diff --git a/src/shared/ui/input/InputErrorMessage.tsx b/src/shared/ui/input/InputErrorMessage.tsx index 05b7fa0..ed27d68 100644 --- a/src/shared/ui/input/InputErrorMessage.tsx +++ b/src/shared/ui/input/InputErrorMessage.tsx @@ -1,6 +1,4 @@ -import { cn } from '@/shared/styles/utils/cn'; - -import { useInputContext } from './InputContext'; +import { FormControlErrorMessage } from '@/shared/ui/form-control/FormControlErrorMessage'; import type { InputErrorMessageProps } from './Input.types'; @@ -25,20 +23,9 @@ import type { InputErrorMessageProps } from './Input.types'; * ``` */ export function InputErrorMessage({ children, className, ...props }: InputErrorMessageProps) { - const { errorMessageId, invalid } = useInputContext(); - - if (!invalid || !children) { - return null; - } - return ( - + ); } diff --git a/src/shared/ui/input/InputField.tsx b/src/shared/ui/input/InputField.tsx index b0405a9..ac5ca4e 100644 --- a/src/shared/ui/input/InputField.tsx +++ b/src/shared/ui/input/InputField.tsx @@ -1,6 +1,9 @@ import { cn } from '@/shared/styles/utils/cn'; - -import { useInputContext } from './InputContext'; +import { useFormControlContext } from '@/shared/ui/form-control/FormControlContext'; +import { + fieldBaseClassName, + fieldInteractionClassName, +} from '@/shared/ui/form-control/formControlStyles'; import type { InputFieldProps } from './Input.types'; @@ -31,7 +34,7 @@ import type { InputFieldProps } from './Input.types'; * ``` */ export function InputField({ className, ref, type = 'text', ...props }: InputFieldProps) { - const { disabled, errorMessageId, fieldId, invalid, required } = useInputContext(); + const { disabled, errorMessageId, fieldId, invalid, required } = useFormControlContext(); return ( + * * * - * + * * ``` */ -export function InputControl({ children, className, ...props }: InputControlProps) { +export function InputFieldGroup({ children, className, ...props }: InputFieldGroupProps) { return (
+ {children} - {required ? ( - - ) : null} - + ); } From dfed5c74cde7bb93fe17ec4fc2def26797f319cf Mon Sep 17 00:00:00 2001 From: Leeseojeong Date: Mon, 22 Jun 2026 15:29:17 +0900 Subject: [PATCH 2/8] =?UTF-8?q?=E2=9C=A8=20Feat:=20TextArea=20=EA=B3=B5?= =?UTF-8?q?=ED=86=B5=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 긴 텍스트 입력에서 라벨, 오류, 비활성 상태와 글자 수 표시를 일관되게 사용할 수 있는 Compound TextArea를 추가했습니다. 권장 길이와 최대 길이를 타입으로 구분하고 최대 입력을 보장합니다. --- src/shared/ui/textarea/TextArea.stories.tsx | 193 ++++++++++++++++++ src/shared/ui/textarea/TextArea.tsx | 86 ++++++++ src/shared/ui/textarea/TextArea.types.ts | 40 ++++ .../ui/textarea/TextAreaErrorMessage.tsx | 11 + src/shared/ui/textarea/TextAreaField.tsx | 126 ++++++++++++ src/shared/ui/textarea/TextAreaLabel.tsx | 11 + src/shared/ui/textarea/index.ts | 1 + 7 files changed, 468 insertions(+) create mode 100644 src/shared/ui/textarea/TextArea.stories.tsx create mode 100644 src/shared/ui/textarea/TextArea.tsx create mode 100644 src/shared/ui/textarea/TextArea.types.ts create mode 100644 src/shared/ui/textarea/TextAreaErrorMessage.tsx create mode 100644 src/shared/ui/textarea/TextAreaField.tsx create mode 100644 src/shared/ui/textarea/TextAreaLabel.tsx create mode 100644 src/shared/ui/textarea/index.ts diff --git a/src/shared/ui/textarea/TextArea.stories.tsx b/src/shared/ui/textarea/TextArea.stories.tsx new file mode 100644 index 0000000..e8b4aa5 --- /dev/null +++ b/src/shared/ui/textarea/TextArea.stories.tsx @@ -0,0 +1,193 @@ +import { useState } from 'react'; + +import { useForm } from 'react-hook-form'; + +import { Button } from '@/shared/ui/button'; +import { FormField } from '@/shared/ui/form-field'; + +import { TextArea } from './index'; + +import type { Meta, StoryObj } from '@storybook/nextjs'; + +interface TextAreaPlaygroundProps { + defaultValue: string; + disabled: boolean; + errorMessage: string; + invalid: boolean; + label: string; + length: number; + lengthType: 'max' | 'recommended'; + placeholder: string; + required: boolean; + showCount: boolean; +} + +interface ExampleFormValues { + answer: string; +} + +function TextAreaPlayground({ + defaultValue, + disabled, + errorMessage, + invalid, + label, + length, + lengthType, + placeholder, + required, + showCount, +}: TextAreaPlaygroundProps) { + const lengthProps = lengthType === 'max' ? { maxLength: length } : { recommendedLength: length }; + + return ( + + ); +} + +function ReactHookFormExample() { + const [submittedValue, setSubmittedValue] = useState(''); + const { control, handleSubmit } = useForm({ + defaultValues: { + answer: '', + }, + mode: 'onBlur', + }); + + return ( +
setSubmittedValue(answer))} + > + ( + + )} + rules={{ required: '자기소개서 내용을 입력해 주세요.' }} + /> + + + + {submittedValue ? ( +

제출된 글자 수: {submittedValue.length}자

+ ) : null} + + ); +} + +const meta = { + title: 'Shared/TextArea', + component: TextAreaPlayground, + args: { + defaultValue: '', + disabled: false, + errorMessage: '자기소개서 내용을 입력해 주세요.', + invalid: false, + label: '자기소개서 내용', + length: 1000, + lengthType: 'recommended', + placeholder: '자기소개서 내용을 입력해 주세요.', + required: false, + showCount: true, + }, + argTypes: { + defaultValue: { control: 'text' }, + disabled: { control: 'boolean' }, + errorMessage: { control: 'text' }, + invalid: { control: 'boolean' }, + label: { control: 'text' }, + length: { control: 'number' }, + lengthType: { + control: 'inline-radio', + options: ['recommended', 'max'], + }, + placeholder: { control: 'text' }, + required: { control: 'boolean' }, + showCount: { control: 'boolean' }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + parameters: { + docs: { + description: { + component: + '긴 텍스트 입력을 위한 Compound TextArea입니다. recommendedLength는 안내 기준이고 maxLength는 실제 입력 제한입니다.', + }, + }, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const WithoutCount: Story = { + args: { + showCount: false, + }, +}; + +export const RecommendedLengthExceeded: Story = { + args: { + defaultValue: '권장 길이를 초과한 자기소개서 내용입니다.', + length: 10, + lengthType: 'recommended', + }, +}; + +export const MaxLengthReached: Story = { + args: { + defaultValue: '최대길이', + length: 5, + lengthType: 'max', + }, +}; + +export const WithError: Story = { + args: { + invalid: true, + required: true, + }, +}; + +export const Disabled: Story = { + args: { + defaultValue: '수정할 수 없는 자기소개서 내용입니다.', + disabled: true, + }, +}; + +export const ReactHookForm: Story = { + render: () => , +}; diff --git a/src/shared/ui/textarea/TextArea.tsx b/src/shared/ui/textarea/TextArea.tsx new file mode 100644 index 0000000..ca1b952 --- /dev/null +++ b/src/shared/ui/textarea/TextArea.tsx @@ -0,0 +1,86 @@ +'use client'; + +import { useId } from 'react'; + +import { cn } from '@/shared/styles/utils/cn'; +import type { FormControlContextValue } from '@/shared/ui/form-control/FormControl.types'; +import { FormControlContext } from '@/shared/ui/form-control/FormControlContext'; + +import { TextAreaErrorMessage } from './TextAreaErrorMessage'; +import { TextAreaField } from './TextAreaField'; +import { TextAreaLabel } from './TextAreaLabel'; + +import type { TextAreaProps } from './TextArea.types'; + +/** + * ## TextArea + * + * @description + * Label, Field, ErrorMessage를 조합해 긴 텍스트를 입력받는 공통 Compound TextArea입니다. + * 자기소개서 답변, 우대사항처럼 여러 줄 입력이 필요한 폼에서 사용합니다. + * + * ### 주요 내용 + * + * `required`, `disabled`, `invalid` 상태를 하위 컴포넌트에 전달합니다. `id`를 생략하면 + * 고유한 Field id를 생성하며 Label, 글자 수 안내, 오류 문구의 접근성 연결에 사용합니다. + * + * ### 접근성 + * + * 입력 목적을 제공하는 `TextArea.Label`을 함께 사용합니다. 유효성 오류가 있으면 + * `invalid`를 전달하고 `TextArea.ErrorMessage`에 해결 가능한 오류 문구를 제공합니다. + * + * @example + * ```tsx + * + * ``` + */ +function TextAreaRoot({ + children, + className, + containerId, + disabled = false, + id, + invalid = false, + required = false, + ...props +}: TextAreaProps) { + const generatedId = useId(); + const fieldId = id ?? `text-area-${generatedId}`; + const contextValue: FormControlContextValue = { + disabled, + errorMessageId: `${fieldId}-error-message`, + fieldId, + invalid, + required, + }; + + return ( + +
+ {children} +
+
+ ); +} + +const TextArea = Object.assign(TextAreaRoot, { + ErrorMessage: TextAreaErrorMessage, + Field: TextAreaField, + Label: TextAreaLabel, +}); + +export { TextArea }; diff --git a/src/shared/ui/textarea/TextArea.types.ts b/src/shared/ui/textarea/TextArea.types.ts new file mode 100644 index 0000000..baf426d --- /dev/null +++ b/src/shared/ui/textarea/TextArea.types.ts @@ -0,0 +1,40 @@ +import type { ComponentPropsWithoutRef, ComponentPropsWithRef, ReactNode } from 'react'; + +interface TextAreaProps extends Omit, 'id'> { + children: ReactNode; + containerId?: string; + disabled?: boolean; + id?: string; + invalid?: boolean; + required?: boolean; +} + +interface TextAreaFieldBaseProps extends Omit< + ComponentPropsWithRef<'textarea'>, + | 'aria-describedby' + | 'aria-errormessage' + | 'aria-invalid' + | 'disabled' + | 'id' + | 'maxLength' + | 'required' +> { + showCount?: boolean; +} + +type TextAreaFieldProps = TextAreaFieldBaseProps & + ( + | { maxLength: number; recommendedLength?: never } + | { maxLength?: never; recommendedLength: number } + | { maxLength?: undefined; recommendedLength?: undefined } + ); + +type TextAreaLabelProps = Omit, 'htmlFor'> & { + children: ReactNode; +}; + +type TextAreaErrorMessageProps = Omit, 'id'> & { + children?: ReactNode; +}; + +export type { TextAreaErrorMessageProps, TextAreaFieldProps, TextAreaLabelProps, TextAreaProps }; diff --git a/src/shared/ui/textarea/TextAreaErrorMessage.tsx b/src/shared/ui/textarea/TextAreaErrorMessage.tsx new file mode 100644 index 0000000..7e442b3 --- /dev/null +++ b/src/shared/ui/textarea/TextAreaErrorMessage.tsx @@ -0,0 +1,11 @@ +import { FormControlErrorMessage } from '@/shared/ui/form-control/FormControlErrorMessage'; + +import type { TextAreaErrorMessageProps } from './TextArea.types'; + +export function TextAreaErrorMessage({ children, className, ...props }: TextAreaErrorMessageProps) { + return ( + + {children} + + ); +} diff --git a/src/shared/ui/textarea/TextAreaField.tsx b/src/shared/ui/textarea/TextAreaField.tsx new file mode 100644 index 0000000..dfbf7dc --- /dev/null +++ b/src/shared/ui/textarea/TextAreaField.tsx @@ -0,0 +1,126 @@ +import { useState } from 'react'; + +import { cn } from '@/shared/styles/utils/cn'; +import { useFormControlContext } from '@/shared/ui/form-control/FormControlContext'; +import { + fieldBaseClassName, + fieldInteractionClassName, +} from '@/shared/ui/form-control/formControlStyles'; + +import type { TextAreaFieldProps } from './TextArea.types'; + +const getValueLength = (value: TextAreaFieldProps['value']) => String(value ?? '').length; + +/** + * ## TextArea.Field + * + * @description + * 실제 HTML `textarea`를 렌더링하고 선택적으로 현재 글자 수를 표시합니다. 네이티브 + * `maxLength`는 입력을 제한하며 `recommendedLength`는 입력을 막지 않는 권장 기준입니다. + * + * ### 주요 내용 + * + * controlled 값은 렌더링 중 길이를 계산하고 uncontrolled 값은 입력 이벤트에서 길이를 + * 갱신합니다. React Hook Form의 `field` 객체와 React 19의 일반 `ref` prop을 지원합니다. + * + * @param recommendedLength - 입력을 제한하지 않는 권장 글자 수 + * @param showCount - 현재 글자 수와 기준 글자 수 표시 여부 + * @param maxLength - 브라우저가 강제하는 최대 입력 글자 수 + */ +export function TextAreaField({ + className, + defaultValue, + maxLength, + onChange, + recommendedLength, + ref, + showCount = false, + value, + ...props +}: TextAreaFieldProps) { + const { disabled, errorMessageId, fieldId, invalid, required } = useFormControlContext(); + const [uncontrolledLength, setUncontrolledLength] = useState(() => getValueLength(defaultValue)); + const isControlled = value !== undefined; + const currentLength = isControlled ? getValueLength(value) : uncontrolledLength; + const countId = `${fieldId}-character-count`; + const describedBy = [showCount ? countId : undefined, invalid ? errorMessageId : undefined] + .filter(Boolean) + .join(' '); + const hasReachedMaxLength = maxLength !== undefined && currentLength >= maxLength; + const hasExceededRecommendedLength = + recommendedLength !== undefined && currentLength > recommendedLength; + const countLimit = recommendedLength ?? maxLength; + const hasExceededCountLimit = countLimit !== undefined && currentLength > countLimit; + const isNearCountLimit = + countLimit !== undefined && currentLength >= countLimit * 0.9 && !hasExceededCountLimit; + + const handleChange: NonNullable = (event) => { + const inputValue = event.currentTarget.value; + const nextValue = + maxLength !== undefined && inputValue.length > maxLength + ? inputValue.slice(0, maxLength) + : inputValue; + + if (inputValue !== nextValue) { + event.currentTarget.value = nextValue; + } + + if (!isControlled) { + setUncontrolledLength(nextValue.length); + } + + onChange?.(event); + }; + + return ( +
+