# Carousel 현재 HM 구현의 기준 문서. 공통 원칙은 `docs/guidelines/프로젝트-지침.md`(§6, §7), Token 구조 원칙은 `테마-토큰-가이드.md`를 따른다. 이 문서는 Carousel 고유의 API·Token·상태·결정만 기록한다. ## 1. 상태와 성격 | 항목 | 내용 | |---|---| | 성격 | **Interactive layout component** (slide 탐색). 단순 가로 scroll이 아니다 | | 구현 상태 | 구현 완료 (현재 HM 규격) | | 기준일 | 2026-10-03 | | 기반 | shadcn/ui Carousel = **Embla** (`embla-carousel-react` 8.6.0) | | 대표 Story | `Components/Data & Display/Carousel` → `All` (이름 "Carousel") | | 보류 | 아래 §12 | ## 2. 적용 근거 - **참고 근거**: 외부 ui_viz Reference (ZIP 미포함)의 기존 테스트 규격에는 Carousel이 없다. 참고할 수치가 없었다. - **현재 HM 규격**: 아래 §3~§4. 현재 HM Semantic/Primitive로 새로 정했다. Button/Scroll Area Token은 참조하지 않는다. - **구현 방식**: Embla가 drag/swipe, snap, loop, align, plugin, scroll 계산을 소유한다. Carousel은 이전/다음 control, 방향키, carousel 접근성 속성을 더하고 Embla API를 `setApi`로 노출한다. 데이터 fetching이나 paging은 소유하지 않는다. - **보류**: §12. ## 3. 기본 규격 (현재 HM 규격) | 항목 | 값 | |---|---| | Slide 폭 | 기본 `basis-full`(한 개). 여러 개는 소비자가 `CarouselItem`에 `basis-1/2` 등을 준다. 카드 폭을 컴포넌트가 강제하지 않는다 | | Gap | 16px (Track이 `-16px`만큼 당겨지고 각 Slide가 16px padding, 가로는 left / 세로는 top) | | Control | 32 × 32px 원형 버튼, 흰 면 + 1px `--hm-sem-stroke-input-default` + `--hm-sem-fg-primary` 화살표 16px | | Control 위치 | **carousel 가장자리 안쪽 8px**(가로: 좌우 가운데, 세로: 위/아래 가운데이며 화살표를 90° 회전). shadcn은 바깥(-48px)이라 좁은 부모에서 잘리는 문제가 있어 안쪽으로 정했다 | | Content 커서 | `grab` / 누르는 동안 `grabbing` | ## 4. 상태 규격 | 상태 | 값 | |---|---| | Initial | Previous disabled(loop가 아니면), Next enabled | | Disabled (경계) | opacity 0.6(이 컴포넌트 값, 전역 Disabled 규칙으로 승격 안 함), cursor not-allowed. loop일 때는 비활성되지 않는다 | | Hover | Control 배경 `--accent` | | Focus (`:focus-visible`) | Control에 ring 모양 `0 0 0 3px ring 50%` | | Dragging | Embla pointer drag, 커서 `grabbing` | | Active slide | 컴포넌트 속성은 없다. `setApi`로 `selectedScrollSnap()`을 읽어 소비자가 표시한다(Story의 `slide n / total`) | | Vertical | `orientation="vertical"`, Control이 위/아래로 이동하고 화살표가 회전 | ## 5. API shadcn 구조 그대로. export: `Carousel`, `CarouselContent`, `CarouselItem`, `CarouselPrevious`, `CarouselNext`, `useCarousel`, type `CarouselApi`. - `Carousel`: `orientation` `"horizontal" | "vertical"`, `opts`(Embla options: `loop`, `align`, `dragFree`, `startIndex`…), `plugins`, `setApi`. 출력 `data-orientation`. - `CarouselContent`: Embla viewport(`overflow-hidden`)와 track을 렌더한다(`data-slot="carousel-content"`, 안쪽 `carousel-track`). `className`은 track에 적용된다. - `CarouselItem`: slide. `className`으로 `basis-*` 지정. - `CarouselPrevious`/`CarouselNext`: Carousel 전용 `button`(Button 컴포넌트가 아님). sr-only 텍스트 "Previous slide" / "Next slide". - `variant`, `size`, `cardType` 등은 만들지 않았다. - **접근성**: Root `role="region"` + `aria-roledescription="carousel"`(이름은 소비자의 `aria-label`), Slide `role="group"` + `aria-roledescription="slide"`, Control은 disabled일 때 native `disabled`, ArrowLeft/ArrowRight가 이전/다음(캡처 단계에서 처리). - **Root `min-w-0`**: grid/flex 부모의 `min-width:auto`가 Embla track의 intrinsic width로 커져 페이지가 밀리는 것을 막기 위해 Root에 `min-w-0`을 둔다. 한 단계 더 감싼 wrapper(grid item)에는 소비자가 `min-w-0`을 줘야 한다(Story가 그렇게 한다). ## 6. Dependency - `embla-carousel-react` ^8.6.0 (`packages/ui`에 추가, shadcn Carousel의 기반). 사용: Carousel. CENTER 패키지에는 추가하지 않았다. ## 7. Token Component Mapping 위치: `packages/ui/src/theme/carousel.css` (`--hm-carousel-*`). `gap`, `control-size`, `control-inset`, `control-icon-size`, `control-background`, `control-text`, `control-border-width|color`, `control-hover-background`, `disabled-opacity`. Semantic과 Primitive(`--hm-warm-neutral-pale`)만 참조한다. 신규 Primitive와 Semantic 없음. 모든 규칙이 `@layer components`에 있어 소비자의 utility(`className`: gap, control 위치와 크기)가 이긴다. ## 8. Embla에서 유지한 항목 pointer drag/swipe와 snap, loop·align·dragFree·skipSnaps 등 options, plugins, `canScrollPrev/Next`, `reInit`/`select` 이벤트, `scrollPrev/Next` 구현. ## 9. Storybook - 대표 Story `All`("Carousel"): Basic(한 개 + `setApi`로 `slide n / 5` 표시), Multiple items(`basis-1/2`), Loop(3개), Vertical(세로), Media(AspectRatio 조합)를 한 페이지에 둔다. Card와 AspectRatio는 Story 조합이며 Carousel은 둘을 import하지 않는다. - verification: `Basic`, `Multiple Items`, `Loop`, `Vertical`. ## 10. 소비자 확장성 - Slide 폭: `className="basis-1/2"`이 그대로 적용된다(확인). - Gap/Control 위치·크기: CSS가 `@layer components`라 `className`의 utility가 이긴다(예: `CarouselPrevious className="-left-12"`로 바깥 배치 가능). - Track 높이(세로): `CarouselContent style/className`으로 높이를 준다. ## 11. 사용자 확정 결정 - 2026-10-03: ui-viz는 참고 자료이며 최종 기준은 현재 HM 구현과 이 문서다(작업 지시). Control은 Button 조합이 아니라 Carousel 전용 control로 둔다(Button Token과 분리). ## 12. 미확정 / 보류 - 수치(32px control, 8px inset, 16px gap)는 현재 HM 규격이며 디자인 확정 시 재검토. - Control이 slide 위에 겹치므로 카드 안쪽 콘텐츠를 가릴 수 있다(특히 세로 carousel의 첫/끝 항목). 바깥 배치는 `className`으로 선택한다. - 인디케이터(dot), autoplay, 썸네일 동기화는 만들지 않았다(Embla plugin과 `setApi`로 소비자가 조합). - 반응형(breakpoint별 `basis`)은 소비자 `className`(`md:basis-1/3`)이다. 자동 responsive carousel은 만들지 않았다. - 포인터가 컨트롤 위일 때 drag 시작 동작, 스크린리더가 slide 변경을 알리는 live region은 정의하지 않았다. ## 13. 관련 파일 ``` packages/ui/src/components/ui/carousel.tsx packages/ui/src/theme/carousel.css packages/ui/package.json (dependency: embla-carousel-react) packages/ui/src/theme.css (import) packages/ui/src/index.ts (export) src/components/carousel.stories.tsx ``` ## 14. 검증 상태 - `npm run typecheck`, `npm run build`: 통과(2026-10-03). - 실제 DOM(Browser pane): Root `role=region`, `aria-roledescription=carousel`, `aria-label`, Slide `role=group`/`slide`, 5개 slide, 초기 Previous `disabled`, Control 32px·radius 9999px·흰 면, `setApi` readout이 `slide 1 / 5`. - navigation: Next 클릭으로 `slide 2 / 5`와 Previous 활성화, Previous 클릭으로 `slide 1 / 5`와 Previous 비활성화. 버튼에 focus를 둔 채 ArrowRight/ArrowLeft로 3 → 2. 끝(5/5)에서 Next `disabled`. - pointer: 왼쪽 drag로 `slide 2 → 3`, 오른쪽 drag로 `3 → 2`. - loop: 첫 slide에서 Previous가 활성이고 클릭하면 `3 / 3`, Next로 `1 / 3`, 계속 눌러도 Next가 비활성되지 않음. - vertical: `data-orientation=vertical`, Previous `top: 8px`·`rotate: 90deg`, Next `bottom: 8px`, Next 클릭 후 Previous 활성화, slide가 위아래로 쌓임. - 레이아웃: Slide 폭이 Root 폭(`basis-full`), `basis-1/2`이면 절반(390px에서 187px), gap 16px(Track `-16px`, Item padding 16px). - Responsive(390×844): 모든 carousel이 358px 안(좌 16px)에 들어가고 Control이 안쪽에 있어 잘리지 않으며 페이지 가로 overflow 없음. (처음에는 grid wrapper의 `min-width:auto` 때문에 페이지가 436px로 밀려 Root에 `min-w-0`과 Story wrapper의 `minWidth:0`을 추가해 해결했다.) - 참고: 이 검증 환경은 페이지가 화면에 그려질 때만 animation frame이 진행되어, scroll 위치(translate)는 screenshot으로 frame을 만든 뒤에야 최종값에 도달했다. 위 검증은 Embla의 `selectedScrollSnap`(readout)과 버튼 상태 기준이다. - 미확인: 터치 기기에서의 swipe(마우스 drag로 대체), `dragFree`·plugin(autoplay), 스크린리더 실제 읽기, 소비자의 `md:basis-*` breakpoint 동작.