[FE-02] 공용 UI 프리미티브

작업 내용 (설계 의도)

근거 설계: 20260722-공고알림앱-design-fe-web.md — “컴포넌트 트리”, “테마 토큰 정의”

변경 사항

모든 화면이 공유하는 도메인 무관 UI 프리미티브를 src/components/ui/**에 만듭니다. 화면 티켓 11건이 이 컴포넌트로 조립되므로 wave 2의 병목이지만, 도메인 지식이 0이라 도메인 로직(FE-03)·목 핸들러(FE-04)와 서로 의존하지 않아 같은 wave에서 병렬 진행됩니다.

기성 컴포넌트 라이브러리를 도입하지 않는 근거는 설계 “방안 5”에 있습니다 — 토스 시각 언어를 기준으로 하는데 라이브러리 기본 스타일을 덮어쓰는 비용이 직접 만드는 비용보다 크고, 필요한 프리미티브가 10여 개뿐입니다.

대상 컴포넌트:

컴포넌트요점
Buttonvariant: primary(accent 채움) / secondary(surface + border) / text. loading 시 스피너 + disabled. 화면당 primary 1개 원칙은 사용처가 지킵니다
TextFieldlabel · placeholder · errorMessage(인라인 에러) · description. 에러 시 aria-invalid + aria-describedby
Select네이티브 <select> 기반 — 접근성·키보드 조작을 공짜로 얻습니다
DateField네이티브 <input type="date"> 기반. null 허용(“선택 안 함”)
Chipfill: filled / outlined / none. tone: accent·positive·warning·danger·neutral. 확신도 위계(채움/테두리/없음)를 표현할 수 있는 형태 축이 핵심
BadgeChip보다 작은 상태 표시
Cardsurface + radius-card + 내부 패딩. as="button"이면 링크·버튼 role
BottomSheetoverlay + 하단 슬라이드. 포커스 트랩 · Esc 닫기 · 배경 스크롤 잠금 · role="dialog" + aria-modal
Segment2~3개 세그먼트 전환 (role="tablist")
Skeleton--skeleton 토큰 기반 플레이스홀더
EmptyState제목 · 설명 · 선택적 액션. 일러스트 없이 텍스트 중심
ErrorState제목 · 설명 · [다시 시도] 액션

규칙: 색은 시맨틱 토큰만 사용합니다(하드코딩 시 lint 에러). 모든 컴포넌트는 라이트·다크 두 모드에서 동작해야 완료입니다 — 토큰만 쓰면 자동으로 만족하지만, 테스트로 두 모드 렌더를 확인합니다.

의존

  • FE-01

다이어그램

처리 흐름

sequenceDiagram
    participant P as 화면 컴포넌트
    participant S as BottomSheet
    participant F as 포커스 트랩
    participant B as document.body
    P->>S: open=true
    S->>B: 스크롤 잠금
    S->>F: 첫 포커서블로 포커스 이동
    P->>S: Esc 또는 오버레이 클릭
    S->>P: onClose()
    S->>B: 스크롤 복원
    S->>F: 트리거 요소로 포커스 복귀

클래스 의존

flowchart LR
    subgraph Tokens["theme"]
        CSS[tokens.css 시맨틱 토큰]
    end
    subgraph Input["입력"]
        Button
        TextField
        Select
        DateField
    end
    subgraph Display["표시"]
        Chip
        Badge
        Card
        Skeleton
    end
    subgraph Layer["레이어·상태"]
        BottomSheet
        Segment
        EmptyState
        ErrorState
    end
    Input --> CSS
    Display --> CSS
    Layer --> CSS

테스트 케이스

  • Buttonloading이 true면 스피너가 보이고 클릭 핸들러가 호출되지 않는다
  • Button variant primary·secondary가 서로 다른 클래스로 렌더된다
  • TextFielderrorMessage를 주면 에러 문구가 보이고 aria-invalid가 true가 된다
  • TextField의 label 클릭으로 입력에 포커스가 이동한다
  • Chip이 fill filled/outlined/none에 따라 다른 형태로 렌더된다 (확신도 위계의 기반)
  • BottomSheet가 열리면 role="dialog"가 노출되고 첫 포커서블 요소로 포커스가 이동한다
  • BottomSheet에서 Esc를 누르면 onClose가 호출된다
  • BottomSheet가 열린 동안 배경 스크롤이 잠기고 닫히면 복원된다
  • BottomSheet가 닫히면 포커스가 트리거 요소로 되돌아온다
  • Segment에서 다른 항목을 클릭하면 onChange가 선택 값과 함께 호출된다
  • Segment가 좌우 방향키로 이동 가능하다
  • EmptyState가 제목·설명·액션 버튼을 모두 렌더하고 액션 클릭이 콜백을 호출한다
  • ErrorState[다시 시도] 클릭이 onRetry를 호출한다
  • 전 컴포넌트가 .dark 클래스가 적용된 트리에서도 오류 없이 렌더된다
  • Select가 옵션 0개일 때도 크래시 없이 렌더된다