@uniai-fe/uds-primitives 0.3.3 → 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/styles.css CHANGED
@@ -160,6 +160,19 @@
160
160
  --color-surface-strong,
161
161
  var(--color-cool-gray-20)
162
162
  );
163
+ --carousel-gap: var(--spacing-gap-5);
164
+ --carousel-inside-button-offset: var(--spacing-padding-2);
165
+ --carousel-control-button-size: var(--theme-size-medium-2);
166
+ --carousel-control-button-active: var(--color-cool-gray-20);
167
+ --carousel-control-button-disabled: var(--color-cool-gray-85);
168
+ --carousel-control-button-z-index: 50;
169
+ --carousel-control-button-padding-inline: var(--spacing-padding-5);
170
+ --carousel-control-button-gap: var(--spacing-gap-2);
171
+ --carousel-control-button-label-padding-prev: var(--spacing-padding-7);
172
+ --carousel-control-button-label-padding-next: var(--spacing-padding-7);
173
+ --carousel-button-label-color: var(--color-common-100);
174
+ --carousel-button-label-disabled-color: var(--color-label-neutral);
175
+ --carousel-button-label-font-size: var(--font-label-medium-size);
163
176
  --theme-checkbox-frame-size-medium: 20px;
164
177
  --theme-checkbox-frame-size-large: 24px;
165
178
  --theme-checkbox-indicator-size-medium: 16px;
@@ -1410,6 +1423,104 @@
1410
1423
 
1411
1424
 
1412
1425
 
1426
+ .carousel-container {
1427
+ width: 100%;
1428
+ }
1429
+
1430
+ .carousel-control {
1431
+ width: 100%;
1432
+ display: flex;
1433
+ align-items: center;
1434
+ gap: var(--carousel-gap);
1435
+ }
1436
+ .carousel-control[data-button-placement=inside] {
1437
+ position: relative;
1438
+ gap: 0;
1439
+ }
1440
+ .carousel-control[data-button-placement=inside] .carousel-move-button {
1441
+ position: absolute;
1442
+ top: 50%;
1443
+ transform: translateY(-50%);
1444
+ }
1445
+ .carousel-control[data-button-placement=inside] .carousel-prev-button {
1446
+ left: var(--carousel-inside-button-offset);
1447
+ }
1448
+ .carousel-control[data-button-placement=inside] .carousel-next-button {
1449
+ right: var(--carousel-inside-button-offset);
1450
+ }
1451
+
1452
+ .carousel-viewport {
1453
+ width: 100%;
1454
+ flex: 1 1 auto;
1455
+ min-width: 0;
1456
+ overflow-x: auto;
1457
+ overflow-y: hidden;
1458
+ scroll-snap-type: x proximity;
1459
+ scrollbar-width: none;
1460
+ -ms-overflow-style: none;
1461
+ }
1462
+ .carousel-viewport::-webkit-scrollbar {
1463
+ display: none;
1464
+ }
1465
+
1466
+ .carousel-track {
1467
+ display: flex;
1468
+ gap: var(--carousel-gap);
1469
+ margin: 0;
1470
+ padding: 0;
1471
+ list-style: none;
1472
+ scroll-behavior: smooth;
1473
+ }
1474
+ .carousel-track > * {
1475
+ flex: 0 0 auto;
1476
+ scroll-snap-align: start;
1477
+ }
1478
+
1479
+ .carousel-item[data-carousel-focus=true] {
1480
+ z-index: 1;
1481
+ }
1482
+
1483
+ .carousel-move-button {
1484
+ min-width: var(--carousel-control-button-size);
1485
+ height: var(--carousel-control-button-size);
1486
+ border-radius: calc(var(--carousel-control-button-size) / 2);
1487
+ padding: 0 var(--carousel-control-button-padding-inline);
1488
+ display: flex;
1489
+ align-items: center;
1490
+ justify-content: center;
1491
+ gap: var(--carousel-control-button-gap);
1492
+ cursor: pointer;
1493
+ background: var(--carousel-control-button-active);
1494
+ z-index: var(--carousel-control-button-z-index);
1495
+ }
1496
+ .carousel-move-button:disabled {
1497
+ cursor: default;
1498
+ background: var(--carousel-control-button-disabled);
1499
+ }
1500
+ .carousel-move-button:disabled .carousel-button-label {
1501
+ color: var(--carousel-button-label-disabled-color);
1502
+ }
1503
+ .carousel-move-button.label-padding-prev {
1504
+ padding-right: var(--carousel-control-button-label-padding-prev);
1505
+ }
1506
+ .carousel-move-button.label-padding-next {
1507
+ padding-left: var(--carousel-control-button-label-padding-next);
1508
+ }
1509
+
1510
+ .carousel-move-button-icon {
1511
+ display: flex;
1512
+ margin: 0;
1513
+ }
1514
+
1515
+ .carousel-button-label {
1516
+ font-size: var(--carousel-button-label-font-size);
1517
+ font-weight: 400;
1518
+ line-height: 1em;
1519
+ color: var(--carousel-button-label-color);
1520
+ }
1521
+
1522
+
1523
+
1413
1524
  .checkbox {
1414
1525
  display: inline-flex;
1415
1526
  align-items: center;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniai-fe/uds-primitives",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
4
4
  "description": "UNIAI Design System; Primitives Components Package",
5
5
  "type": "module",
6
6
  "private": false,
@@ -0,0 +1,2 @@
1
+ export { useCarousel } from "../markup/Provider";
2
+ export { useCarouselProviderController } from "./useContext";
@@ -0,0 +1,565 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
+
5
+ import type {
6
+ CarouselContextValue,
7
+ CarouselProviderControllerParams,
8
+ } from "../types";
9
+ import { normalizePositiveInteger, resolveIndexFromScrollLeft } from "../utils";
10
+
11
+ /**
12
+ * Hook 내부 처리 파이프라인
13
+ * 1) ref/state 준비
14
+ * 2) count/edge/index 계산 함수 준비
15
+ * 3) Prev/Next 이동 함수 준비
16
+ * 4) scroll 이벤트 동기화 effect 등록
17
+ * 5) 외부 콜백/정리 effect 등록
18
+ * 6) context value를 memo로 반환
19
+ *
20
+ * 아래 코드는 이 1~6 순서를 강제적으로 유지해서,
21
+ * 디버깅할 때 "어디를 먼저 봐야 하는지"를 눈에 바로 들어오게 만든다.
22
+ */
23
+
24
+ /**
25
+ * 자동 visibleCount 계산이 불가능할 때 사용할 최소 슬롯 수
26
+ * - 0 슬롯을 허용하면 maxIndex 계산과 이동 계산이 붕괴되므로 1을 유지한다.
27
+ */
28
+ const DEFAULT_VISIBLE_COUNT = 1;
29
+ /**
30
+ * 버튼 이동량 기본값
31
+ * - count가 없거나 비정상이어도 최소 1 page는 이동 가능하도록 보정한다.
32
+ */
33
+ const DEFAULT_BUTTON_MOVE_COUNT = 1;
34
+ /**
35
+ * smooth scroll 이후 programmatic 잠금 해제 대기 시간
36
+ * - 브라우저별 scroll 이벤트 지연/잔진동을 흡수하기 위한 완충 시간이다.
37
+ */
38
+ const SCROLL_SETTLE_MS = 240;
39
+
40
+ /**
41
+ * Carousel Hook; Provider 제어 로직 통합 Hook
42
+ * @hook
43
+ * @param {CarouselProviderControllerParams} params Provider 제어 파라미터
44
+ * @param {number} [params.buttonMoveCount] Prev/Next 기본 viewport page 이동 개수
45
+ * @param {number} [params.visibleCount] viewport 내 표시 item 개수 고정값
46
+ * @param {(index: number) => void} [params.onIndexChange] 현재 index 변경 콜백
47
+ * @returns {CarouselContextValue} Carousel context에 주입할 제어 상태/핸들러 묶음
48
+ * @example
49
+ * const contextValue = useCarouselProviderController({
50
+ * buttonMoveCount: 1,
51
+ * visibleCount: undefined,
52
+ * onIndexChange: index => console.log(index),
53
+ * });
54
+ */
55
+ export const useCarouselProviderController = ({
56
+ buttonMoveCount,
57
+ visibleCount: fixedVisibleCount,
58
+ onIndexChange,
59
+ }: CarouselProviderControllerParams): CarouselContextValue => {
60
+ /**
61
+ * ---------------------------------------------------------------------------
62
+ * [Section 1] DOM Ref 계층
63
+ * ---------------------------------------------------------------------------
64
+ * - viewportRef: scroll 좌표의 source of truth
65
+ * - trackRef: panel 좌표(offsetLeft) 계산 source
66
+ * - scrollSettleTimer: smooth scroll settle 타이밍 제어
67
+ * - isProgrammaticScroll: 사용자/코드 주도 스크롤 분기
68
+ * - scrollFrame: scroll 이벤트 RAF 압축 제어
69
+ * ---------------------------------------------------------------------------
70
+ */
71
+ /**
72
+ * viewport: 실제 스크롤 좌표를 읽고/쓰기 하는 기준 요소
73
+ */
74
+ const viewportRef = useRef<HTMLDivElement | null>(null);
75
+ /**
76
+ * track: panel(li) 집합. panel offsetLeft를 읽어 index를 계산한다.
77
+ */
78
+ const trackRef = useRef<HTMLUListElement | null>(null);
79
+ /**
80
+ * programmatic 이동 종료 타이머 id
81
+ */
82
+ const scrollSettleTimer = useRef<number | null>(null);
83
+ /**
84
+ * 코드 주도 스크롤 여부 플래그
85
+ * - true면 scroll 핸들러에서 사용자 스크롤 계산을 잠시 중단한다.
86
+ */
87
+ const isProgrammaticScroll = useRef(false);
88
+ /**
89
+ * scroll 이벤트 RAF 압축용 id
90
+ */
91
+ const scrollFrame = useRef<number | null>(null);
92
+
93
+ /**
94
+ * ---------------------------------------------------------------------------
95
+ * [Section 2] 상태 계층
96
+ * ---------------------------------------------------------------------------
97
+ * - itemCount: Track에서 보고한 panel 개수
98
+ * - visibleCount: 현재 viewport에 동시 표시 가능한 슬롯
99
+ * - currentIndex: scroll 좌표를 기반으로 계산된 현재 index
100
+ * - focusIndex: 사용자가 선택한 의도 상태 index
101
+ * - isScrollAtStart/End: 버튼 disabled 계산용 edge 상태
102
+ * ---------------------------------------------------------------------------
103
+ */
104
+ /**
105
+ * Track에서 등록된 panel 개수
106
+ */
107
+ const [itemCount, setItemCount] = useState(0);
108
+ /**
109
+ * viewport에 동시에 표시되는 panel 슬롯 수
110
+ */
111
+ const [visibleCount, setVisibleCount] = useState(
112
+ normalizePositiveInteger(fixedVisibleCount, DEFAULT_VISIBLE_COUNT),
113
+ );
114
+ /**
115
+ * viewport 좌측 edge 기준 현재 index
116
+ */
117
+ const [currentIndex, setCurrentIndex] = useState(0);
118
+ /**
119
+ * 사용자 선택(focus/selected) 상태 index
120
+ * - scroll index와 분리해서 마지막 아이템 선택 무결성을 유지한다.
121
+ */
122
+ const [focusIndex, setFocusIndexState] = useState(0);
123
+ /**
124
+ * scroll 시작 edge 도달 여부
125
+ */
126
+ const [isScrollAtStart, setIsScrollAtStart] = useState(true);
127
+ /**
128
+ * scroll 끝 edge 도달 여부
129
+ */
130
+ const [isScrollAtEnd, setIsScrollAtEnd] = useState(false);
131
+
132
+ /**
133
+ * ---------------------------------------------------------------------------
134
+ * [Section 3] 파생값 계층
135
+ * ---------------------------------------------------------------------------
136
+ * maxIndex:
137
+ * - "이론상 마지막 item index"가 아니라
138
+ * - "좌측 정렬 가능한 마지막 index"를 의미한다.
139
+ *
140
+ * defaultMoveCount:
141
+ * - 외부 입력(buttonMoveCount)을 안전한 양의 정수로 보정한 값
142
+ * - onPrev/onNext 기본 호출 경로에서 사용한다.
143
+ * ---------------------------------------------------------------------------
144
+ */
145
+ /**
146
+ * 이동 가능한 마지막 index
147
+ * - visibleCount를 반영한 scroll 가능 범위의 상한이다.
148
+ */
149
+ const maxIndex = useMemo(
150
+ () => Math.max(itemCount - visibleCount, 0),
151
+ [itemCount, visibleCount],
152
+ );
153
+
154
+ /**
155
+ * Provider에서 주입된 buttonMoveCount를 안전한 양의 정수로 보정한다.
156
+ */
157
+ const defaultMoveCount = useMemo(
158
+ () => normalizePositiveInteger(buttonMoveCount, DEFAULT_BUTTON_MOVE_COUNT),
159
+ [buttonMoveCount],
160
+ );
161
+
162
+ /**
163
+ * ---------------------------------------------------------------------------
164
+ * [Section 4] 등록/계산 함수 계층
165
+ * ---------------------------------------------------------------------------
166
+ * registerItemCount:
167
+ * - Track에서 계산한 child count를 Provider state로 전달한다.
168
+ * - item 수가 줄면 currentIndex를 즉시 clamp한다.
169
+ *
170
+ * updateVisibleCount:
171
+ * - fixedVisibleCount가 있으면 해당 값 우선
172
+ * - 없으면 viewport/panel 폭으로 슬롯 자동 계산
173
+ * - 계산 실패 시 DEFAULT_VISIBLE_COUNT로 안전 복귀
174
+ * ---------------------------------------------------------------------------
175
+ */
176
+ // Track 렌더 수를 context state로 동기화한다.
177
+ const registerItemCount = useCallback(
178
+ (count: number) => {
179
+ // count는 외부 입력이므로 항상 정수/0 이상으로 정규화한다.
180
+ const normalizedCount = Math.max(0, Math.floor(count));
181
+ setItemCount(normalizedCount);
182
+ // 현재 index가 새로운 범위를 벗어나면 즉시 범위 내로 축소한다.
183
+ setCurrentIndex(previousIndex =>
184
+ Math.min(previousIndex, Math.max(normalizedCount - visibleCount, 0)),
185
+ );
186
+ },
187
+ [visibleCount],
188
+ );
189
+
190
+ // visibleCount를 fixed 우선, 자동 계산 보조 순서로 갱신한다.
191
+ const updateVisibleCount = useCallback(() => {
192
+ // fixed 모드 여부와 무관하게 먼저 안전한 정수로 보정한다.
193
+ const normalizedFixedVisibleCount = normalizePositiveInteger(
194
+ fixedVisibleCount,
195
+ DEFAULT_VISIBLE_COUNT,
196
+ );
197
+
198
+ // [Case A] 외부 fixedVisibleCount가 있으면 자동 계산을 건너뛴다.
199
+ if (typeof fixedVisibleCount === "number") {
200
+ setVisibleCount(normalizedFixedVisibleCount);
201
+ setCurrentIndex(previousIndex =>
202
+ Math.min(
203
+ previousIndex,
204
+ Math.max(itemCount - normalizedFixedVisibleCount, 0),
205
+ ),
206
+ );
207
+ return;
208
+ }
209
+
210
+ // [Case B] 자동 계산에 필요한 ref/children이 준비되지 않았으면 기본값으로 복귀한다.
211
+ if (!viewportRef.current || !trackRef.current?.children.length) {
212
+ setVisibleCount(DEFAULT_VISIBLE_COUNT);
213
+ return;
214
+ }
215
+
216
+ const firstPanel = trackRef.current.children[0] as HTMLElement;
217
+ const secondPanel = trackRef.current.children[1] as HTMLElement | undefined;
218
+ const viewportWidth = viewportRef.current.clientWidth;
219
+ const panelWidth = secondPanel
220
+ ? secondPanel.offsetLeft - firstPanel.offsetLeft
221
+ : firstPanel.getBoundingClientRect().width;
222
+
223
+ // [Guard] panel width가 비정상일 경우 계산을 포기한다.
224
+ if (panelWidth <= 0) {
225
+ setVisibleCount(DEFAULT_VISIBLE_COUNT);
226
+ return;
227
+ }
228
+
229
+ // [Case C] 자동 계산 성공: floor(viewport/panel)로 표시 슬롯 수를 계산한다.
230
+ const slots = Math.max(Math.floor(viewportWidth / panelWidth), 1);
231
+ setVisibleCount(slots);
232
+ setCurrentIndex(previousIndex =>
233
+ Math.min(previousIndex, Math.max(itemCount - slots, 0)),
234
+ );
235
+ }, [fixedVisibleCount, itemCount]);
236
+
237
+ useEffect(() => {
238
+ // item 수/폭 조건 변화 시 visibleCount를 재계산한다.
239
+ updateVisibleCount();
240
+ }, [updateVisibleCount, itemCount]);
241
+
242
+ useEffect(() => {
243
+ /**
244
+ * ResizeObserver effect
245
+ * - viewport width가 바뀌면 visibleCount를 재계산한다.
246
+ * - SSR/legacy 보호: ResizeObserver 미지원 환경은 조용히 건너뛴다.
247
+ */
248
+ // viewport 리사이즈를 감시해 visibleCount를 동기화한다.
249
+ if (!viewportRef.current || typeof ResizeObserver === "undefined") {
250
+ return;
251
+ }
252
+
253
+ const observer = new ResizeObserver(() => {
254
+ updateVisibleCount();
255
+ });
256
+
257
+ observer.observe(viewportRef.current);
258
+ return () => observer.disconnect();
259
+ }, [updateVisibleCount]);
260
+
261
+ const clampIndex = useCallback(
262
+ (index: number) => Math.max(0, Math.min(index, maxIndex)),
263
+ [maxIndex],
264
+ );
265
+
266
+ /**
267
+ * clampFocusIndex가 clampIndex와 분리된 이유
268
+ * - clampIndex는 "scroll 가능한 좌측 anchor 범위(maxIndex)"를 따른다.
269
+ * - clampFocusIndex는 "선택 가능한 item 범위(itemCount-1)"를 따른다.
270
+ * - 즉, 같은 index라도 clamp 목적이 다르므로 함수를 분리한다.
271
+ */
272
+ // focus는 선택 상태이므로 itemCount-1까지 허용한다.
273
+ const clampFocusIndex = useCallback(
274
+ (index: number) => Math.max(0, Math.min(index, Math.max(itemCount - 1, 0))),
275
+ [itemCount],
276
+ );
277
+
278
+ // Prev/Next disabled 상태를 실제 scroll edge 기준으로 계산한다.
279
+ const syncScrollEdgeState = useCallback(() => {
280
+ // edge 계산은 viewport scroll 좌표를 단일 source로 삼는다.
281
+ const viewport = viewportRef.current;
282
+ if (!viewport) {
283
+ return;
284
+ }
285
+
286
+ const maxScrollLeft = Math.max(
287
+ viewport.scrollWidth - viewport.clientWidth,
288
+ 0,
289
+ );
290
+ // 1px 허용오차로 sub-pixel 오차를 흡수한다.
291
+ setIsScrollAtStart(viewport.scrollLeft <= 1);
292
+ setIsScrollAtEnd(viewport.scrollLeft >= maxScrollLeft - 1);
293
+ }, []);
294
+
295
+ const scrollToIndex = useCallback(
296
+ (index: number) => {
297
+ // moveTo(index) 호출 시 panel anchor로 정밀 이동한다.
298
+ if (!viewportRef.current || !trackRef.current) {
299
+ return;
300
+ }
301
+
302
+ const panel = trackRef.current.children[index] as HTMLElement | undefined;
303
+ if (!panel) {
304
+ return;
305
+ }
306
+
307
+ const targetLeft = panel.offsetLeft;
308
+ const currentLeft = viewportRef.current.scrollLeft;
309
+
310
+ // 목표 지점과 현재 지점이 거의 같으면 scroll 호출을 생략한다.
311
+ if (Math.abs(currentLeft - targetLeft) < 1) {
312
+ setCurrentIndex(index);
313
+ return;
314
+ }
315
+
316
+ // programmatic 이동 시작
317
+ isProgrammaticScroll.current = true;
318
+ viewportRef.current.scrollTo({
319
+ left: targetLeft,
320
+ behavior: "smooth",
321
+ });
322
+
323
+ setCurrentIndex(index);
324
+
325
+ if (scrollSettleTimer.current) {
326
+ window.clearTimeout(scrollSettleTimer.current);
327
+ }
328
+
329
+ scrollSettleTimer.current = window.setTimeout(() => {
330
+ // 이동 settle 이후 사용자 스크롤 계산 경로를 다시 연다.
331
+ isProgrammaticScroll.current = false;
332
+ syncScrollEdgeState();
333
+ }, SCROLL_SETTLE_MS);
334
+ },
335
+ [syncScrollEdgeState],
336
+ );
337
+
338
+ const moveTo = useCallback(
339
+ (index: number) => {
340
+ // focus는 의도 상태, current는 scroll 상태로 분리해서 갱신한다.
341
+ // 1) focusIndex 먼저 반영
342
+ // 2) scroll anchor index로 clamp
343
+ // 3) 실제 스크롤 실행
344
+ const nextIndex = clampIndex(index);
345
+ const nextFocusIndex = clampFocusIndex(index);
346
+ setFocusIndexState(nextFocusIndex);
347
+ if (nextIndex === currentIndex) {
348
+ return;
349
+ }
350
+ scrollToIndex(nextIndex);
351
+ },
352
+ [clampFocusIndex, clampIndex, currentIndex, scrollToIndex],
353
+ );
354
+
355
+ const moveByViewport = useCallback(
356
+ (count: number) => {
357
+ // Prev/Next는 item 단위가 아닌 viewport width(page) 단위 이동을 사용한다.
358
+ const viewport = viewportRef.current;
359
+ if (!viewport) {
360
+ return;
361
+ }
362
+
363
+ const normalizedCount = normalizePositiveInteger(
364
+ Math.abs(count),
365
+ DEFAULT_BUTTON_MOVE_COUNT,
366
+ );
367
+ const direction = count >= 0 ? 1 : -1;
368
+ // delta = viewport width * 이동 페이지 수 * 방향
369
+ const delta = viewport.clientWidth * normalizedCount * direction;
370
+ const maxScrollLeft = Math.max(
371
+ viewport.scrollWidth - viewport.clientWidth,
372
+ 0,
373
+ );
374
+ const targetScrollLeft = Math.min(
375
+ Math.max(viewport.scrollLeft + delta, 0),
376
+ maxScrollLeft,
377
+ );
378
+
379
+ // 실제 이동량이 0이면 좌표는 그대로, edge 상태만 최신화한다.
380
+ if (Math.abs(targetScrollLeft - viewport.scrollLeft) < 1) {
381
+ syncScrollEdgeState();
382
+ return;
383
+ }
384
+
385
+ isProgrammaticScroll.current = true;
386
+ viewport.scrollTo({
387
+ left: targetScrollLeft,
388
+ behavior: "smooth",
389
+ });
390
+
391
+ const panels = trackRef.current?.children;
392
+ if (panels && panels.length) {
393
+ // 목표 scrollLeft 기준 index를 선반영해 상태 표시 지연을 줄인다.
394
+ const resolvedIndex = resolveIndexFromScrollLeft({
395
+ panels,
396
+ scrollLeft: targetScrollLeft,
397
+ maxIndex,
398
+ });
399
+ setCurrentIndex(resolvedIndex);
400
+ }
401
+
402
+ if (scrollSettleTimer.current) {
403
+ window.clearTimeout(scrollSettleTimer.current);
404
+ }
405
+ scrollSettleTimer.current = window.setTimeout(() => {
406
+ // settle 타이머 종료 시 사용자 스크롤 계산 경로를 다시 연다.
407
+ isProgrammaticScroll.current = false;
408
+ syncScrollEdgeState();
409
+ }, SCROLL_SETTLE_MS);
410
+ },
411
+ [maxIndex, syncScrollEdgeState],
412
+ );
413
+
414
+ const onPrevBy = useCallback(
415
+ (count: number) => {
416
+ // count는 항상 양수 정수로 보정한 뒤 음수 방향으로 이동한다.
417
+ const normalizedCount = normalizePositiveInteger(count, 1);
418
+ moveByViewport(-normalizedCount);
419
+ },
420
+ [moveByViewport],
421
+ );
422
+
423
+ const onNextBy = useCallback(
424
+ (count: number) => {
425
+ // count는 항상 양수 정수로 보정한 뒤 양수 방향으로 이동한다.
426
+ const normalizedCount = normalizePositiveInteger(count, 1);
427
+ moveByViewport(normalizedCount);
428
+ },
429
+ [moveByViewport],
430
+ );
431
+
432
+ const onPrev = useCallback(() => {
433
+ // Provider 기본 이동량 기반 이전 이동
434
+ onPrevBy(defaultMoveCount);
435
+ }, [defaultMoveCount, onPrevBy]);
436
+
437
+ const onNext = useCallback(() => {
438
+ // Provider 기본 이동량 기반 다음 이동
439
+ onNextBy(defaultMoveCount);
440
+ }, [defaultMoveCount, onNextBy]);
441
+
442
+ useEffect(() => {
443
+ const viewport = viewportRef.current;
444
+ if (!viewport) {
445
+ return;
446
+ }
447
+
448
+ /**
449
+ * scroll 핸들러 설계 의도
450
+ * 1) edge 상태는 매 이벤트에서 즉시 반영
451
+ * 2) programmatic 이동 중이면 사용자 index 계산 중단
452
+ * 3) RAF로 최종 좌표를 읽어 currentIndex를 1회 업데이트
453
+ */
454
+ const handleScroll = () => {
455
+ // 매 scroll마다 edge를 우선 계산해 disabled 정합성을 유지한다.
456
+ syncScrollEdgeState();
457
+
458
+ if (isProgrammaticScroll.current || !trackRef.current) {
459
+ return;
460
+ }
461
+
462
+ if (scrollFrame.current) {
463
+ // 이전 프레임 예약이 있으면 취소하고 마지막 이벤트만 반영한다.
464
+ cancelAnimationFrame(scrollFrame.current);
465
+ }
466
+
467
+ scrollFrame.current = requestAnimationFrame(() => {
468
+ // RAF 타이밍의 최종 scrollLeft를 기준으로 index를 계산한다.
469
+ const { scrollLeft } = viewport;
470
+ const panels = trackRef.current?.children;
471
+
472
+ if (!panels || !panels.length) {
473
+ return;
474
+ }
475
+ const resolvedIndex = resolveIndexFromScrollLeft({
476
+ panels,
477
+ scrollLeft,
478
+ maxIndex,
479
+ });
480
+ setCurrentIndex(previousIndex =>
481
+ // 불필요 렌더를 피하기 위해 동일값 업데이트는 생략한다.
482
+ previousIndex === resolvedIndex ? previousIndex : resolvedIndex,
483
+ );
484
+ });
485
+ };
486
+
487
+ viewport.addEventListener("scroll", handleScroll, { passive: true });
488
+
489
+ return () => {
490
+ viewport.removeEventListener("scroll", handleScroll);
491
+ if (scrollFrame.current) {
492
+ cancelAnimationFrame(scrollFrame.current);
493
+ scrollFrame.current = null;
494
+ }
495
+ };
496
+ }, [maxIndex, syncScrollEdgeState]);
497
+
498
+ useEffect(() => {
499
+ // 외부 동기화 콜백(onIndexChange) 전달
500
+ if (typeof onIndexChange === "function") {
501
+ onIndexChange(currentIndex);
502
+ }
503
+ }, [currentIndex, onIndexChange]);
504
+
505
+ useEffect(() => {
506
+ // 아이템 수 감소 시 focusIndex 범위를 보정한다.
507
+ setFocusIndexState(previousFocusIndex =>
508
+ Math.min(previousFocusIndex, Math.max(itemCount - 1, 0)),
509
+ );
510
+ }, [itemCount]);
511
+
512
+ useEffect(() => {
513
+ // 상태 변화마다 edge를 재평가해 버튼 disabled 오차를 줄인다.
514
+ syncScrollEdgeState();
515
+ }, [currentIndex, itemCount, syncScrollEdgeState, visibleCount]);
516
+
517
+ useEffect(() => {
518
+ // 언마운트 시 타이머를 정리한다.
519
+ return () => {
520
+ if (scrollSettleTimer.current) {
521
+ window.clearTimeout(scrollSettleTimer.current);
522
+ }
523
+ };
524
+ }, []);
525
+
526
+ return useMemo<CarouselContextValue>(
527
+ () => ({
528
+ // Provider가 필요한 모든 상태/핸들러를 단일 객체로 제공한다.
529
+ viewportRef,
530
+ trackRef,
531
+ onPrev,
532
+ onNext,
533
+ onPrevBy,
534
+ onNextBy,
535
+ moveBy: moveByViewport,
536
+ moveTo,
537
+ registerItemCount,
538
+ itemCount,
539
+ visibleCount,
540
+ currentIndex,
541
+ focusIndex,
542
+ maxIndex,
543
+ isReachStart: isScrollAtStart,
544
+ isReachEnd: isScrollAtEnd,
545
+ }),
546
+ [
547
+ // 아래 dependency 배열은 "상태 묶음 객체의 재생성 기준"을 명확히 고정한다.
548
+ // - 핸들러 레퍼런스와 상태값이 바뀌는 경우에만 contextValue를 갱신한다.
549
+ currentIndex,
550
+ itemCount,
551
+ isScrollAtEnd,
552
+ isScrollAtStart,
553
+ maxIndex,
554
+ moveByViewport,
555
+ moveTo,
556
+ onNext,
557
+ onNextBy,
558
+ onPrev,
559
+ onPrevBy,
560
+ focusIndex,
561
+ registerItemCount,
562
+ visibleCount,
563
+ ],
564
+ );
565
+ };
@@ -0,0 +1,3 @@
1
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
2
+ <path d="M15 4L7 12L15 20" stroke="white" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
3
+ </svg>