/** * @title Form * @description 사용자가 정보를 입력하고 제출하도록 필드·라벨·메시지·레이아웃을 묶어 주는 폼이에요. */import * as React from 'react';import { Slot } from 'radix-ui';import { CircleAlert, CircleCheck } from 'lucide-react';import { cn } from '@/lib/utils';// Atlassian @atlaskit/form — label / messages / field / fieldset / form-header / form-section / form-footer 해독 스펙// react-final-form 대신 가벼운 내부 상태로 필드 검증(blur·submit)과 제출 오류를 다뤄요./* -------------------------------------------------------------------------- *//* Context *//* -------------------------------------------------------------------------- */type FormValues = Record<string, unknown>;type Validator = (value: unknown, formValues: FormValues) => string | void | undefined | Promise<string | void | undefined>;interface FieldEntry { validate?: Validator; setError: (error: string | undefined) => void; setTouched: () => void;}interface FormContextValue { isDisabled: boolean; submitting: boolean; register: (name: string, entry: FieldEntry) => () => void;}const FormContext = React.createContext<FormContextValue | null>(null);const FieldIdContext = React.createContext<string | undefined>(undefined);// platform-dst-motion-uplift-input ON: 메시지 등장·퇴장 모션// - 'row': 등장 150ms(form.message.enter) 아래 2px 에서 올라오며 페이드 인 + 행 높이 0fr→1fr,// 퇴장 100ms(form.message.exit) 아래 2px 로 내려가며 페이드 아웃 + 높이 1fr→0fr// - 'instant': 메시지가 동시에 바뀌거나(추가+삭제) 제출로 바뀌면 모션 없이 즉시 교체type MessageTransition = 'row' | 'instant';interface MessageWrapperContextValue { isWrapper: boolean; hasMotionBoundary: boolean; transition: MessageTransition;}const MessageWrapperContext = React.createContext<MessageWrapperContextValue>({ isWrapper: false, hasMotionBoundary: false, transition: 'row',});/** MessageWrapper 가 퇴장 중인 메시지에 알려주는 값 */const MessagePresenceContext = React.createContext(false);/** 제출 횟수. 제출 때문에 바뀌는 메시지는 모션 없이 바꿔요. */const FormSubmitContext = React.createContext(0);/** 폼 안에서 비활성 여부·제출 중 상태를 읽어요. */function useFormContext() { return React.useContext(FormContext);}// FormData → 값 객체 (같은 이름이 여러 개면 배열)function readValues(form: HTMLFormElement): FormValues { const values: FormValues = {}; const data = new FormData(form); for (const key of new Set(data.keys())) { const all = data.getAll(key).map((v) => (typeof v === 'string' ? v : v.name)); values[key] = all.length > 1 ? all : all[0]; } return values;}/* -------------------------------------------------------------------------- *//* Form *//* -------------------------------------------------------------------------- */type SubmitErrors = Record<string, string | undefined> | void | undefined;interface FormChildrenArgs { formProps: { ref: React.RefObject<HTMLFormElement | null>; onSubmit: (event: React.FormEvent<HTMLFormElement>) => void; noValidate?: boolean; 'aria-label'?: string; 'aria-labelledby'?: string; }; disabled: boolean; submitting: boolean; getValues: () => FormValues; reset: () => void;}export interface FormProps extends Omit<React.ComponentProps<'form'>, 'onSubmit' | 'children' | 'autoComplete'> { /** 제출 핸들러. 필드 검증을 모두 통과해야 호출돼요. `{ 필드이름: '오류' }` 를 반환하면 제출 오류로 보여줘요. */ onSubmit: (values: FormValues, event: React.FormEvent<HTMLFormElement>) => SubmitErrors | Promise<SubmitErrors>; /** JSX 또는 `({ formProps, submitting, disabled }) => ReactNode` 함수 */ children: React.ReactNode | ((args: FormChildrenArgs) => React.ReactNode); /** 폼과 모든 필드를 비활성으로 만들어요. */ isDisabled?: boolean; /** 폼의 접근성 이름 (aria-label) */ label?: string; /** 접근성 이름을 가진 요소의 id (aria-labelledby) */ labelId?: string; /** 브라우저 자동 완성 */ autocomplete?: 'on' | 'off'; testId?: string;}function Form({ onSubmit, children, isDisabled = false, label, labelId, autocomplete, testId, noValidate, className, ...props}: FormProps) { const formRef = React.useRef<HTMLFormElement | null>(null); const fieldsRef = React.useRef(new Map<string, FieldEntry>()); const [submitting, setSubmitting] = React.useState(false); const [submitCount, setSubmitCount] = React.useState(0); const register = React.useCallback((name: string, entry: FieldEntry) => { fieldsRef.current.set(name, entry); return () => { if (fieldsRef.current.get(name) === entry) fieldsRef.current.delete(name); }; }, []); const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => { event.preventDefault(); const form = formRef.current; if (!form || isDisabled || submitting) return; const values = readValues(form); // 1) 필드 검증 — 결과를 모은 뒤 한 번에 반영해요. 제출로 바뀌는 메시지는 모션 없이 바뀌어요. let hasError = false; const results: [FieldEntry, string | undefined][] = []; for (const [name, field] of fieldsRef.current) { const error = field.validate ? (await field.validate(values[name], values)) || undefined : undefined; results.push([field, error]); if (error) hasError = true; } setSubmitCount((count) => count + 1); for (const [field, error] of results) { field.setTouched(); field.setError(error); } if (hasError) { requestAnimationFrame(() => form.querySelector<HTMLElement>('[aria-invalid="true"]')?.focus()); return; } // 2) 제출 (비동기면 submitting 상태) setSubmitting(true); try { const errors = await onSubmit(values, event); if (errors) { for (const [name, message] of Object.entries(errors)) fieldsRef.current.get(name)?.setError(message); requestAnimationFrame(() => form.querySelector<HTMLElement>('[aria-invalid="true"]')?.focus()); } } finally { setSubmitting(false); } }; const reset = () => { formRef.current?.reset(); for (const field of fieldsRef.current.values()) field.setError(undefined); }; const formProps: FormChildrenArgs['formProps'] = { ref: formRef, onSubmit: handleSubmit, noValidate, 'aria-label': label, 'aria-labelledby': labelId, }; const context = React.useMemo(() => ({ isDisabled, submitting, register }), [isDisabled, submitting, register]); return ( <FormContext.Provider value={context}> <FormSubmitContext.Provider value={submitCount}> {typeof children === 'function' ? ( children({ formProps, disabled: isDisabled, submitting, getValues: () => (formRef.current ? readValues(formRef.current) : {}), reset, }) ) : ( <form {...props} {...formProps} data-slot="form" data-testid={testId} autoComplete={autocomplete} aria-busy={submitting || undefined} className={cn('w-full', className)} > {children} </form> )} </FormSubmitContext.Provider> </FormContext.Provider> );}/* -------------------------------------------------------------------------- *//* Label / Legend / RequiredAsterisk *//* -------------------------------------------------------------------------- */// label.js: font.body.small + bold, text.subtle, 아래 space.050const labelClass = 'mt-ds-0 mb-ds-050 inline-block ds-font-body-small font-ds-bold text-ds-text-subtle';export interface LabelProps extends React.ComponentProps<'label'> { testId?: string;}function Label({ className, testId, ...props }: LabelProps) { return <label data-slot="form-label" data-testid={testId} className={cn(labelClass, className)} {...props} />;}function Legend({ className, ...props }: React.ComponentProps<'legend'>) { return <legend data-slot="form-legend" className={cn(labelClass, 'p-0', className)} {...props} />;}/** 필수 표시 별표(*). 스크린 리더에는 숨기고 입력 요소의 required 로 알려요. */function RequiredAsterisk({ className, ...props }: React.ComponentProps<'span'>) { return ( <span aria-hidden="true" title="필수" data-slot="required-asterisk" className={cn('ps-ds-025 font-ds-body text-ds-text-danger', className)} {...props} > * </span> );}/* -------------------------------------------------------------------------- *//* Messages *//* -------------------------------------------------------------------------- */type MessageAppearance = 'default' | 'error' | 'valid';const messageColor: Record<MessageAppearance, string> = { default: 'text-ds-text-subtlest', error: 'text-ds-text-danger', valid: 'text-ds-text-success',};const MESSAGE_ENTER_MS = 150;const MESSAGE_EXIT_MS = 100;const messageEnterClass = cn( 'transition-[grid-template-rows] starting:grid-rows-[0fr]', 'animate-in fade-in-0 slide-in-from-bottom-[2px]', 'duration-(--ds-motion-form-message-enter-duration) ease-(--ds-motion-form-message-enter-curve)', 'motion-reduce:animate-none motion-reduce:transition-none',);const messageExitClass = cn( 'grid-rows-[0fr] transition-[grid-template-rows]', 'animate-out fade-out-0 slide-out-to-bottom-[2px] fill-mode-forwards', 'duration-(--ds-motion-form-message-exit-duration) ease-(--ds-motion-form-message-exit-curve)', 'motion-reduce:animate-none motion-reduce:transition-none',);function Message({ appearance = 'default', fieldId, testId, children,}: { appearance?: MessageAppearance; fieldId?: string; testId?: string; children?: React.ReactNode;}) { const { isWrapper, hasMotionBoundary, transition } = React.useContext(MessageWrapperContext); const isExiting = React.useContext(MessagePresenceContext); // 오류 메시지는 혼자서도, 나머지는 MessageWrapper(모션 경계) 안에서만 움직여요. const shouldAnimate = appearance === 'error' || hasMotionBoundary; const [isEntering, setIsEntering] = React.useState(() => shouldAnimate && transition !== 'instant'); React.useEffect(() => { if (!isEntering) return; const t = setTimeout(() => setIsEntering(false), MESSAGE_ENTER_MS); return () => clearTimeout(t); }, [isEntering]); const Icon = appearance === 'error' ? CircleAlert : appearance === 'valid' ? CircleCheck : null; const row = ( <div id={isExiting ? undefined : fieldId} data-slot="form-message" data-appearance={appearance} data-testid={testId} aria-live={isWrapper ? undefined : 'polite'} aria-hidden={isExiting || undefined} className={cn('mt-ds-050 flex gap-ds-075 ds-font-body-small', messageColor[appearance])} > {Icon && ( <span className="flex h-4 items-center"> <Icon className="size-3 shrink-0" aria-label={appearance === 'error' ? '오류' : '성공'} role="img" /> </span> )} {typeof children === 'string' ? children : <span>{children}</span>} </div> ); if (!shouldAnimate) return row; const state = isExiting ? 'exiting' : isEntering ? 'entering' : 'visible'; return ( <div data-slot="form-message-motion" data-motion-state={state} data-testid={testId && `${testId}-motion`} aria-hidden={isExiting || undefined} className={cn( 'grid grid-rows-[1fr]', state === 'entering' && messageEnterClass, state === 'exiting' && (transition === 'instant' ? 'hidden' : messageExitClass), )} > <div className="min-h-0">{row}</div> </div> );}interface MessageProps { children?: React.ReactNode; testId?: string;}/** 필드에 어떤 값을 넣어야 하는지 알려주는 도움말 */function HelperMessage({ children, testId }: MessageProps) { const fieldId = React.useContext(FieldIdContext); return ( <Message fieldId={fieldId && `${fieldId}-helper`} testId={testId}> {children} </Message> );}/** 입력값이 잘못됐음을 알려주는 오류 메시지 */function ErrorMessage({ children, testId }: MessageProps) { const fieldId = React.useContext(FieldIdContext); return ( <Message appearance="error" fieldId={fieldId && `${fieldId}-error`} testId={testId}> {children} </Message> );}/** 입력값이 올바르다는 확인 메시지 */function ValidMessage({ children, testId }: MessageProps) { const fieldId = React.useContext(FieldIdContext); return ( <Message appearance="valid" fieldId={fieldId && `${fieldId}-valid`} testId={testId}> {children} </Message> );}const isMotionCapable = (child: React.ReactNode) => React.isValidElement(child) && (child.type === HelperMessage || child.type === ErrorMessage || child.type === ValidMessage);const keyOf = (child: React.ReactElement) => String(child.key);interface ExitingMessage { key: string; element: React.ReactElement; index: number;}/** 자식 메시지가 추가·삭제될 때 등장·퇴장 모션을 주고, 퇴장하는 메시지를 잠깐 더 그려요. */function MotionMessages({ children }: { children?: React.ReactNode }) { const submitCount = React.useContext(FormSubmitContext); const current = React.Children.toArray(children).filter(React.isValidElement) as React.ReactElement[]; const currentKeys = current.map(keyOf); const [snapshot, setSnapshot] = React.useState(() => ({ keys: currentKeys, transition: 'row' as MessageTransition, submitCount, })); const [exiting, setExiting] = React.useState<ExitingMessage[]>([]); // 마지막으로 커밋된 메시지 요소 (퇴장할 때 마지막 내용을 그대로 그려요) const latestRef = React.useRef(new Map<string, React.ReactElement>(current.map((el) => [keyOf(el), el]))); React.useEffect(() => { latestRef.current = new Map(current.map((el) => [keyOf(el), el])); }); const hasKeyChange = currentKeys.length !== snapshot.keys.length || currentKeys.some((key, i) => key !== snapshot.keys[i]); const isSubmitUpdate = submitCount !== snapshot.submitCount; let transition = snapshot.transition; if (hasKeyChange || isSubmitUpdate) { const removedKeys = snapshot.keys.filter((key) => !currentKeys.includes(key)); const addedCount = currentKeys.filter((key) => !snapshot.keys.includes(key)).length; if (hasKeyChange) transition = isSubmitUpdate || (removedKeys.length > 0 && addedCount > 0) ? 'instant' : 'row'; // 키 변화 없이 제출만 했으면 직전 모드를 유지해요. setSnapshot({ keys: currentKeys, transition, submitCount }); const removed: ExitingMessage[] = []; if (transition !== 'instant') { for (const key of removedKeys) { // 직전 커밋의 요소를 읽기만 해요 const element = latestRef.current.get(key); if (element) removed.push({ key, element, index: snapshot.keys.indexOf(key) }); } } setExiting((items) => [ ...(transition === 'instant' ? [] : items.filter((item) => !currentKeys.includes(item.key))), ...removed, ]); } React.useEffect(() => { if (!exiting.length) return; const keys = exiting.map((item) => item.key); const t = setTimeout( () => setExiting((items) => items.filter((item) => !keys.includes(item.key))), MESSAGE_EXIT_MS, ); return () => clearTimeout(t); }, [exiting]); const rendered: { key: string; element: React.ReactElement; isExiting: boolean }[] = current.map((element) => ({ key: keyOf(element), element, isExiting: false, })); for (const item of [...exiting].sort((a, b) => a.index - b.index)) { if (currentKeys.includes(item.key)) continue; rendered.splice(Math.min(item.index, rendered.length), 0, { ...item, isExiting: true }); } const value = React.useMemo(() => ({ isWrapper: true, hasMotionBoundary: true, transition }), [transition]); return ( <MessageWrapperContext.Provider value={value}> {rendered.map(({ key, element, isExiting }) => ( <MessagePresenceContext.Provider key={key} value={isExiting}> {element} </MessagePresenceContext.Provider> ))} </MessageWrapperContext.Provider> );}const WRAPPER_CONTEXT: MessageWrapperContextValue = { isWrapper: true, hasMotionBoundary: false, transition: 'row' };/** * 오류·확인 메시지를 스크린 리더가 읽도록 aria-live 영역으로 감싸요. 메시지보다 먼저 DOM 에 있어야 해요. * 자식이 모두 HelperMessage·ErrorMessage·ValidMessage 면 메시지가 바뀔 때 등장·퇴장 모션을 줘요. */function MessageWrapper({ children }: { children?: React.ReactNode }) { const shouldProvideMotionBoundary = React.Children.toArray(children).every(isMotionCapable); return ( <MessageWrapperContext.Provider value={WRAPPER_CONTEXT}> <div aria-live="polite" data-slot="message-wrapper"> {shouldProvideMotionBoundary ? <MotionMessages>{children}</MotionMessages> : children} </div> </MessageWrapperContext.Provider> );}/* -------------------------------------------------------------------------- *//* Field *//* -------------------------------------------------------------------------- */export interface FieldMeta { touched: boolean; valid: boolean; error?: string;}export interface FieldControlProps { id: string; name: string; isRequired: boolean; isDisabled: boolean; isInvalid: boolean; onChange: (event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => void; onBlur: (event: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => void; 'aria-invalid': 'true' | 'false'; 'aria-labelledby': string; 'aria-describedby'?: string;}export interface FieldProps extends Omit<React.ComponentProps<'div'>, 'children' | 'defaultValue'> { /** 폼 데이터에서 쓰는 이름 */ name: string; /** 필드 위에 보이는 라벨 */ label?: React.ReactNode; /** 라벨(과 별표) 뒤에 붙는 요소 */ elementAfterLabel?: React.ReactNode; /** 필수 필드. 라벨 뒤에 빨간 별표가 붙어요. */ isRequired?: boolean; /** 비활성. 폼이 비활성이면 항상 비활성이에요. */ isDisabled?: boolean; /** 검증 결과와 상관없이 오류 상태로 보여줘요. */ isInvalid?: boolean; /** input 의 id. 없으면 `${name}-<uid>` 로 만들어요. */ id?: string; /** 처음 값 (입력 요소의 defaultValue 로 넘겨요) */ defaultValue?: string; /** 도움말 */ helperMessage?: React.ReactNode; /** 오류일 때 보여줄 메시지 (없으면 validate 가 돌려준 문자열) */ errorMessage?: React.ReactNode; /** 검증을 통과했을 때 보여줄 메시지 */ validMessage?: React.ReactNode; /** 값 검증. 오류 문자열을 반환하면 오류 상태가 돼요. blur·제출 시 실행돼요. */ validate?: Validator; /** * 입력 요소 하나 (Textfield, TextArea, input …). id·name·aria-* 를 자동으로 연결해요. * Atlaskit 처럼 `({ fieldProps, error, valid, meta }) => ReactNode` 함수도 쓸 수 있어요. */ children: | React.ReactElement | ((args: { fieldProps: FieldControlProps; error?: string; valid: boolean; meta: FieldMeta }) => React.ReactNode); testId?: string;}function Field({ name, label, elementAfterLabel, isRequired = false, isDisabled: isDisabledProp = false, isInvalid: isInvalidProp = false, id: idProp, defaultValue, helperMessage, errorMessage, validMessage, validate, children, testId, className, ...props}: FieldProps) { const form = useFormContext(); const uid = React.useId(); const fieldId = idProp ?? `${name}-${uid.replace(/:/g, '')}`; const isDisabled = isDisabledProp || !!form?.isDisabled; const [error, setError] = React.useState<string | undefined>(); const [touched, setTouchedState] = React.useState(false); const [checked, setChecked] = React.useState(false); const validateRef = React.useRef(validate); validateRef.current = validate; React.useEffect(() => { if (!form) return; return form.register(name, { validate: (value, values) => validateRef.current?.(value, values), setError: (e) => { setError(e); setChecked(true); }, setTouched: () => setTouchedState(true), }); }, [form, name]); const runValidate = async (value: unknown) => { if (!validateRef.current) return; const values = (document.getElementById(fieldId) as HTMLInputElement | null)?.form; const result = await validateRef.current(value, values ? readValues(values) : {}); setError(result || undefined); setChecked(true); }; const readTarget = (target: EventTarget) => { const el = target as HTMLInputElement; return el.type === 'checkbox' ? (el.checked ? el.value : undefined) : el.value; }; const onBlur = (event: React.FocusEvent<HTMLElement>) => { setTouchedState(true); void runValidate(readTarget(event.target)); }; const onChange = (event: React.ChangeEvent<HTMLElement>) => { // 한 번 건드린 뒤에는 입력할 때마다 다시 검증해요 (오류를 바로 지워 줘요). if (touched || error) void runValidate(readTarget(event.target)); }; const hasError = !!error || isInvalidProp; const showError = hasError && !!(errorMessage || error); const isValid = touched && checked && !hasError; const showValid = isValid && !!validMessage; const describedBy = [showError && `${fieldId}-error`, showValid && `${fieldId}-valid`, helperMessage && `${fieldId}-helper`] .filter(Boolean) .join(' ') || undefined; const fieldProps: FieldControlProps = { id: fieldId, name, isRequired, isDisabled, isInvalid: hasError, onChange, onBlur, 'aria-invalid': hasError ? 'true' : 'false', 'aria-labelledby': `${fieldId}-label`, 'aria-describedby': describedBy, }; // 입력 요소에 그대로 얹을 속성 (Textfield·TextArea 는 aria-invalid·disabled 를 읽어 상태를 맞춰요) const slotProps = { id: fieldId, name, defaultValue, required: isRequired, disabled: isDisabled || undefined, 'aria-invalid': hasError ? 'true' : 'false', 'aria-required': isRequired ? 'true' : undefined, 'aria-labelledby': label ? `${fieldId}-label` : undefined, 'aria-describedby': describedBy, onBlur, onChange, }; return ( <div data-slot="field" data-testid={testId} className={cn('mt-ds-100', className)} {...props}> {label && ( <Label htmlFor={fieldId} id={`${fieldId}-label`} testId={testId && `${testId}--label`}> {label} {isRequired && <RequiredAsterisk />} {elementAfterLabel} </Label> )} <FieldIdContext.Provider value={fieldId}> {typeof children === 'function' ? ( children({ fieldProps, error, valid: isValid, meta: { touched, valid: isValid, error } }) ) : ( <> <Slot.Root {...(slotProps as React.HTMLAttributes<HTMLElement>)}> {children} </Slot.Root> <MessageWrapper> {helperMessage && <HelperMessage>{helperMessage}</HelperMessage>} {showError && <ErrorMessage>{errorMessage || error}</ErrorMessage>} {showValid && <ValidMessage>{validMessage}</ValidMessage>} </MessageWrapper> </> )} </FieldIdContext.Provider> </div> );}/* -------------------------------------------------------------------------- *//* Fieldset / layout *//* -------------------------------------------------------------------------- */export interface FieldsetProps extends React.ComponentProps<'fieldset'> { /** 묶음 제목 (legend) */ legend?: React.ReactNode;}/** 여러 필드(체크박스 묶음 등)를 하나로 묶어요. */function Fieldset({ legend, children, className, ...props }: FieldsetProps) { return ( <fieldset data-slot="fieldset" className={cn('mx-0 mb-0 mt-ds-100 min-w-0 border-0 p-0', className)} {...props}> {legend && <Legend>{legend}</Legend>} {children} </fieldset> );}export interface FormHeaderProps extends Omit<React.ComponentProps<'div'>, 'title'> { /** 폼 제목 (heading large) */ title?: React.ReactNode; /** 제목 아래 설명 */ description?: React.ReactNode;}function FormHeader({ title, description, children, className, ...props }: FormHeaderProps) { return ( <div data-slot="form-header" className={className} {...props}> {title && <h2 className="m-0 ds-font-heading-large text-ds-text">{title}</h2>} {description && <div className="mt-ds-100 ds-font-body text-ds-text">{description}</div>} {children && <div className="mt-ds-100 min-w-full">{children}</div>} </div> );}export interface FormSectionProps extends Omit<React.ComponentProps<'div'>, 'title'> { /** 섹션 제목 (heading medium) */ title?: React.ReactNode; /** 섹션 설명 */ description?: React.ReactNode;}function FormSection({ title, description, children, className, ...props }: FormSectionProps) { return ( <div data-slot="form-section" className={cn('mt-ds-300', className)} {...props}> {title && <h3 className="m-0 ds-font-heading-medium text-ds-text">{title}</h3>} {description && <div className="mt-ds-100 ds-font-body text-ds-text">{description}</div>} {children} </div> );}export interface FormFooterProps extends React.ComponentProps<'footer'> { /** 내용 정렬. 단일 페이지 폼·플래그·카드에서는 `start`, 모달에서는 `end` */ align?: 'start' | 'end';}function FormFooter({ align = 'end', className, ...props }: FormFooterProps) { return ( <footer data-slot="form-footer" className={cn('mt-ds-300 flex', align === 'start' ? 'justify-start' : 'justify-end', className)} {...props} /> );}/* -------------------------------------------------------------------------- *//* CharacterCounter *//* -------------------------------------------------------------------------- */export interface CharacterCounterProps { maxCharacters?: number; minCharacters?: number; /** 현재 입력값 */ currentValue?: string; /** 최대 글자 수를 넘었을 때 메시지 */ overMaximumMessage?: string; /** 최대 글자 수 이내일 때 메시지 */ underMaximumMessage?: string; /** 최소 글자 수보다 적을 때 메시지 */ underMinimumMessage?: string; /** 제한을 어기면 오류 스타일(빨간 글자 + 아이콘)로 보여줘요. */ shouldShowAsError?: boolean; /** 연결할 입력 id. Field 안에서는 자동이에요. `${inputId}-character-counter` 를 aria-describedby 에 넣어요. */ inputId?: string; testId?: string;}/** 남은 글자 수, 초과·부족을 실시간으로 알려주는 글자 수 표시 */function CharacterCounter({ maxCharacters, minCharacters, currentValue, overMaximumMessage, underMaximumMessage, underMinimumMessage, shouldShowAsError = true, inputId, testId,}: CharacterCounterProps) { const contextId = React.useContext(FieldIdContext); const fieldId = contextId || inputId; const length = currentValue?.length ?? 0; const isTooShort = minCharacters !== undefined && length < minCharacters; const isTooLong = maxCharacters !== undefined && length > maxCharacters; let text: string | null = null; if (isTooShort) text = underMinimumMessage || `${minCharacters! - length}자 더 입력해야 해요`; else if (isTooLong) text = overMaximumMessage || `${length - maxCharacters!}자 초과했어요`; else if (maxCharacters) text = underMaximumMessage || `${maxCharacters - length}자 남았어요`; // 스크린 리더 안내는 입력이 멈춘 뒤(1초) 한 번만 const [announcement, setAnnouncement] = React.useState(''); React.useEffect(() => { const t = setTimeout(() => setAnnouncement(text || ''), 1000); return () => clearTimeout(t); }, [text]); if (!text) return null; const isError = (isTooShort || isTooLong) && shouldShowAsError; return ( <div data-slot="character-counter" data-testid={testId} className="flex"> <div className={cn('mt-ds-050 flex gap-ds-075 ds-font-body-small', isError ? 'text-ds-text-danger' : 'text-ds-text-subtlest')}> {isError && ( <span className="flex h-4 items-center"> <CircleAlert className="size-3 shrink-0" aria-hidden /> </span> )} <span id={fieldId ? `${fieldId}-character-counter` : undefined}>{text}</span> </div> <div aria-live="polite" className="sr-only"> {announcement} </div> </div> );}export { Form, Field, Label, Legend, RequiredAsterisk, HelperMessage, ErrorMessage, ValidMessage, MessageWrapper, Fieldset, FormHeader, FormSection, FormFooter, CharacterCounter, useFormContext,};