117 lines
8.6 KiB
Markdown
117 lines
8.6 KiB
Markdown
# 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 동작.
|