문제 상황


기존 폼에서는 section 태그와 라벨 구조가 반복되어서, 이를 공통으로 사용하기 위해 FormSection 컴포넌트를 구현하여 사용했습니다.

기존 FormSection 컴포넌트

/**
 * 폼 섹션 컴포넌트
 * @param required 필수 입력 여부
 * @param title 섹션 제목
 */
function FormSection({ required, title, children }: FormSectionProps) {
  return (
    <section>
      <Label
        required={required}
        className="font-extrabold"
      >
        {title}
      </Label>
      {children}
    </section>
  );
}

문제점

  1. 모달이 아닌 강의 생성/입장 페이지에서는 섹션 간 간격이 다르고 HelpText가 필요한 등 요구사항이 달랐습니다.
  2. 경우마다 다른 디자인에 대응하기 위해서는, 사용처마다 컴포넌트를 선언하거나 props를 추가해야 했고(inModal 등), 컴포넌트가 비대해지는 문제가 있었습니다.
  3. 이에 따라, 이 컴포넌트가 무엇을 하는지 알기 힘들었습니다.
  4. 한 section에서만 스타일이 달라지거나, 라벨 옆에 HelpText를 붙여야 하는 변경이 생길 경우에도 유연하게 대응하기 어려운 구조였습니다.

해결 방안: 컴파운드 컴포넌트 패턴 적용


이를 개선하기 위해 FormField를 도입하고, Label, Input, HelpText, Error를 컴포지트 컴포넌트 패턴으로 분리했습니다.

새로운 FormField 컴포넌트

// Context를 통한 상태 공유
interface FormFieldContextValue {
  id: string;
  error?: string;
  required?: boolean;
}

const FormFieldContext = createContext<FormFieldContextValue | null>(null);

// Root 컴포넌트
function FormFieldRoot({ children, error, required, className }: FormFieldRootProps) {
  const id = useId();
  const classNames = cn('flex flex-col', className);

  return (
    <FormFieldContext.Provider value={{ id, error, required }}>
      <section className={classNames}>{children}</section>
    </FormFieldContext.Provider>
  );
}

// Label 컴포넌트 (Context에서 id, required 자동 연결)
function FormFieldLabel({ children, className, size }: FormFieldLabelProps) {
  const { id, required } = useFormFieldContext();

  return (
    <Label htmlFor={id} required={required} size={size} className={className}>
      {children}
    </Label>
  );
}

// Input 컴포넌트 (Context에서 id, error 자동 연결 + ARIA 속성)
function FormFieldInput(props: FormFieldInputProps) {
  const { id, error } = useFormFieldContext();

  return (
    <Input
      {...props}
      id={id}
      aria-invalid={error ? 'true' : 'false'}
      aria-describedby={error ? `${id}-error` : undefined}
    />
  );
}

// HelpText 컴포넌트
function FormFieldHelpText({ children, className }: FormFieldHelpTextProps) {
  const { id } = useFormFieldContext();

  return (
    <HelpText id={`${id}-help`} className={className}>
      {children}
    </HelpText>
  );
}

// Error 컴포넌트 (role="alert" 자동 적용)
function FormFieldError({ children, className = '' }: FormFieldErrorProps) {
  const { id, error } = useFormFieldContext();

  if (!error && !children) return null;

  return (
    <p id={`${id}-error`} role="alert" className={`text-error text-sm ${className}`}>
      {children || error}
    </p>
  );
}

// Object.assign으로 컴포지트 패턴 구현
export const FormField = Object.assign(FormFieldRoot, {
  Label: FormFieldLabel,
  Input: FormFieldInput,
  HelpText: FormFieldHelpText,
  Error: FormFieldError,
});