@watcha-authentic/react-slider 0.1.1 → 0.1.2
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/README.md
CHANGED
|
@@ -38,12 +38,49 @@ function App() {
|
|
|
38
38
|
items={items}
|
|
39
39
|
onItemKey={(item) => item.id}
|
|
40
40
|
onCreateItemView={(item) => <div>{item.title}</div>}
|
|
41
|
-
onIndexChange={(newIndex) =>
|
|
41
|
+
onIndexChange={(newIndex, cause) =>
|
|
42
|
+
console.log("Index changed:", newIndex, cause)
|
|
43
|
+
}
|
|
42
44
|
/>
|
|
43
45
|
);
|
|
44
46
|
}
|
|
45
47
|
```
|
|
46
48
|
|
|
49
|
+
### Ref를 사용한 네비게이션
|
|
50
|
+
|
|
51
|
+
`ref`를 통해 `doNext()`와 `doPrev()` 메서드를 사용하여 슬라이더를 제어할 수 있습니다.
|
|
52
|
+
|
|
53
|
+
```tsx
|
|
54
|
+
import { Slider, type SliderRef } from "@watcha-authentic/react-slider";
|
|
55
|
+
import { useRef } from "react";
|
|
56
|
+
|
|
57
|
+
function App() {
|
|
58
|
+
const sliderRef = useRef<SliderRef>(null);
|
|
59
|
+
|
|
60
|
+
const handlePrev = () => {
|
|
61
|
+
sliderRef.current?.doPrev();
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const handleNext = () => {
|
|
65
|
+
sliderRef.current?.doNext();
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<>
|
|
70
|
+
<button onClick={handlePrev}>Previous</button>
|
|
71
|
+
<button onClick={handleNext}>Next</button>
|
|
72
|
+
<Slider
|
|
73
|
+
ref={sliderRef}
|
|
74
|
+
items={items}
|
|
75
|
+
onItemKey={(item) => item.id}
|
|
76
|
+
onCreateItemView={(item) => <div>{item.title}</div>}
|
|
77
|
+
defaultIndex={0}
|
|
78
|
+
/>
|
|
79
|
+
</>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
47
84
|
### 스타일 import
|
|
48
85
|
|
|
49
86
|
CSS 스타일을 import하여 사용할 수 있습니다:
|
|
@@ -54,31 +91,64 @@ import "@watcha-authentic/react-slider/style.css";
|
|
|
54
91
|
|
|
55
92
|
### Context 사용
|
|
56
93
|
|
|
57
|
-
|
|
58
|
-
import {
|
|
59
|
-
Slider,
|
|
60
|
-
SliderContextProvider,
|
|
61
|
-
useSliderContext,
|
|
62
|
-
} from "@watcha-authentic/react-slider";
|
|
63
|
-
|
|
64
|
-
function CustomNavigation() {
|
|
65
|
-
const { currentIndex, goToIndex } = useSliderContext();
|
|
94
|
+
아이템 내부에서 슬라이더 컨텍스트를 사용하여 포커스 상태나 전환 애니메이션을 처리할 수 있습니다.
|
|
66
95
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
96
|
+
```tsx
|
|
97
|
+
import { Slider, useSliderContext } from "@watcha-authentic/react-slider";
|
|
98
|
+
|
|
99
|
+
function CustomItem({ item }: { item: { id: number; title: string } }) {
|
|
100
|
+
useSliderContext({
|
|
101
|
+
onFocus: (isAutoSlide) => {
|
|
102
|
+
console.log("Item focused", isAutoSlide);
|
|
103
|
+
},
|
|
104
|
+
onBlur: () => {
|
|
105
|
+
console.log("Item blurred");
|
|
106
|
+
},
|
|
107
|
+
onTransitionChange: (t, immediate) => {
|
|
108
|
+
// t: 0 ~ 1 사이의 값 (0: fade in, 1: fade out)
|
|
109
|
+
// immediate: true면 실시간 값 변경, false면 애니메이션 트리거 가능
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
return <div>{item.title}</div>;
|
|
74
114
|
}
|
|
75
115
|
|
|
76
116
|
function App() {
|
|
77
117
|
return (
|
|
78
|
-
<
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
118
|
+
<Slider
|
|
119
|
+
items={items}
|
|
120
|
+
onItemKey={(item) => item.id}
|
|
121
|
+
onCreateItemView={(item) => <CustomItem item={item} />}
|
|
122
|
+
/>
|
|
82
123
|
);
|
|
83
124
|
}
|
|
84
125
|
```
|
|
126
|
+
|
|
127
|
+
## 주요 Props
|
|
128
|
+
|
|
129
|
+
### `defaultIndex?: number`
|
|
130
|
+
|
|
131
|
+
- 초기 인덱스를 설정합니다.
|
|
132
|
+
- 기본값은 `0`입니다.
|
|
133
|
+
- **참고**: `index` prop은 제거되었습니다. 슬라이더 제어는 `ref`의 `doNext()`와 `doPrev()` 메서드를 사용하거나 `onIndexChange` 콜백을 통해 처리하세요.
|
|
134
|
+
|
|
135
|
+
### `ref: React.Ref<SliderRef>`
|
|
136
|
+
|
|
137
|
+
- `SliderRef` 타입의 ref를 전달하면 다음 메서드에 접근할 수 있습니다:
|
|
138
|
+
- `doNext()`: 다음 슬라이드로 이동
|
|
139
|
+
- `doPrev()`: 이전 슬라이드로 이동
|
|
140
|
+
- 또한 `HTMLUListElement`의 모든 속성과 메서드도 사용할 수 있습니다.
|
|
141
|
+
|
|
142
|
+
### `onIndexChange?: (newIndex: number, cause: SlideTriggerEvent) => void`
|
|
143
|
+
|
|
144
|
+
- 인덱스가 변경될 때 호출되는 콜백입니다.
|
|
145
|
+
- `cause`: 슬라이드 원인 (`'swipe'`: 키보드 네비게이션, `'drag'`: 드래그/스와이프, `'pending'`: 초기 상태)
|
|
146
|
+
|
|
147
|
+
### 기타 Props
|
|
148
|
+
|
|
149
|
+
- `items`: 슬라이더에 표시할 아이템 배열
|
|
150
|
+
- `onCreateItemView`: 각 아이템을 렌더링하는 함수
|
|
151
|
+
- `onItemKey`: 각 아이템의 고유 키를 반환하는 함수
|
|
152
|
+
- `animationDuration`: 애니메이션 지속 시간 (기본값: 500ms)
|
|
153
|
+
- `enableDrag`: 드래그 기능 활성화 여부 (기본값: true)
|
|
154
|
+
- `visibleCount`: 중앙 기준 좌우로 보여줄 요소 개수 (기본값: 1)
|
|
@@ -19,7 +19,7 @@ const _slidercontextprovider = require("../context/slider-context-provider");
|
|
|
19
19
|
* - 드래그로 페이지 전환을 트리거하는 임계값 비율 (아이템 너비 기준)
|
|
20
20
|
*/ const CAN_SCROLL_THRESHOLD_RATIO = 0.15;
|
|
21
21
|
const DEFAULT_SCROLL_THRESHOLD = 125;
|
|
22
|
-
const SliderComponent = ({ animationDuration = 500, animationTimingFunction = "ease", defaultIndex = 0, enableDrag = true, estimateSizeFromEveryElements = false, gap = 0,
|
|
22
|
+
const SliderComponent = ({ animationDuration = 500, animationTimingFunction = "ease", defaultIndex = 0, enableDrag = true, estimateSizeFromEveryElements = false, gap = 0, itemProps, contentProps, items, visibleCount = 1, wrapProps, onCreateItemView, onDraggingNow, onIndexChange, onItemKey }, ref)=>{
|
|
23
23
|
const stableOnIndexChange = (0, _reacteventcallback.useEventCallback)(onIndexChange);
|
|
24
24
|
/**
|
|
25
25
|
* - 아이템이 부족할 경우 복제하여 확장된 아이템 배열을 생성합니다.
|
|
@@ -58,7 +58,7 @@ const SliderComponent = ({ animationDuration = 500, animationTimingFunction = "e
|
|
|
58
58
|
*/ const [enableScrollAnimator, setEnableScrollAnimator] = (0, _react.useState)(false);
|
|
59
59
|
// slider 페이지 인덱스, 아이템 포지션 정보 등
|
|
60
60
|
const [sliderInfo, setSliderInfo] = (0, _react.useState)({
|
|
61
|
-
currentIndex:
|
|
61
|
+
currentIndex: defaultIndex,
|
|
62
62
|
elementStates: [],
|
|
63
63
|
height: 0
|
|
64
64
|
});
|
|
@@ -66,11 +66,30 @@ const SliderComponent = ({ animationDuration = 500, animationTimingFunction = "e
|
|
|
66
66
|
// 아이템 element ref 값
|
|
67
67
|
const elementInfos = (0, _react.useRef)(new Map());
|
|
68
68
|
// 콜백 호출용 인덱스 값
|
|
69
|
-
const
|
|
69
|
+
const prevCallbackIndex = (0, _react.useRef)(sliderInfo.currentIndex);
|
|
70
70
|
// 드래그 임계값 (px, 동적으로 계산됨)
|
|
71
|
-
const
|
|
72
|
-
const lastSlideTriggerEvent = (0, _react.useRef)("
|
|
73
|
-
(0, _react.
|
|
71
|
+
const canScrollThreshold = (0, _react.useRef)(DEFAULT_SCROLL_THRESHOLD);
|
|
72
|
+
const lastSlideTriggerEvent = (0, _react.useRef)("pending");
|
|
73
|
+
const lastSliderInfoCurrentIndex = (0, _react.useRef)(sliderInfo.currentIndex);
|
|
74
|
+
const animateNow = (0, _react.useRef)(false);
|
|
75
|
+
const animateChecker = (0, _react.useRef)(undefined);
|
|
76
|
+
const capturedDefaultIndex = (0, _react.useRef)(defaultIndex);
|
|
77
|
+
const stopAnimateCheck = (0, _react.useCallback)(()=>{
|
|
78
|
+
clearTimeout(animateChecker.current);
|
|
79
|
+
animateChecker.current = undefined;
|
|
80
|
+
}, []);
|
|
81
|
+
const startAnimateCheck = (0, _react.useCallback)(()=>{
|
|
82
|
+
stopAnimateCheck();
|
|
83
|
+
return new Promise((resolve)=>{
|
|
84
|
+
animateChecker.current = setTimeout(()=>{
|
|
85
|
+
animateNow.current = false;
|
|
86
|
+
resolve();
|
|
87
|
+
}, animationDuration);
|
|
88
|
+
});
|
|
89
|
+
}, [
|
|
90
|
+
animationDuration,
|
|
91
|
+
stopAnimateCheck
|
|
92
|
+
]);
|
|
74
93
|
/**
|
|
75
94
|
* - element reference 를 저장 합니다.
|
|
76
95
|
*/ const updateElement = (0, _react.useCallback)(({ elementInfo: { content, item }, index })=>{
|
|
@@ -202,7 +221,7 @@ const SliderComponent = ({ animationDuration = 500, animationTimingFunction = "e
|
|
|
202
221
|
/**
|
|
203
222
|
* - 페이지를 기준으로 element 의 상태를 업데이트 합니다.
|
|
204
223
|
* - currentIndex 도 함께 업데이트 합니다.
|
|
205
|
-
*/ const updateStateByPageIndex = (0, _react.useCallback)(({ centerIndex, withAnimate = true })=>{
|
|
224
|
+
*/ const updateStateByPageIndex = (0, _react.useCallback)(async ({ centerIndex, withAnimate = true })=>{
|
|
206
225
|
setEnableScrollAnimator(withAnimate);
|
|
207
226
|
setSliderInfo((prev)=>({
|
|
208
227
|
...prev,
|
|
@@ -214,14 +233,20 @@ const SliderComponent = ({ animationDuration = 500, animationTimingFunction = "e
|
|
|
214
233
|
prevStates: prev.elementStates
|
|
215
234
|
})
|
|
216
235
|
}));
|
|
236
|
+
if (withAnimate) {
|
|
237
|
+
await startAnimateCheck();
|
|
238
|
+
} else {
|
|
239
|
+
stopAnimateCheck();
|
|
240
|
+
}
|
|
217
241
|
}, [
|
|
218
|
-
getNewStatesByItems
|
|
242
|
+
getNewStatesByItems,
|
|
243
|
+
startAnimateCheck,
|
|
244
|
+
stopAnimateCheck
|
|
219
245
|
]);
|
|
220
246
|
/**
|
|
221
247
|
* - 포인터 드래그에 의해 레이아웃을 조정합니다.
|
|
222
248
|
* - 뷰포트에 보여질 엘리먼트만 영향을 받습니다. (visibleCount 기준)
|
|
223
249
|
*/ const updateStateByDrag = (0, _react.useCallback)((diff)=>{
|
|
224
|
-
setEnableScrollAnimator(false);
|
|
225
250
|
setSliderInfo((prev)=>{
|
|
226
251
|
const { currentIndex } = prev;
|
|
227
252
|
const { length } = extendedItems;
|
|
@@ -342,7 +367,7 @@ const SliderComponent = ({ animationDuration = 500, animationTimingFunction = "e
|
|
|
342
367
|
estimatedScrollThreshold = (rect?.width ?? DEFAULT_SCROLL_THRESHOLD) * CAN_SCROLL_THRESHOLD_RATIO;
|
|
343
368
|
}
|
|
344
369
|
wrapRef.current.style.height = `${estimatedHeight}px`;
|
|
345
|
-
|
|
370
|
+
canScrollThreshold.current = estimatedScrollThreshold;
|
|
346
371
|
// 아이템 위치 계산
|
|
347
372
|
setSliderInfo((prevSliderInfo)=>{
|
|
348
373
|
return {
|
|
@@ -367,82 +392,38 @@ const SliderComponent = ({ animationDuration = 500, animationTimingFunction = "e
|
|
|
367
392
|
estimateSizeFromEveryElements,
|
|
368
393
|
getNewStatesByItems
|
|
369
394
|
]);
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
const candidateIndexes = extendedItems.filter((ei)=>ei.originalIndex === index).map((ei)=>ei.extendedIndex);
|
|
378
|
-
if (candidateIndexes.length > 0) {
|
|
379
|
-
// 현재 인덱스에서 가장 가까운 후보 찾기
|
|
380
|
-
let closestIndex = candidateIndexes[0];
|
|
381
|
-
if (closestIndex === undefined) {
|
|
382
|
-
console.warn("가장 가까운 후보 인덱스를 찾을 수 없습니다.", {
|
|
383
|
-
candidateIndexes,
|
|
384
|
-
sliderInfoCurrentIndex: sliderInfo.currentIndex,
|
|
385
|
-
extendedItems,
|
|
386
|
-
index
|
|
387
|
-
});
|
|
388
|
-
closestIndex = 0;
|
|
389
|
-
}
|
|
390
|
-
let minDistance = Math.abs(closestIndex - sliderInfo.currentIndex);
|
|
391
|
-
for (const candidate of candidateIndexes){
|
|
392
|
-
const distance = Math.abs(candidate - sliderInfo.currentIndex);
|
|
393
|
-
if (distance < minDistance) {
|
|
394
|
-
minDistance = distance;
|
|
395
|
-
closestIndex = candidate;
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
lastSlideTriggerEvent.current = "swipe";
|
|
399
|
-
setSliderInfo((prev)=>({
|
|
400
|
-
...prev,
|
|
401
|
-
currentIndex: closestIndex
|
|
402
|
-
}));
|
|
403
|
-
}
|
|
404
|
-
}
|
|
395
|
+
const handleSwipe = (0, _react.useCallback)(async ()=>{
|
|
396
|
+
lastSlideTriggerEvent.current = "swipe";
|
|
397
|
+
await updateStateByPageIndex({
|
|
398
|
+
centerIndex: sliderInfo.currentIndex,
|
|
399
|
+
withAnimate: true
|
|
400
|
+
});
|
|
401
|
+
lastSlideTriggerEvent.current = "pending";
|
|
405
402
|
}, [
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
items.length,
|
|
409
|
-
sliderInfo.currentIndex
|
|
403
|
+
sliderInfo.currentIndex,
|
|
404
|
+
updateStateByPageIndex
|
|
410
405
|
]);
|
|
411
406
|
/**
|
|
412
407
|
* - currentIndex 변경 시 애니메이션을 적용합니다.
|
|
413
408
|
* - doNext/doPrev 또는 외부 index prop 변경 모두 처리합니다.
|
|
414
|
-
*/
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
updateStateByPageIndex({
|
|
419
|
-
centerIndex: sliderInfo.currentIndex,
|
|
420
|
-
withAnimate: true
|
|
421
|
-
});
|
|
422
|
-
prevIndexRef.current = sliderInfo.currentIndex;
|
|
423
|
-
animateNow.current = true;
|
|
424
|
-
const animateCheck = setTimeout(()=>{
|
|
425
|
-
animateNow.current = false;
|
|
426
|
-
}, animationDuration);
|
|
427
|
-
return ()=>{
|
|
428
|
-
animateNow.current = false;
|
|
429
|
-
clearTimeout(animateCheck);
|
|
430
|
-
};
|
|
409
|
+
*/ (0, _react.useLayoutEffect)(()=>{
|
|
410
|
+
if (lastSliderInfoCurrentIndex.current !== sliderInfo.currentIndex) {
|
|
411
|
+
lastSliderInfoCurrentIndex.current = sliderInfo.currentIndex;
|
|
412
|
+
handleSwipe();
|
|
431
413
|
}
|
|
432
414
|
}, [
|
|
433
|
-
animationDuration,
|
|
434
415
|
sliderInfo.currentIndex,
|
|
435
|
-
|
|
416
|
+
handleSwipe
|
|
436
417
|
]);
|
|
437
418
|
/**
|
|
438
419
|
* - currentIndex 변경 시 콜백을 호출합니다.
|
|
439
420
|
* - 원본 인덱스로 변환하여 전달합니다.
|
|
440
421
|
*/ (0, _react.useEffect)(()=>{
|
|
441
|
-
if (
|
|
422
|
+
if (prevCallbackIndex.current !== sliderInfo.currentIndex && lastSlideTriggerEvent.current) {
|
|
442
423
|
// 원본 인덱스로 변환
|
|
443
424
|
const originalIndex = items.length > 0 ? sliderInfo.currentIndex % items.length : 0;
|
|
444
425
|
stableOnIndexChange(originalIndex, lastSlideTriggerEvent.current);
|
|
445
|
-
|
|
426
|
+
prevCallbackIndex.current = sliderInfo.currentIndex;
|
|
446
427
|
}
|
|
447
428
|
}, [
|
|
448
429
|
items.length,
|
|
@@ -465,10 +446,11 @@ const SliderComponent = ({ animationDuration = 500, animationTimingFunction = "e
|
|
|
465
446
|
* - 트랜잭션을 업데이트 합니다.
|
|
466
447
|
*/ const calculate = primaryPointer?.calculate;
|
|
467
448
|
if (calculate) {
|
|
449
|
+
setEnableScrollAnimator(false);
|
|
468
450
|
updateStateByDrag(calculate.diff);
|
|
469
451
|
if (isEnd) {
|
|
470
452
|
const diffX = Math.abs(calculate.diff.x);
|
|
471
|
-
if (!isCancel && diffX >
|
|
453
|
+
if (!isCancel && diffX > canScrollThreshold.current) {
|
|
472
454
|
lastSlideTriggerEvent.current = "drag";
|
|
473
455
|
if (calculate.diff.x < 0) {
|
|
474
456
|
doNext();
|
|
@@ -477,7 +459,6 @@ const SliderComponent = ({ animationDuration = 500, animationTimingFunction = "e
|
|
|
477
459
|
}
|
|
478
460
|
} else {
|
|
479
461
|
// 제자리로 움직이게 합니다.
|
|
480
|
-
setEnableScrollAnimator(true);
|
|
481
462
|
setSliderInfo((prev)=>({
|
|
482
463
|
...prev,
|
|
483
464
|
elementStates: getNewStatesByItems({
|
|
@@ -497,15 +478,23 @@ const SliderComponent = ({ animationDuration = 500, animationTimingFunction = "e
|
|
|
497
478
|
handler: (event)=>{
|
|
498
479
|
if (event.key === "ArrowLeft") {
|
|
499
480
|
event.preventDefault();
|
|
500
|
-
lastSlideTriggerEvent.current = "swipe";
|
|
501
481
|
doPrev();
|
|
502
482
|
} else if (event.key === "ArrowRight") {
|
|
503
483
|
event.preventDefault();
|
|
504
|
-
lastSlideTriggerEvent.current = "swipe";
|
|
505
484
|
doNext();
|
|
506
485
|
}
|
|
507
486
|
}
|
|
508
487
|
});
|
|
488
|
+
(0, _react.useImperativeHandle)(ref, (0, _react.useCallback)(()=>{
|
|
489
|
+
return {
|
|
490
|
+
doNext,
|
|
491
|
+
doPrev,
|
|
492
|
+
...wrapRef.current
|
|
493
|
+
};
|
|
494
|
+
}, [
|
|
495
|
+
doNext,
|
|
496
|
+
doPrev
|
|
497
|
+
]));
|
|
509
498
|
return /*#__PURE__*/ (0, _jsxruntime.jsx)("ul", {
|
|
510
499
|
"aria-roledescription": "carousel",
|
|
511
500
|
role: "region",
|
|
@@ -545,7 +534,7 @@ const SliderComponent = ({ animationDuration = 500, animationTimingFunction = "e
|
|
|
545
534
|
itemProps?.className
|
|
546
535
|
].join(" "),
|
|
547
536
|
style: state ? {
|
|
548
|
-
transform: `
|
|
537
|
+
transform: `translate3d(${state.point.x}px, ${state.point.y}px, 0px)`,
|
|
549
538
|
transition: enableScrollAnimator && state.enableAnimation ? `transform ${animationDuration}ms ${animationTimingFunction}` : undefined,
|
|
550
539
|
zIndex: state.zIndex
|
|
551
540
|
} : undefined,
|
|
@@ -9,7 +9,7 @@ import { SliderItemContextProvider } from "../context/slider-context-provider.js
|
|
|
9
9
|
* - 드래그로 페이지 전환을 트리거하는 임계값 비율 (아이템 너비 기준)
|
|
10
10
|
*/ const CAN_SCROLL_THRESHOLD_RATIO = 0.15;
|
|
11
11
|
const DEFAULT_SCROLL_THRESHOLD = 125;
|
|
12
|
-
const SliderComponent = ({ animationDuration = 500, animationTimingFunction = "ease", defaultIndex = 0, enableDrag = true, estimateSizeFromEveryElements = false, gap = 0,
|
|
12
|
+
const SliderComponent = ({ animationDuration = 500, animationTimingFunction = "ease", defaultIndex = 0, enableDrag = true, estimateSizeFromEveryElements = false, gap = 0, itemProps, contentProps, items, visibleCount = 1, wrapProps, onCreateItemView, onDraggingNow, onIndexChange, onItemKey }, ref)=>{
|
|
13
13
|
const stableOnIndexChange = useEventCallback(onIndexChange);
|
|
14
14
|
/**
|
|
15
15
|
* - 아이템이 부족할 경우 복제하여 확장된 아이템 배열을 생성합니다.
|
|
@@ -48,7 +48,7 @@ const SliderComponent = ({ animationDuration = 500, animationTimingFunction = "e
|
|
|
48
48
|
*/ const [enableScrollAnimator, setEnableScrollAnimator] = useState(false);
|
|
49
49
|
// slider 페이지 인덱스, 아이템 포지션 정보 등
|
|
50
50
|
const [sliderInfo, setSliderInfo] = useState({
|
|
51
|
-
currentIndex:
|
|
51
|
+
currentIndex: defaultIndex,
|
|
52
52
|
elementStates: [],
|
|
53
53
|
height: 0
|
|
54
54
|
});
|
|
@@ -56,11 +56,30 @@ const SliderComponent = ({ animationDuration = 500, animationTimingFunction = "e
|
|
|
56
56
|
// 아이템 element ref 값
|
|
57
57
|
const elementInfos = useRef(new Map());
|
|
58
58
|
// 콜백 호출용 인덱스 값
|
|
59
|
-
const
|
|
59
|
+
const prevCallbackIndex = useRef(sliderInfo.currentIndex);
|
|
60
60
|
// 드래그 임계값 (px, 동적으로 계산됨)
|
|
61
|
-
const
|
|
62
|
-
const lastSlideTriggerEvent = useRef("
|
|
63
|
-
|
|
61
|
+
const canScrollThreshold = useRef(DEFAULT_SCROLL_THRESHOLD);
|
|
62
|
+
const lastSlideTriggerEvent = useRef("pending");
|
|
63
|
+
const lastSliderInfoCurrentIndex = useRef(sliderInfo.currentIndex);
|
|
64
|
+
const animateNow = useRef(false);
|
|
65
|
+
const animateChecker = useRef(undefined);
|
|
66
|
+
const capturedDefaultIndex = useRef(defaultIndex);
|
|
67
|
+
const stopAnimateCheck = useCallback(()=>{
|
|
68
|
+
clearTimeout(animateChecker.current);
|
|
69
|
+
animateChecker.current = undefined;
|
|
70
|
+
}, []);
|
|
71
|
+
const startAnimateCheck = useCallback(()=>{
|
|
72
|
+
stopAnimateCheck();
|
|
73
|
+
return new Promise((resolve)=>{
|
|
74
|
+
animateChecker.current = setTimeout(()=>{
|
|
75
|
+
animateNow.current = false;
|
|
76
|
+
resolve();
|
|
77
|
+
}, animationDuration);
|
|
78
|
+
});
|
|
79
|
+
}, [
|
|
80
|
+
animationDuration,
|
|
81
|
+
stopAnimateCheck
|
|
82
|
+
]);
|
|
64
83
|
/**
|
|
65
84
|
* - element reference 를 저장 합니다.
|
|
66
85
|
*/ const updateElement = useCallback(({ elementInfo: { content, item }, index })=>{
|
|
@@ -192,7 +211,7 @@ const SliderComponent = ({ animationDuration = 500, animationTimingFunction = "e
|
|
|
192
211
|
/**
|
|
193
212
|
* - 페이지를 기준으로 element 의 상태를 업데이트 합니다.
|
|
194
213
|
* - currentIndex 도 함께 업데이트 합니다.
|
|
195
|
-
*/ const updateStateByPageIndex = useCallback(({ centerIndex, withAnimate = true })=>{
|
|
214
|
+
*/ const updateStateByPageIndex = useCallback(async ({ centerIndex, withAnimate = true })=>{
|
|
196
215
|
setEnableScrollAnimator(withAnimate);
|
|
197
216
|
setSliderInfo((prev)=>({
|
|
198
217
|
...prev,
|
|
@@ -204,14 +223,20 @@ const SliderComponent = ({ animationDuration = 500, animationTimingFunction = "e
|
|
|
204
223
|
prevStates: prev.elementStates
|
|
205
224
|
})
|
|
206
225
|
}));
|
|
226
|
+
if (withAnimate) {
|
|
227
|
+
await startAnimateCheck();
|
|
228
|
+
} else {
|
|
229
|
+
stopAnimateCheck();
|
|
230
|
+
}
|
|
207
231
|
}, [
|
|
208
|
-
getNewStatesByItems
|
|
232
|
+
getNewStatesByItems,
|
|
233
|
+
startAnimateCheck,
|
|
234
|
+
stopAnimateCheck
|
|
209
235
|
]);
|
|
210
236
|
/**
|
|
211
237
|
* - 포인터 드래그에 의해 레이아웃을 조정합니다.
|
|
212
238
|
* - 뷰포트에 보여질 엘리먼트만 영향을 받습니다. (visibleCount 기준)
|
|
213
239
|
*/ const updateStateByDrag = useCallback((diff)=>{
|
|
214
|
-
setEnableScrollAnimator(false);
|
|
215
240
|
setSliderInfo((prev)=>{
|
|
216
241
|
const { currentIndex } = prev;
|
|
217
242
|
const { length } = extendedItems;
|
|
@@ -332,7 +357,7 @@ const SliderComponent = ({ animationDuration = 500, animationTimingFunction = "e
|
|
|
332
357
|
estimatedScrollThreshold = (rect?.width ?? DEFAULT_SCROLL_THRESHOLD) * CAN_SCROLL_THRESHOLD_RATIO;
|
|
333
358
|
}
|
|
334
359
|
wrapRef.current.style.height = `${estimatedHeight}px`;
|
|
335
|
-
|
|
360
|
+
canScrollThreshold.current = estimatedScrollThreshold;
|
|
336
361
|
// 아이템 위치 계산
|
|
337
362
|
setSliderInfo((prevSliderInfo)=>{
|
|
338
363
|
return {
|
|
@@ -357,82 +382,38 @@ const SliderComponent = ({ animationDuration = 500, animationTimingFunction = "e
|
|
|
357
382
|
estimateSizeFromEveryElements,
|
|
358
383
|
getNewStatesByItems
|
|
359
384
|
]);
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
const candidateIndexes = extendedItems.filter((ei)=>ei.originalIndex === index).map((ei)=>ei.extendedIndex);
|
|
368
|
-
if (candidateIndexes.length > 0) {
|
|
369
|
-
// 현재 인덱스에서 가장 가까운 후보 찾기
|
|
370
|
-
let closestIndex = candidateIndexes[0];
|
|
371
|
-
if (closestIndex === undefined) {
|
|
372
|
-
console.warn("가장 가까운 후보 인덱스를 찾을 수 없습니다.", {
|
|
373
|
-
candidateIndexes,
|
|
374
|
-
sliderInfoCurrentIndex: sliderInfo.currentIndex,
|
|
375
|
-
extendedItems,
|
|
376
|
-
index
|
|
377
|
-
});
|
|
378
|
-
closestIndex = 0;
|
|
379
|
-
}
|
|
380
|
-
let minDistance = Math.abs(closestIndex - sliderInfo.currentIndex);
|
|
381
|
-
for (const candidate of candidateIndexes){
|
|
382
|
-
const distance = Math.abs(candidate - sliderInfo.currentIndex);
|
|
383
|
-
if (distance < minDistance) {
|
|
384
|
-
minDistance = distance;
|
|
385
|
-
closestIndex = candidate;
|
|
386
|
-
}
|
|
387
|
-
}
|
|
388
|
-
lastSlideTriggerEvent.current = "swipe";
|
|
389
|
-
setSliderInfo((prev)=>({
|
|
390
|
-
...prev,
|
|
391
|
-
currentIndex: closestIndex
|
|
392
|
-
}));
|
|
393
|
-
}
|
|
394
|
-
}
|
|
385
|
+
const handleSwipe = useCallback(async ()=>{
|
|
386
|
+
lastSlideTriggerEvent.current = "swipe";
|
|
387
|
+
await updateStateByPageIndex({
|
|
388
|
+
centerIndex: sliderInfo.currentIndex,
|
|
389
|
+
withAnimate: true
|
|
390
|
+
});
|
|
391
|
+
lastSlideTriggerEvent.current = "pending";
|
|
395
392
|
}, [
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
items.length,
|
|
399
|
-
sliderInfo.currentIndex
|
|
393
|
+
sliderInfo.currentIndex,
|
|
394
|
+
updateStateByPageIndex
|
|
400
395
|
]);
|
|
401
396
|
/**
|
|
402
397
|
* - currentIndex 변경 시 애니메이션을 적용합니다.
|
|
403
398
|
* - doNext/doPrev 또는 외부 index prop 변경 모두 처리합니다.
|
|
404
|
-
*/
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
updateStateByPageIndex({
|
|
409
|
-
centerIndex: sliderInfo.currentIndex,
|
|
410
|
-
withAnimate: true
|
|
411
|
-
});
|
|
412
|
-
prevIndexRef.current = sliderInfo.currentIndex;
|
|
413
|
-
animateNow.current = true;
|
|
414
|
-
const animateCheck = setTimeout(()=>{
|
|
415
|
-
animateNow.current = false;
|
|
416
|
-
}, animationDuration);
|
|
417
|
-
return ()=>{
|
|
418
|
-
animateNow.current = false;
|
|
419
|
-
clearTimeout(animateCheck);
|
|
420
|
-
};
|
|
399
|
+
*/ useLayoutEffect(()=>{
|
|
400
|
+
if (lastSliderInfoCurrentIndex.current !== sliderInfo.currentIndex) {
|
|
401
|
+
lastSliderInfoCurrentIndex.current = sliderInfo.currentIndex;
|
|
402
|
+
handleSwipe();
|
|
421
403
|
}
|
|
422
404
|
}, [
|
|
423
|
-
animationDuration,
|
|
424
405
|
sliderInfo.currentIndex,
|
|
425
|
-
|
|
406
|
+
handleSwipe
|
|
426
407
|
]);
|
|
427
408
|
/**
|
|
428
409
|
* - currentIndex 변경 시 콜백을 호출합니다.
|
|
429
410
|
* - 원본 인덱스로 변환하여 전달합니다.
|
|
430
411
|
*/ useEffect(()=>{
|
|
431
|
-
if (
|
|
412
|
+
if (prevCallbackIndex.current !== sliderInfo.currentIndex && lastSlideTriggerEvent.current) {
|
|
432
413
|
// 원본 인덱스로 변환
|
|
433
414
|
const originalIndex = items.length > 0 ? sliderInfo.currentIndex % items.length : 0;
|
|
434
415
|
stableOnIndexChange(originalIndex, lastSlideTriggerEvent.current);
|
|
435
|
-
|
|
416
|
+
prevCallbackIndex.current = sliderInfo.currentIndex;
|
|
436
417
|
}
|
|
437
418
|
}, [
|
|
438
419
|
items.length,
|
|
@@ -455,10 +436,11 @@ const SliderComponent = ({ animationDuration = 500, animationTimingFunction = "e
|
|
|
455
436
|
* - 트랜잭션을 업데이트 합니다.
|
|
456
437
|
*/ const calculate = primaryPointer?.calculate;
|
|
457
438
|
if (calculate) {
|
|
439
|
+
setEnableScrollAnimator(false);
|
|
458
440
|
updateStateByDrag(calculate.diff);
|
|
459
441
|
if (isEnd) {
|
|
460
442
|
const diffX = Math.abs(calculate.diff.x);
|
|
461
|
-
if (!isCancel && diffX >
|
|
443
|
+
if (!isCancel && diffX > canScrollThreshold.current) {
|
|
462
444
|
lastSlideTriggerEvent.current = "drag";
|
|
463
445
|
if (calculate.diff.x < 0) {
|
|
464
446
|
doNext();
|
|
@@ -467,7 +449,6 @@ const SliderComponent = ({ animationDuration = 500, animationTimingFunction = "e
|
|
|
467
449
|
}
|
|
468
450
|
} else {
|
|
469
451
|
// 제자리로 움직이게 합니다.
|
|
470
|
-
setEnableScrollAnimator(true);
|
|
471
452
|
setSliderInfo((prev)=>({
|
|
472
453
|
...prev,
|
|
473
454
|
elementStates: getNewStatesByItems({
|
|
@@ -487,15 +468,23 @@ const SliderComponent = ({ animationDuration = 500, animationTimingFunction = "e
|
|
|
487
468
|
handler: (event)=>{
|
|
488
469
|
if (event.key === "ArrowLeft") {
|
|
489
470
|
event.preventDefault();
|
|
490
|
-
lastSlideTriggerEvent.current = "swipe";
|
|
491
471
|
doPrev();
|
|
492
472
|
} else if (event.key === "ArrowRight") {
|
|
493
473
|
event.preventDefault();
|
|
494
|
-
lastSlideTriggerEvent.current = "swipe";
|
|
495
474
|
doNext();
|
|
496
475
|
}
|
|
497
476
|
}
|
|
498
477
|
});
|
|
478
|
+
useImperativeHandle(ref, useCallback(()=>{
|
|
479
|
+
return {
|
|
480
|
+
doNext,
|
|
481
|
+
doPrev,
|
|
482
|
+
...wrapRef.current
|
|
483
|
+
};
|
|
484
|
+
}, [
|
|
485
|
+
doNext,
|
|
486
|
+
doPrev
|
|
487
|
+
]));
|
|
499
488
|
return /*#__PURE__*/ _jsx("ul", {
|
|
500
489
|
"aria-roledescription": "carousel",
|
|
501
490
|
role: "region",
|
|
@@ -535,7 +524,7 @@ const SliderComponent = ({ animationDuration = 500, animationTimingFunction = "e
|
|
|
535
524
|
itemProps?.className
|
|
536
525
|
].join(" "),
|
|
537
526
|
style: state ? {
|
|
538
|
-
transform: `
|
|
527
|
+
transform: `translate3d(${state.point.x}px, ${state.point.y}px, 0px)`,
|
|
539
528
|
transition: enableScrollAnimator && state.enableAnimation ? `transform ${animationDuration}ms ${animationTimingFunction}` : undefined,
|
|
540
529
|
zIndex: state.zIndex
|
|
541
530
|
} : undefined,
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import type { CSSProperties } from "react";
|
|
2
2
|
import type { SlideTriggerEvent } from "../../script/type/slider-types";
|
|
3
|
+
export type SliderRef = HTMLUListElement & {
|
|
4
|
+
doNext: () => void;
|
|
5
|
+
doPrev: () => void;
|
|
6
|
+
};
|
|
3
7
|
type SliderProps<ItemType> = {
|
|
4
8
|
itemProps?: React.HTMLAttributes<HTMLLIElement>;
|
|
5
9
|
wrapProps?: React.HTMLAttributes<HTMLUListElement>;
|
|
@@ -33,11 +37,6 @@ type SliderProps<ItemType> = {
|
|
|
33
37
|
* - 기본값은 0 입니다.
|
|
34
38
|
*/
|
|
35
39
|
defaultIndex?: number;
|
|
36
|
-
/**
|
|
37
|
-
* - currentIndex 입니다. 이 값이 변경되면 애니메이션이 동작 될 수 있습니다.
|
|
38
|
-
* - 기본값은 defaultIndex 입니다.
|
|
39
|
-
*/
|
|
40
|
-
index?: number;
|
|
41
40
|
/**
|
|
42
41
|
* - 중앙 기준 좌우로 보여줄 요소 개수입니다.
|
|
43
42
|
* - 예: 1이면 좌1 + 중앙1 + 우1 = 3개, 2이면 좌2 + 중앙1 + 우2 = 5개
|