# PageIndicator

`PageIndicator`는 캐러셀의 현재 페이지를 점으로 표시합니다. 페이지가 많으면 가운데 3개를 원래 크기로 두고 양옆 2개씩을 점점 작게 그려 이어지는 페이지가 있음을 알립니다. 현재 페이지가 가운데 3개를 벗어날 때만 점 묶음이 필요한 만큼 이동합니다.

```tsx
'use client';

import { PageIndicator } from '@orioncactuscorp/ui';
import { mergeRefs } from '@orioncactuscorp/ui/react/refs';
import {
  useDragScroll,
  useScrollSnapIndex,
} from '@orioncactuscorp/ui/react/scroll';

export function PhotoCarousel({ photos }: { photos: Photo[] }) {
  const { ref: snapRef, index } = useScrollSnapIndex();
  const { ref: dragRef } = useDragScroll();

  return (
    <div>
      <div
        ref={mergeRefs(snapRef, dragRef)}
        role='region'
        aria-roledescription='carousel'
        aria-label='게시물 사진'
        tabIndex={0}
        style={{
          display: 'flex',
          overflowX: 'auto',
          scrollSnapType: 'x mandatory',
        }}
      >
        {photos.map((photo, page) => (
          <figure
            key={photo.id}
            role='group'
            aria-roledescription='slide'
            aria-label={`${page + 1} / ${photos.length}`}
            style={{
              flex: 'none',
              inlineSize: '100%',
              scrollSnapAlign: 'center',
            }}
          >
            <img src={photo.src} alt={photo.alt} />
          </figure>
        ))}
      </div>
      {photos.length > 1 && (
        <PageIndicator count={photos.length} index={index} />
      )}
    </div>
  );
}
```

## Props

| prop               | 의미                                                             |
| ------------------ | ---------------------------------------------------------------- |
| `count`            | 전체 페이지 수                                                   |
| `index`            | 현재 페이지(0부터). 범위를 벗어나면 가까운 끝으로 맞춤           |
| `onIndexChange`    | 지정하면 조작 모드. `(index, { reason })`으로 페이지 이동을 요청 |
| `getAriaValueText` | 조작 모드에서 읽어 줄 값 문구. 예: "3 / 9"                       |

나머지 `div` 속성은 루트로 전달됩니다.

## 표시 규칙

- 5페이지 이하: 모든 점을 원래 크기로 표시하고 묶음이 이동하지 않습니다.
- 6페이지 이상: 7칸을 표시합니다. 가운데 3칸은 원래 크기, 바깥으로 0.67배, 0.33배입니다. 처음과 끝 페이지에서는 바깥쪽 칸이 비어 있어 점 묶음이 한쪽으로 치우쳐 보입니다.
- 묶음은 현재 페이지가 가운데 3칸을 벗어날 때만 이동합니다. 같은 페이지라도 어느 방향에서 왔는지에 따라 묶음 위치가 다를 수 있습니다.
- 묶음 위치는 한 값으로 미끄러지고, 점 크기는 매 프레임 그 위치에서의 거리로 정해집니다. 이동 중에 다음 페이지가 오면 현재 위치에서 이어서 움직이며, 여러 칸을 건너뛰면 점들이 창을 지나가며 커지고 작아집니다. 창 밖의 점은 크기가 0이라 보이지 않습니다.
- 묶음 이동과 점 크기 변화는 `move` 모션을 사용하며, reduced-motion에서는 즉시 바뀝니다. 등록된 CSS 변수(`@property`)로 움직이므로 이를 지원하지 않는 브라우저(Safari 16.4·Firefox 128 미만)에서는 애니메이션 없이 바로 바뀝니다. 활성 점 색은 짧은 `feedback` 모션으로 바뀌며 reduced-motion에서도 유지됩니다.
- 페이지가 하나뿐이면 점을 숨기는 것을 권장합니다. 컴포넌트는 `count`만큼 점을 그립니다.

## 색

| 변수                               | 기본값                          | 용도                                                        |
| ---------------------------------- | ------------------------------- | ----------------------------------------------------------- |
| `--oc-page-indicator-color`        | `--oc-color-theme-label-normal` | 기준색. 비활성 점은 이 색의 16%, 작은 점의 `+`는 61%로 그림 |
| `--oc-page-indicator-active-color` | `--oc-page-indicator-color` 값  | 활성 점 색. 브랜드 강조색 등으로 지정 가능                  |

비활성 점의 비율은 고정입니다. 기준색은 단색 배경 위에서 색을 맞추는 용도로 쓰세요. 기준색을 흰색으로 바꿔 사진 위에 올리면 비활성 점이 잘 보이지 않습니다.

## 접근성

기본적으로 장식 요소(`aria-hidden="true"`)입니다. 현재 위치는 [APG 캐러셀 패턴](https://www.w3.org/WAI/ARIA/apg/patterns/carousel/)처럼 각 슬라이드의 레이블("3 / 9")로 전달하세요. 슬라이드 레이블을 둘 수 없는 구성에서는 `aria-label` 또는 `aria-labelledby`를 지정하면 `role="img"`로 렌더링됩니다. 마우스 사용자를 위해 이전·다음 버튼을 함께 두는 것을 권장합니다.

## 조작 모드

`onIndexChange`를 지정하면 점으로 페이지를 이동할 수 있습니다. 컴포넌트는 이동을 요청만 하고, 실제 페이지는 캐러셀이 이동한 결과를 `index`로 다시 받습니다. 지정하지 않으면 위의 표시 전용 동작을 그대로 유지합니다.

```tsx
const { ref, index, scrollToIndex } = useScrollSnapIndex();

<PageIndicator
  count={photos.length}
  index={index}
  aria-label='사진 위치'
  getAriaValueText={(page, total) => `${page + 1} / ${total}`}
  onIndexChange={(page, { reason }) =>
    scrollToIndex(page, {
      behavior: reason === 'scrub' ? 'instant' : 'smooth',
    })
  }
/>;
```

| 입력                              | 동작                                                                                                                                                                                                                 | `reason`         |
| --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- |
| 마우스 hover                      | 점 배치는 그대로. hover한 점은 원래 크기로 커짐. 인디케이터 위에서는 끝의 가장 작은 점이 원래 크기로 커지며 옅은 `+` 아이콘 표시, 그 점을 hover하면 점 간격만큼 커지며 `+`가 선명해짐(모든 페이지를 펼칠 수 있을 때) | —                |
| 마우스 클릭                       | 가장 가까운 보이는 점의 페이지로 이동. 끝의 가장 작은 점이면 이동 대신 모든 페이지를 펼침                                                                                                                            | `press`          |
| 펼친 점 마우스 클릭·끌기          | 포인터 아래 점의 페이지로 이동. 고른 뒤 1초가 지나거나 포인터가 벗어나고 1초가 지나면 접힘                                                                                                                           | `press`, `scrub` |
| 접힌 점 마우스 끌기·길게 누르기   | 4px 넘게 움직이거나 길게 누르면 캡슐이 나타나고 누른 점부터 점 간격의 절반마다 한 페이지 이동. 펼치지 않음. 길게 누른 뒤 놓으면 클릭하지 않음                                                                        | `scrub`          |
| 키보드 포커스                     | 모든 페이지를 원래 크기로 펼침                                                                                                                                                                                       | —                |
| 터치 길게 누른 뒤 좌우 이동       | 길게 누르면 캡슐이 나타나고, 마우스처럼 점 간격의 절반만큼 움직일 때마다 한 페이지 이동. 손가락을 멈추면 멈춤                                                                                                        | `scrub`          |
| 터치 짧게 탭                      | 모든 페이지를 원래 크기로 펼침. 펼친 점을 탭하면 그 페이지로, 좌우로 끌면 손가락 아래 페이지로 이동. 고른 뒤 1초가 지나거나 다른 곳을 터치하면 접힘                                                                  | `press`, `scrub` |
| 방향키, Home/End, PageUp/PageDown | 한 페이지, 처음·끝, 3페이지씩 이동. RTL에서는 좌우 방향키가 반대                                                                                                                                                     | `keyboard`       |

- 역할은 `role="slider"`이며 Tab 정지점은 하나입니다. `aria-label` 또는 `aria-labelledby`가 필요합니다. 값은 `aria-valuenow`(1부터)와 `getAriaValueText`로 전달됩니다.
- 마우스는 끝의 가장 작은 점 클릭으로, 터치는 탭으로 펼칩니다. hover는 점을 움직이지 않으므로 보이는 점은 커서를 올린 그대로 클릭할 수 있습니다. 숨은 점은 작은 점 바깥에 간격 없이 접혀 있다가 펼칠 때 간격과 함께 벌어집니다. 펼친 폭이 인디케이터가 놓인 영역보다 넓으면 펼치지 않습니다. 이때 작은 점 클릭은 그 페이지로 이동하고, 마우스 끌기와 길게 누르기 넘기기로 모든 페이지에 닿습니다. 인디케이터는 가로 이동을 직접 받으므로(`touch-action: pan-y`) 길게 누르기 전의 가로 스와이프는 아무 동작도 하지 않고, 세로 페이지 스크롤은 브라우저 동작을 그대로 따릅니다. 누르는 동안 손가락이 좌우로 조금 흔들려도 길게 누르기는 유지되고, 세로로 움직이면 페이지 스크롤로 넘깁니다. 길게 누르기가 시작되면 손을 뗄 때까지 페이지 스크롤을 막습니다.
- 넘기기는 페이지마다 한 번씩 호출되므로 `reason`이 `scrub`이면 캐러셀을 애니메이션 없이 이동하세요.
- 펼친 점의 클릭 영역은 접근성 최소 크기(24px)보다 작습니다. 같은 기능을 하는 이전·다음 버튼을 캐러셀에 함께 두세요.
- 넘기는 동안 묶음은 포인터를 바로 따라오도록 짧은 `gesture` 모션으로 움직입니다.
- 터치·마우스 길게 누르기는 `oc-motion`의 `hold` 모션을 사용합니다. 판정 시간은 작은 컨트롤용 quick pace로 `--oc-motion-duration-hold` × `--oc-motion-pace-quick`(기본 약 0.49초)이며, 이 token을 바꾸면 모션과 판정이 함께 바뀝니다.
  - 누르는 동안 인디케이터 전체가 조금씩 부풀고(`hold charge`), 판정 순간 짧은 스프링으로 돌아오면서 캡슐이 살짝 튀며 자리 잡습니다(`hold enter`). 판정 전에 손을 떼면 그 자리에서 되돌아갑니다.
  - 캡슐은 `Background` 표면입니다. 점 줄 크기에서 시작해 판정까지 조금 커지고 옅게 차오르며, 판정 순간 완전히 나타납니다. 마우스로 끌기 시작하면 판정을 기다리지 않고 바로 나타납니다.
  - 터치 탭, 작은 점 클릭, 키보드 포커스는 예열 없이 바로 펼칩니다.
- hover한 점의 확대와 작은 점의 `+` 표시는 `feedback` 모션을 사용하며 reduced-motion에서는 즉시 바뀝니다. `+`는 기준색의 61%(보조 라벨 톤)로 그려, 옅은 점 위에서 대비를 유지하면서 진한 활성 점과 구분됩니다.
- 펼치기·접기는 튐 없는 `move`, 캡슐 사라짐은 `fade` 모션을 사용합니다. reduced-motion에서는 예열을 판정 시간 그대로 두고 부풀기를 줄이며, 판정 순간의 튐 없이 자리 잡습니다. 펼치기·접기는 즉시 바뀝니다. 터치 진동(햅틱)은 웹에서 제공하지 않습니다.

## useScrollSnapIndex

```tsx
const { ref, index, scrollToIndex } = useScrollSnapIndex({ axis: 'x' });
```

`ref`를 네이티브 스크롤 요소에 연결하면 `scroll-snap-align`이 지정된 하위 요소를 DOM 순서대로 페이지로 봅니다. 다음 또는 이전 페이지가 페이징 축 기준으로 자기 크기의 절반을 넘게 보이면 `index`가 바뀝니다. 여러 페이지가 절반을 넘으면 더 많이 보이는 페이지를 고르고, 정확히 절반에서는 현재 페이지를 유지합니다. 스크롤 중 매 프레임 레이아웃을 읽지 않고 `IntersectionObserver`로 5% 단위로 판정합니다.

- 한 페이지가 스크롤 영역을 채우는 페이징 캐러셀을 기준으로 합니다.
- 페이지 추가·제거·순서 변경과 하위 요소의 `class`·`hidden` 변경을 반영합니다. 인라인 `style`이나 미디어 쿼리만으로 `scroll-snap-align`·`display`가 바뀌는 경우는 다시 수집하지 않습니다. 중첩 스크롤 영역 안의 스냅 요소는 제외합니다.
- 서버 렌더링과 첫 판정 전의 값은 `0`입니다. 첫 페이지가 아닌 위치에서 시작하면 마운트 직후 해당 페이지로 갱신됩니다.
- 터치·트랙패드 스크롤과 관성·스냅은 브라우저 동작을 그대로 사용합니다. 마우스 드래그가 필요하면 `useDragScroll`을 같은 요소에 함께 연결합니다.
- `axis`는 `'x'`(기본값)와 `'y'`를 지원합니다.
- `scrollToIndex(index, { behavior })`는 해당 페이지의 스냅 위치로 스크롤합니다. 기본 `behavior`는 `'smooth'`입니다. mandatory 스냅이 아니면 페이지 시작 가장자리에 맞춥니다.
  - `'smooth'`는 브라우저의 smooth scroll 대신 스크립트로 이동해 모든 브라우저에서 같은 움직임을 보입니다(Safari의 smooth scroll은 여러 페이지를 0.2초 안에 건너뜁니다). `Carousel` 버튼 이동과 같은 `move` 모션을 사용하며, 먼 페이지일수록 빠르게 출발해 감속하고 마지막 몇 픽셀도 멈칫하지 않고 이어서 스냅 위치에 정확히 멈춥니다. 1→15페이지 이동은 기본 token에서 약 1.3초입니다.
  - 이동하는 동안 스냅을 잠시 끄고, 도착한 뒤 원래 `scroll-snap-type`을 돌려줍니다. 이동 중 다시 호출하면 속도를 이어 목적지만 바꿉니다.
  - 사용자 입력이 들어오면 즉시 멈춥니다. 터치·펜으로 끌면 바로 브라우저 스냅에 맡기고, 탭·마우스 누르기·휠·스크롤 키는 스크롤이 멈춘 뒤 가장 가까운 페이지로 미끄러져 멈춥니다.
  - `'instant'`는 애니메이션 없이 바로 이동하고, `'auto'`는 스크롤 요소의 CSS `scroll-behavior`를 따르는 브라우저 동작입니다. reduced-motion에서는 `'smooth'`도 애니메이션 없이 이동합니다.
