@uniai-fe/uds-primitives 0.3.4 → 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 +111 -0
- package/package.json +1 -1
- package/src/components/carousel/hooks/index.ts +2 -0
- package/src/components/carousel/hooks/useContext.ts +565 -0
- package/src/components/carousel/img/chevron-left.svg +3 -0
- package/src/components/carousel/img/chevron-right.svg +3 -0
- package/src/components/carousel/index.scss +1 -0
- package/src/components/carousel/index.tsx +6 -0
- package/src/components/carousel/markup/Container.tsx +54 -0
- package/src/components/carousel/markup/Control.tsx +53 -0
- package/src/components/carousel/markup/Provider.tsx +77 -0
- package/src/components/carousel/markup/Track.tsx +84 -0
- package/src/components/carousel/markup/button/Base.tsx +87 -0
- package/src/components/carousel/markup/button/Next.tsx +51 -0
- package/src/components/carousel/markup/button/Prev.tsx +53 -0
- package/src/components/carousel/markup/index.tsx +19 -0
- package/src/components/carousel/styles/carousel.scss +105 -0
- package/src/components/carousel/styles/index.scss +2 -0
- package/src/components/carousel/styles/variables.scss +16 -0
- package/src/components/carousel/types/index.ts +1 -0
- package/src/components/carousel/types/props.ts +372 -0
- package/src/components/carousel/utils/index.ts +1 -0
- package/src/components/carousel/utils/math.ts +62 -0
- package/src/index.scss +1 -0
- package/src/index.tsx +1 -0
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
|
@@ -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
|
+
};
|