# 마우스 드래그 스크롤

`useDragScroll`은 실제 네이티브 스크롤 요소의 선택한 축(`scrollLeft` 또는 `scrollTop`)을 조작합니다. 트랙패드·터치·펜 스크롤과 네이티브 관성을 유지하며 마우스에는 드래그 이동을 추가합니다. 관성은 기본 활성화되며, 스냅 없는 영역에서도 macOS WebKit 관성 계산기의 측정 곡선으로 감속합니다. 단일 축 mandatory 스냅 영역에서는 놓는 순간 목표를 계산하고 `ocGesture`의 스냅 전용 감속으로 정착합니다. 스냅·관성이 모두 없으면 즉시 정지합니다.

```tsx
'use client';

import { useDragScroll } from '@orioncactuscorp/ui/react/scroll';

export function HorizontalList() {
  const { ref, isDragging } = useDragScroll({ axis: 'x' });
  return (
    <div
      ref={ref}
      role='region'
      aria-label='업무 진행 단계'
      tabIndex={0}
      style={{ overflowX: 'auto', cursor: isDragging ? 'grabbing' : 'grab' }}
    >
      <div style={{ display: 'flex', width: 'max-content' }}>
        {/* 목록 콘텐츠 */}
      </div>
    </div>
  );
}
```

`ScrollArea`와 함께 쓰면 ref를 바깥 `ScrollArea`가 아닌 실제 `ScrollAreaContainer`에 연결합니다. 여러 ref가 필요하면 React ref를 합성합니다. 요소 교체·비활성화·언마운트 시 리스너와 진행 중 드래그를 해제합니다.

| 옵션         | 기본값  | 의미                                                                                                                  |
| ------------ | ------- | --------------------------------------------------------------------------------------------------------------------- |
| `axis`       | `'x'`   | 마우스 드래그가 조작할 축. `'x'`는 `scrollLeft`, `'y'`는 `scrollTop`을 사용                                           |
| `enabled`    | `true`  | 마우스 드래그 활성화                                                                                                  |
| `inertia`    | `true`  | 스냅 없는 마우스 release 관성 활성화. 스냅은 이 옵션과 무관하게 속도 반영                                             |
| `threshold`  | `6`     | 선택한 축의 드래그 시작 이동 거리(CSS px), 최소 1                                                                     |
| `deferFocus` | `false` | 포커스를 옮기는 시점. `false`는 누르는 순간, `true`는 드래그가 아닌 클릭으로 끝났을 때. 포커스 시 활성화하는 컨트롤용 |

반환값은 실제 스크롤 요소에 연결하는 callback `ref`, 선택적으로 직계 콘텐츠에 연결하는 `contentRef`, `isDragging`입니다. 훅은 DOM attribute를 삽입하지 않습니다. CSS 스냅이 설정되어 있으면 드래그·관성 중 `scroll-snap-type` 인라인 스타일을 잠시 `none`으로 바꾸고 종료 시 원래 값과 우선순위를 복원합니다. `axis: 'x'`에는 네이티브 `overflow-x: auto`, `axis: 'y'`에는 `overflow-y: auto`가 필요하며, x축 RTL은 해당 요소의 `direction`과 네이티브 `scrollLeft`를 따릅니다.

## 입력 정책

- 왼쪽 마우스 버튼만 처리합니다. 터치·펜, 보조 버튼, modifier 조합은 가로채지 않습니다.
- 임계값 아래에서는 일반 클릭을 유지하고, 드래그 후 이어지는 클릭만 억제합니다. 키보드 클릭은 유지합니다.
- 드래그 영역의 일반 텍스트 선택 대신 선택한 축의 스크롤을 우선합니다. Shift+드래그 또는 `data-oc-drag-scroll-ignore`를 지정한 하위 영역에서는 텍스트를 선택할 수 있습니다.
- 드래그가 될 수 있는 마우스 누름은 `deferFocus`와 무관하게 기본 동작을 취소합니다. 브라우저는 누름의 기본 동작으로 선택 드래그와 자동 스크롤을 준비하고, 누른 채 영역 밖으로 나가면 스스로 스크롤합니다. 이 자동 스크롤이 1:1 드래그 위에 겹치면 목록이 끝까지 달려갑니다. 위에서 제외한 대상(입력 필드, CSS `resize` 요소, `data-oc-drag-scroll-ignore`, 중첩 스크롤 영역, modifier 조합)은 기본 동작을 유지합니다.
- 기본 동작을 취소하면 브라우저가 포커스를 옮기지 않으므로 훅이 직접 옮깁니다. 누른 요소에서 조상으로 올라가며 포커스를 받는 첫 요소를 찾고, 없으면 네이티브 클릭처럼 기존 포커스를 해제합니다. 포커스 가능 여부는 브라우저가 판정하므로 `summary`, 비활성 컨트롤, `tabindex="-1"`도 네이티브 클릭과 같은 결과가 됩니다. Safari는 네이티브 클릭에서 버튼과 링크에 포커스를 주지 않지만, 훅은 브라우저를 구분하지 않고 그 요소에 포커스를 줍니다.
- 입력 필드·편집 영역·slider·명시적 HTML draggable·CSS `resize` 요소·중첩 네이티브 같은 축 스크롤 영역은 제외합니다. 드래그 핸들 등 다른 제스처 소유 영역에는 `data-oc-drag-scroll-ignore`를 지정합니다.
- 휠·트랙패드·터치는 변경하지 않습니다. 네이티브와 같은 관성·스냅·경계 동작을 주는 것은 네이티브 스크롤뿐이므로, 훅은 네이티브에 없는 마우스 드래그만 더합니다. 선택하지 않은 축의 휠과 페이지 스크롤도 네이티브 동작에 남겨둡니다. 커스텀 ScrollArea 스크롤바 위의 휠 정책은 ScrollArea가 소유합니다.
- 동일 요소에 다른 드래그/슬라이더 라이브러리를 동시에 연결하지 않습니다. 텍스트 선택이 중심인 문서·표에는 전체 영역보다 제한된 영역에 적용합니다.
- 버튼을 놓은 뒤의 포인터 캡처 해제는 `pointerup`보다 먼저 도착해도 정상 release로 이어집니다. 버튼을 누른 상태에서 캡처를 잃거나 `pointercancel`이 발생하면 드래그를 취소합니다.

## Tabs

`TabsList`는 넘치는 목록에서 드래그를 기본 지원합니다. 내부 스크롤 요소가 훅을 소유하므로 별도로 연결하지 않습니다. 마우스로 누르는 시점에는 탭을 선택하지 않고, 클릭이면 선택·포커스를 처리하고 드래그이면 기존 선택을 유지합니다. 방향키, Home/End, 수동 활성화는 기존 Tabs 계약을 따릅니다.

```tsx
<TabsList dragScroll={false}>{/* 기존 마우스 동작 유지 */}</TabsList>
```

스토리북의 `Utilities/Drag Scroll`에서 클릭 가능한 카드·RTL·넘침 없음·비활성화·중첩 영역을 확인할 수 있습니다. Tabs의 `Drag Scroll` 예제에서는 탭 선택과 키보드 통합을 확인합니다. 훅은 드래그를 보조 입력으로 추가하므로 의미 있는 레이블과 키보드 탐색 경로를 유지하세요.

## 관성과 CSS 스냅

```tsx
const { ref } = useDragScroll({ inertia: true });

<div ref={ref} style={{ overflowX: 'auto', scrollSnapType: 'x mandatory' }}>
  <div style={{ display: 'flex', width: 'max-content' }}>
    {items.map(item => (
      <article key={item.id} style={{ scrollSnapAlign: 'start' }}>
        {item.content}
      </article>
    ))}
  </div>
</div>;
```

세로 wheel picker는 `axis: 'y'`와 block 축 스냅을 사용합니다. 첫 항목과 마지막 항목도 중앙에 정착하려면 목록 양끝에 `(scrollport 높이 - 항목 높이) / 2`만큼 여백을 둡니다. `scroll-padding`은 정렬 기준만 바꾸고 스크롤 범위를 늘리지 않으므로 이 여백을 대신할 수 없습니다.

```tsx
const { ref } = useDragScroll({ axis: 'y', inertia: true });

<div
  ref={ref}
  style={{
    blockSize: '20rem',
    overflowY: 'auto',
    scrollSnapType: 'y mandatory',
  }}
>
  <div
    style={{
      display: 'flex',
      flexDirection: 'column',
      paddingBlock: 'calc((20rem - 4rem) / 2)',
    }}
  >
    {items.map(item => (
      <button
        key={item.id}
        style={{ blockSize: '4rem', scrollSnapAlign: 'center' }}
      >
        {item.label}
      </button>
    ))}
  </div>
</div>;
```

중앙 하이라이트는 scrollport 밖의 겹친 요소로 그리고 `pointer-events: none`을 지정합니다. `Utilities/Drag Scroll`의 Vertical Snap에서 구성을 확인할 수 있습니다.

행 텍스트가 선택되지 않게 하려면 스크롤 영역에 `user-select: none`을 지정해도 됩니다. 자동 스크롤 차단은 훅이 담당하므로 이 속성을 그 용도로 둘 필요는 없습니다.

### 값 확정과 끝없는 wheel

스크롤이 멈춘 행을 선택값으로 확정하는 wheel은 관성이 진행되는 동안 스크롤 위치를 건드리지 않아야 합니다. 프로그램 스크롤(`scrollTo`, smooth 정렬, 위치 되돌리기)은 Safari의 트랙패드 관성을 끊습니다.

- `scrollend`만으로 정착을 판단하지 않습니다. Safari는 손가락을 떼는 순간, 관성이 시작되기 전에 `scrollend`를 냅니다. macOS는 관성 구간에도 `wheel` 이벤트를 계속 보내므로, 마지막 `wheel` 이벤트 후 150ms가 지난 뒤에만 정착으로 봅니다. 타이머로 정착을 추정할 때도 같은 조건을 적용합니다.
- 마우스 드래그의 release 동안 훅은 인라인 `scroll-snap-type: none`을 유지하고 매 프레임 위치를 씁니다. 이 값이 `none`인 동안에도 정착으로 보지 않습니다.
- 포커스는 훅이 누르는 순간 옮깁니다. 드래그가 시작된 뒤 effect에서 스크롤 요소에 `focus()`를 호출하면 빠른 flick에서는 놓은 뒤에 실행되어 release를 중단시킬 수 있습니다.

목록을 여러 벌 복제해 끝없이 도는 wheel을 만들면, 위치를 가운데 벌로 되돌릴 수 있는 시점은 입력이 멎은 뒤와 마우스를 누르는 순간뿐입니다. 그 사이의 이동 거리는 복제 수로 감당합니다.

- 강한 트랙패드 flick 한 번이 15,000px 가까이 이동할 수 있습니다. 40px 행 24개짜리 목록이면 15벌이 넘습니다. 한쪽 여유가 그보다 몇 배 크도록 복제 수를 정합니다(예: 101벌).
- 되돌릴 때는 행에 정렬된 위치로 보내지 말고 복제 한 벌의 정수배만큼 옮깁니다. 정렬되지 않은 위치에서 실행돼도 보이지 않습니다. `pointerdown`의 capture 단계에서 되돌리면 훅이 위치를 추적하기 전에 끝납니다.
- 복제 벌은 값이 바뀌어도 다시 렌더링하지 않도록 메모이즈하고, 선택된 항목의 색은 목록의 값 속성으로 CSS에서 칠합니다. 접근성 트리에는 가운데 벌만 남깁니다.
- 복제 방식은 끝에 닿을 가능성을 낮출 뿐 없애지 못합니다. 접근성 검사 도구는 복제 행까지 모두 순회하므로 수천 행에서는 검사 시간이 크게 늘어납니다.

`Utilities/Drag Scroll/Wheel Diagnostics`에서 40px 간격 목록의 네이티브 입력, 마우스 드래그, 복제 방식 wheel을 나란히 비교할 수 있습니다. 기본값이 위 구성이며, control을 하나씩 끄면 해당 문제가 재현됩니다. 기록 시작·종료 후 JSON을 저장하면 입력 이벤트, 프레임별 위치, 스냅 해제 상태를 확인할 수 있습니다.

스냅 대상과 정렬(`start`, `center`, `end`)은 CSS로 지정합니다. `horizontal-tb` 쓰기 모드의 단일 축 mandatory(`x`/`inline` 또는 `y`/`block`)에서는 실제 요소 위치, 축별 `scroll-padding`, `scroll-margin`, 가변 크기와 x축 RTL을 반영합니다. 중첩 스크롤 컨테이너 내부의 스냅 대상은 제외하며, 뷰포트보다 넓은 스냅 요소 안에서는 자유롭게 위치를 유지할 수 있습니다.

큰 요소 사이의 빈 구간에서 정착할 때는 요소 가장자리가 아니라 지정한 CSS 정렬 위치를 사용합니다. 관성 도착점이 큰 요소의 내부에 있으면 자유 이동 위치를 유지하므로, `center`라도 모든 드래그가 반드시 중앙으로 돌아가지는 않습니다. `Oversized Snap`에서 두 동작을 비교할 수 있습니다.

`inertia` 값과 무관하게 놓는 속도로 `ocGesture.projectSnap`의 예상 도착점을 계산하고 가까운 목표를 선택합니다. `inertia`는 스냅 없는 자유 관성만 제어합니다. 자유 관성과 스냅은 같은 네이티브 기반 도착점 투사를 사용합니다. 관성 release에서는 놓은 위치 뒤쪽의 목표를 제외하고 예상 도착점에 가까운 앞쪽 목표를 선택합니다. 잠깐 멈춘 뒤 놓으면 가까운 목표로 정착합니다. 지나가는 `scroll-snap-stop: always` 대상에서는 먼저 멈춥니다.

놓는 속도는 마지막 실제 이동에서 최근 100ms 평균·40ms 속도와 마지막 이동 구간을 함께 확인해, 속도를 줄이며 놓을 때 앞선 빠른 움직임으로 다시 가속하지 않도록 합니다. 마지막 이동 뒤 속도는 100ms 동안 선형으로 줄어들며, 100ms 이상 멈추면 0입니다. 짧은 이동·놓기 간격만으로 관성이 갑자기 사라지지 않습니다. 3 CSS px 미만의 반대 방향 흔들림은 이전 속도 기록을 지우지 않으며, 반대 방향으로 누적해 3px 이상 되돌리면 새 방향으로 추적합니다. release의 첫 프레임은 놓은 프레임에서 이어집니다.

선택한 목적지까지 `ocGesture.createSnap`의 4차 감속 곡선으로 이동합니다. macOS WebKit이 사용하는 관성 계산기를 측정한 근사 모델로, 목표 거리와 놓는 속도에 따라 정착 시간을 계산합니다. 먼 목표에는 정착에 필요한 초기 속도를 적용할 수 있지만 이동 중에는 단조롭게 감속하며, 유한 시간에 목표와 속도 0에 도달합니다. `inertia: false`인 스냅에도 같은 속도 투사와 정착 모델을 사용합니다. 마지막 0.5 CSS px 이내에서는 목표로 정착해 소수점 감속 뒤 한 픽셀만 늦게 움직이는 구간을 제거합니다. 목표 좌표가 먼저 표시되도록 한 프레임을 분리한 뒤 CSS 스냅을 복원합니다. 자유 관성도 같은 곡선으로 자연 도착점에 정착하며, 공용 `ocGesture.decelerate`의 normal/fast 감속률은 변경하지 않습니다.

`proximity`, 두 축 스냅, 세로 쓰기 모드는 브라우저 정착 정책을 유지합니다. `axis`는 `'x'`와 `'y'`만 지원하며 양축 마우스 pan은 제공하지 않습니다. 터치와 트랙패드도 브라우저의 관성·스냅을 그대로 사용합니다. 이 유틸은 CSS 스냅 사양 전체나 특정 OS의 물리를 재구현하지 않습니다. 웹 API는 트랙패드의 OS 관성 속도를 주입하는 기능을 제공하지 않으므로, 실제 입력 장치의 네이티브 움직임과 완전히 같다는 보장은 없습니다.

새 포인터 입력, 휠, 키보드 입력, 관성 중 포커스 이동, 창 크기 변경, blur, 문서 가시성 변경은 진행 중 이동을 중단합니다. `contentRef`를 연결하지 않으면 경계에서 관성을 멈추며 별도 바운스를 추가하지 않습니다. 다시 마우스로 누르면 진행 중 감속을 멈추고 스냅 해제를 유지해, 새 드래그가 시작되기 전에 위치가 보정되지 않도록 합니다. reduced-motion에서는 release 모션을 유지합니다. 관성과 스냅 정착은 사용자가 방금 만든 드래그를 이어받는 `gesture` release라서 `preserve` 예외로 두며, 목표로 순간 이동하면 드래그의 결과가 아니라 튀는 것으로 읽힙니다. 일반 관성과 스냅 모델은 spring이나 overshoot를 쓰지 않습니다. reduced-motion 설정이 바뀌면 진행 중 제스처와 복귀를 중단합니다. `isDragging`은 누르고 끄는 동안만 true이며 관성 중에는 false입니다.

Tabs에서도 관성이 기본 활성화됩니다. `<TabsList dragScrollInertia={false}>`로 끌 수 있습니다. `dragScroll={false}`이면 관성도 실행하지 않습니다. `Utilities/Drag Scroll`의 Basic, No Inertia, Snap, Inertia Snap에서 네 가지 조합을 비교할 수 있습니다. Snap Geometry에서는 중앙 정렬·가변 폭·스냅 여백을 함께 확인할 수 있습니다. Native Comparison은 같은 카드 구성으로 네이티브 입력과 마우스 드래그를 비교합니다. 기록 시작·종료 후 JSON을 저장하면 입력 시각, 프레임별 위치, 스냅 활성 상태를 확인할 수 있습니다. 기록은 로컬 브라우저에만 보관됩니다.

## 경계 rubber band 옵트인

```tsx
const { ref, contentRef, isDragging } = useDragScroll({ axis: 'y' });

<div ref={ref} style={{ overflowY: 'auto', height: '20rem' }}>
  <div ref={contentRef}>{/* 목록 전체 */}</div>
</div>;
```

`contentRef`를 스크롤 요소의 직계 콘텐츠 하나에 연결하면 경계를 넘긴 마우스 이동에 저항을 적용합니다. boolean 옵션은 없습니다. 미연결이면 기존 동작을 유지하며 Tabs 내부 동작도 바뀌지 않습니다. 기존 인라인 `transform`이 있는 콘텐츠에서는 이를 덮어쓰지 않고 오버스크롤을 건너뜁니다. transform이 필요한 장식은 콘텐츠 안쪽에 배치하세요.

드래그 중에는 `rubberBand(넘긴 거리, clientHeight 또는 clientWidth, 0.075)`를 사용합니다. 되돌릴 때는 늘어남부터 해소한 후 실제 스크롤을 움직입니다. 자연 도착점이 범위 밖이면 스냅 목록이 있어도 자유 감속으로 경계에 도달한 뒤 그 순간의 속도로 복귀합니다. 도착점이 범위 안 또는 정확히 경계이면 기존 스냅·정지 정책을 유지합니다.

늘어난 상태의 release 속도는 저항 전 거리(`rawStretch`)와 실제 스크롤 위치를 합친 좌표계에서 추적합니다. 복귀식의 시작 위치는 저항 후 늘어남이고, 입력 속도는 저항 전 속도입니다. 따라서 손을 놓기 전 화면상 콘텐츠 속도와 복귀식의 초기 도함수가 같다는 뜻은 아닙니다. 작은 입력 속도도 복귀식에는 그대로 전달하며, 복귀 종료 후 일반 release로 인계할 때 정지 임계값을 적용합니다.

복귀식은 macOS 27.0.0의 AppKit 함수와 종료 전 표본에서 1e-6px 이내로 일치합니다. 이 비교는 동일한 위치·속도 인자를 넣은 복귀식의 표본에 한정되며, 네이티브 입력 장치의 속도 추적 방식까지 같다는 보장은 아닙니다. 안쪽으로 움직이며 놓으면 복귀가 0.5px 정착 또는 경계 통과 시 끝나고 잔여 속도를 일반 관성·스냅에 인계합니다. 반대편으로 늘어나는 오버슈트는 없으며, 안쪽으로 이어지는 이동은 실제 스크롤입니다. 모든 플랫폼에 같은 모델을 쓰고 터치·트랙패드·휠 입력은 네이티브로 유지합니다.

중단 입력, resize, blur, 요소 교체, 비활성화, 언마운트에서 훅이 적용한 transform을 즉시 제거합니다. reduced-motion에서는 늘어남과 bounce를 사용하지 않고 경계에서 멈춥니다. 스냅 측정은 transform을 제외한 geometry로 수행합니다. 위쪽 끝의 양수 translate는 브라우저의 scrollHeight를 일시적으로 늘릴 수 있으므로 ScrollArea thumb 표시를 함께 확인하세요.

## 쓰지 말아야 할 곳

마우스 드래그는 네이티브 입력에 더하는 보조 입력입니다. 텍스트 선택이 중심인 문서·표, 데스크톱 전용 화면의 주 입력, 다른 드래그 제스처가 같은 영역을 소유하는 곳에는 적용하지 마세요. 키보드와 네이티브 스크롤 경로를 유지하세요.

## Migration Notes

기존 호출 변경은 없습니다. 경계 늘어남을 원하는 곳에서만 반환된 `contentRef`를 직계 콘텐츠에 연결하세요.

속도를 줄이며 놓으면 최근 실제 이동 구간의 속도를 반영합니다. 마지막 이동과 마우스 놓기 사이가 짧으면 이전보다 관성이 더 남을 수 있으며, 100ms 이상 멈춘 뒤 놓으면 정지합니다. 3px 미만의 반대 방향 흔들림은 기존 관성 방향을 뒤집지 않습니다.

`deferFocus: false`에서도 드래그가 될 수 있는 마우스 누름의 기본 동작을 취소하고, 누르는 순간 훅이 포커스를 옮깁니다. 포커스 대상은 네이티브 클릭과 같으며, Safari에서만 버튼·링크를 눌렀을 때의 포커스가 스크롤 영역(또는 문서)에서 그 요소로 바뀝니다. `mousedown`의 `defaultPrevented`에 의존하는 상위 핸들러가 있으면 확인하세요.

스냅 없는 기존 호출도 기본 관성으로 바뀝니다. 기존 호출은 `axis: 'x'`와 동일하게 동작합니다. 세로 목록에는 `axis: 'y'`를 지정하세요. 양축 마우스 pan과 세로 쓰기 모드는 지원하지 않으며, 즉시 정지가 필요하면 `inertia: false` 또는 `TabsList`의 `dragScrollInertia={false}`를 지정하세요. 단일 축 mandatory CSS 스냅은 마우스 release 시 부드러운 런타임 정착으로 바뀝니다. CSS 스냅이 있는 영역은 마우스 조작 중 스냅이 잠시 해제되고 release 이후 복원됩니다.
