diff --git a/src/shared/styles/base/scroll.css b/src/shared/styles/base/scroll.css new file mode 100644 index 0000000..828d383 --- /dev/null +++ b/src/shared/styles/base/scroll.css @@ -0,0 +1,44 @@ +.textarea-scrollbar { + scrollbar-color: var(--color-gray-300) transparent; + scrollbar-width: thin; +} + +.textarea-scrollbar::-webkit-scrollbar { + width: 10px; +} + +.textarea-scrollbar::-webkit-scrollbar-track { + margin-block: 8px; + background: transparent; +} + +.textarea-scrollbar::-webkit-scrollbar-thumb { + border: 3px solid transparent; + border-radius: 9999px; + background-color: var(--color-gray-300); + background-clip: content-box; +} + +.textarea-scrollbar::-webkit-scrollbar-thumb:hover { + background-color: var(--color-gray-200); +} + +.textarea-scrollbar::-webkit-scrollbar-corner { + background: transparent; +} + +.textarea-scrollbar::-webkit-resizer { + background-color: transparent; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath d='M5 11L11 5M1 11L11 1' fill='none' stroke='%23a6a6a6' stroke-linecap='round' stroke-width='1.5'/%3E%3C/svg%3E"); + background-position: right bottom; + background-repeat: no-repeat; + background-size: 12px 12px; +} + +.textarea-scrollbar:disabled { + scrollbar-color: var(--color-gray-500) transparent; +} + +.textarea-scrollbar:disabled::-webkit-scrollbar-thumb { + background-color: var(--color-gray-500); +} diff --git a/src/shared/styles/globals.css b/src/shared/styles/globals.css index d443122..84a1f97 100644 --- a/src/shared/styles/globals.css +++ b/src/shared/styles/globals.css @@ -1,6 +1,7 @@ @import 'tailwindcss'; @import './base/fonts.css'; @import './base/colors.css'; +@import './base/scroll.css'; button { cursor: pointer; 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..542a27c --- /dev/null +++ b/src/shared/ui/form-control/FormControl.types.ts @@ -0,0 +1,40 @@ +import type { ComponentPropsWithoutRef, ReactNode } from 'react'; + +/** Input과 TextArea Root가 Label, Field, ErrorMessage에 제공하는 공통 상태입니다. */ +interface FormControlState { + disabled: boolean; + errorMessageId: string; + fieldId: string; + invalid: boolean; + required: boolean; +} + +/** FormControl 하위 컴포넌트가 공유하는 상태와 ErrorMessage 등록 동작입니다. */ +interface FormControlContextValue extends FormControlState { + hasErrorMessage: boolean; + registerErrorMessage: () => () => void; +} + +/** ErrorMessage 등록 상태를 관리하는 내부 Provider props입니다. */ +interface FormControlProviderProps { + children: ReactNode; + value: FormControlState; +} + +/** 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, + FormControlProviderProps, + FormControlState, +}; 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..58f4914 --- /dev/null +++ b/src/shared/ui/form-control/FormControlErrorMessage.tsx @@ -0,0 +1,68 @@ +import { useEffect } from 'react'; + +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-errormessage`로 참조하도록 등록합니다. + * + * @param children - 유효성 검증 실패 원인과 해결 방법을 설명하는 문구 + * @param className - 공통 오류 문구 스타일을 확장하는 클래스 이름 + * + * @example + * ```tsx + * + * ``` + */ +export function FormControlErrorMessage({ + children, + className, + ...props +}: FormControlErrorMessageProps) { + const { errorMessageId, invalid, registerErrorMessage } = useFormControlContext(); + const hasMessage = Boolean(children); + + useEffect(() => { + if (!invalid || !hasMessage) { + return; + } + + return registerErrorMessage(); + }, [hasMessage, invalid, registerErrorMessage]); + + if (!invalid || !hasMessage) { + 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/FormControlProvider.tsx b/src/shared/ui/form-control/FormControlProvider.tsx new file mode 100644 index 0000000..9f20447 --- /dev/null +++ b/src/shared/ui/form-control/FormControlProvider.tsx @@ -0,0 +1,35 @@ +import { useCallback, useState } from 'react'; + +import { FormControlContext } from './FormControlContext'; + +import type { FormControlContextValue, FormControlProviderProps } from './FormControl.types'; + +/** + * ## FormControlProvider + * + * @description + * Input과 TextArea Root의 공통 상태를 제공하고 실제 렌더링된 ErrorMessage의 수를 관리합니다. + * Field는 등록된 오류 메시지가 있을 때만 해당 id를 ARIA 속성으로 참조합니다. + * + * @param children - FormControl Context를 사용하는 compound 하위 컴포넌트 + * @param value - Root가 제공하는 id, required, disabled, invalid 상태 + */ +export function FormControlProvider({ children, value }: FormControlProviderProps) { + const [errorMessageCount, setErrorMessageCount] = useState(0); + + const registerErrorMessage = useCallback(() => { + setErrorMessageCount((currentCount) => currentCount + 1); + + return () => { + setErrorMessageCount((currentCount) => Math.max(0, currentCount - 1)); + }; + }, []); + + const contextValue: FormControlContextValue = { + ...value, + hasErrorMessage: errorMessageCount > 0, + registerErrorMessage, + }; + + return {children}; +} 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/form-field/FormField.types.ts b/src/shared/ui/form-field/FormField.types.ts index a8c3165..9a79148 100644 --- a/src/shared/ui/form-field/FormField.types.ts +++ b/src/shared/ui/form-field/FormField.types.ts @@ -9,17 +9,34 @@ import type { UseFormStateReturn, } from 'react-hook-form'; +/** + * FormField의 render 함수에 전달하는 React Hook Form 상태입니다. + * + * Controller의 원본 상태를 유지하면서 공통 UI가 바로 사용할 수 있도록 `invalid`와 + * `errorMessage`를 편의 값으로 제공합니다. + */ interface FormFieldRenderProps< TFieldValues extends FieldValues, TName extends FieldPath, > { + /** 현재 Field의 validation 오류 메시지입니다. */ errorMessage?: string; + /** 실제 입력 요소에 전달할 name, value, 이벤트 핸들러, ref입니다. */ field: ControllerRenderProps; + /** touched, dirty, invalid 등 현재 Field 단위 상태입니다. */ fieldState: ControllerFieldState; + /** submit, validating 등 폼 전체 상태입니다. */ formState: UseFormStateReturn; + /** 현재 Field에 validation 오류가 있는지 나타내는 편의 값입니다. */ invalid: boolean; } +/** + * React Hook Form의 Controller props를 사용하는 공통 FormField adapter 타입입니다. + * + * Controller의 기본 `render` 대신 공통 UI에 필요한 편의 값이 포함된 + * `FormFieldRenderProps`를 전달하는 render 함수를 사용합니다. + */ type FormFieldProps> = Omit< ControllerProps, 'render' 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..95c3032 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 { FormControlState } from '@/shared/ui/form-control/FormControl.types'; +import { FormControlProvider } from '@/shared/ui/form-control/FormControlProvider'; -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: FormControlState = { 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..8c0264a 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; } @@ -40,7 +40,7 @@ interface InputControlProps extends ComponentPropsWithoutRef<'div'> { */ interface InputFieldProps extends Omit< ComponentPropsWithRef<'input'>, - 'aria-describedby' | 'aria-invalid' | 'disabled' | 'id' | 'readOnly' | 'required' | 'type' + 'aria-errormessage' | 'aria-invalid' | 'disabled' | 'id' | 'readOnly' | 'required' | 'type' > { type?: InputType; } @@ -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..6d50b54 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'; @@ -18,8 +21,8 @@ import type { InputFieldProps } from './Input.types'; * * ### 접근성 * - * Root 상태에 따라 `required`, `aria-invalid`, `aria-describedby`, `aria-errormessage`를 - * 설정합니다. 접근성 이름은 함께 구성한 `Input.Label`에서 제공받습니다. + * Root 상태에 따라 `required`, `aria-invalid`, `aria-errormessage`를 설정합니다. + * 접근성 이름은 함께 구성한 `Input.Label`에서 제공받습니다. * * @param type - 지원하는 입력 타입. 기본값은 `text` * @param ref - Field DOM 요소에 접근하거나 폼 라이브러리와 연결하는 ref @@ -31,24 +34,22 @@ 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, hasErrorMessage, invalid, required } = + useFormControlContext(); + const ariaErrorMessageId = invalid && hasErrorMessage ? errorMessageId : undefined; return ( + * * * - * + * * ``` */ -export function InputControl({ children, className, ...props }: InputControlProps) { +export function InputFieldGroup({ children, className, ...props }: InputFieldGroupProps) { return (
+ {children} - {required ? ( - - ) : null} - + ); } 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..c793f88 --- /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 { FormControlState } from '@/shared/ui/form-control/FormControl.types'; +import { FormControlProvider } from '@/shared/ui/form-control/FormControlProvider'; + +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: FormControlState = { + 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..573b12e --- /dev/null +++ b/src/shared/ui/textarea/TextArea.types.ts @@ -0,0 +1,63 @@ +import type { ComponentPropsWithoutRef, ComponentPropsWithRef, ReactNode } from 'react'; + +/** + * Compound TextArea 전체에서 공유하는 상태입니다. + * + * `id`는 실제 textarea에 적용되며 Label, 글자 수, ErrorMessage의 접근성 연결에 + * 사용됩니다. DOM wrapper에 별도 id가 필요하면 `containerId`를 사용합니다. + */ +interface TextAreaProps extends Omit, 'id'> { + children: ReactNode; + containerId?: string; + disabled?: boolean; + id?: string; + invalid?: boolean; + required?: boolean; +} + +/** + * 실제 textarea가 지원하는 공통 props입니다. + * + * 접근성 및 공통 상태는 Root에서 관리합니다. React Hook Form의 `name`, `value`, + * `onChange`, `onBlur`, `ref`를 포함한 나머지 네이티브 textarea props를 전달할 수 있습니다. + */ +interface TextAreaFieldBaseProps extends Omit< + ComponentPropsWithRef<'textarea'>, + | 'aria-describedby' + | 'aria-errormessage' + | 'aria-invalid' + | 'children' + | 'disabled' + | 'id' + | 'maxLength' + | 'required' +> { + /** 현재 글자 수와 선택한 길이 기준의 표시 여부입니다. */ + showCount?: boolean; +} + +/** + * TextArea의 글자 수 기준입니다. + * + * `maxLength`는 입력을 실제로 제한하고 `recommendedLength`는 초과 입력을 허용합니다. + * 두 기준은 동작이 다르므로 동시에 전달할 수 없습니다. + */ +type TextAreaLengthProps = + | { maxLength: number; recommendedLength?: never } + | { maxLength?: never; recommendedLength: number } + | { maxLength?: undefined; recommendedLength?: undefined }; + +/** 실제 textarea 요소의 네이티브 props와 글자 수 기준을 결합한 Field props입니다. */ +type TextAreaFieldProps = TextAreaFieldBaseProps & TextAreaLengthProps; + +/** Label은 Root가 제공하는 id와 required 상태를 상속합니다. */ +type TextAreaLabelProps = Omit, 'htmlFor'> & { + children: ReactNode; +}; + +/** Root가 invalid일 때 유효성 검증 오류를 표시하는 문구입니다. */ +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..d502cf7 --- /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, hasErrorMessage, 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; + const ariaErrorMessageId = invalid && hasErrorMessage ? errorMessageId : undefined; + 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 ( +
+