@watcha-authentic/react-slider 0.1.1 → 0.1.3
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,
|
|
22
|
+
const SliderComponent = ({ animationDuration = 500, animationTimingFunction = "ease", defaultIndex = 0, enableDrag = true, gap = 0, itemProps, contentProps, items, visibleCount = 1, disableDraggableItems = true, 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;
|
|
@@ -310,145 +335,59 @@ const SliderComponent = ({ animationDuration = 500, animationTimingFunction = "e
|
|
|
310
335
|
extendedItems,
|
|
311
336
|
getNewStatesByItems
|
|
312
337
|
]);
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
return;
|
|
321
|
-
}
|
|
322
|
-
// 높이, 드래그 임계값 계산
|
|
323
|
-
let estimatedHeight = 0;
|
|
324
|
-
let estimatedScrollThreshold = 0;
|
|
325
|
-
if (estimateSizeFromEveryElements) {
|
|
326
|
-
for (const [, { content }] of elementInfos.current){
|
|
327
|
-
if (content) {
|
|
328
|
-
const rect = content.getBoundingClientRect();
|
|
329
|
-
const { height } = rect;
|
|
330
|
-
if (height > estimatedHeight) {
|
|
331
|
-
estimatedHeight = height;
|
|
332
|
-
}
|
|
333
|
-
if (rect.width > estimatedScrollThreshold) {
|
|
334
|
-
estimatedScrollThreshold = rect.width * CAN_SCROLL_THRESHOLD_RATIO;
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
} else {
|
|
339
|
-
const firstElementInfo = elementInfos.current.get(0);
|
|
340
|
-
const rect = firstElementInfo?.content?.getBoundingClientRect();
|
|
341
|
-
estimatedHeight = rect?.height ?? 0;
|
|
342
|
-
estimatedScrollThreshold = (rect?.width ?? DEFAULT_SCROLL_THRESHOLD) * CAN_SCROLL_THRESHOLD_RATIO;
|
|
343
|
-
}
|
|
344
|
-
wrapRef.current.style.height = `${estimatedHeight}px`;
|
|
345
|
-
canScrollThresholdRef.current = estimatedScrollThreshold;
|
|
346
|
-
// 아이템 위치 계산
|
|
347
|
-
setSliderInfo((prevSliderInfo)=>{
|
|
348
|
-
return {
|
|
349
|
-
...prevSliderInfo,
|
|
350
|
-
elementStates: getNewStatesByItems({
|
|
351
|
-
centerIndex: prevSliderInfo.currentIndex,
|
|
352
|
-
itemIndexs: prevSliderInfo.elementStates.map((_, index)=>index),
|
|
353
|
-
prevStates: prevSliderInfo.elementStates
|
|
354
|
-
})
|
|
355
|
-
};
|
|
356
|
-
});
|
|
357
|
-
});
|
|
358
|
-
};
|
|
359
|
-
// 초기 로드 시 높이 계산
|
|
360
|
-
handleResize();
|
|
361
|
-
window.addEventListener("resize", handleResize);
|
|
362
|
-
return ()=>{
|
|
363
|
-
window.removeEventListener("resize", handleResize);
|
|
364
|
-
};
|
|
365
|
-
}, [
|
|
366
|
-
calcElementState,
|
|
367
|
-
estimateSizeFromEveryElements,
|
|
368
|
-
getNewStatesByItems
|
|
369
|
-
]);
|
|
370
|
-
/**
|
|
371
|
-
* - 외부 index 프롭스 변경 시 currentIndex에 반영합니다.
|
|
372
|
-
* - 외부 index는 원본 인덱스이므로, 현재 위치에서 가장 가까운 확장 인덱스를 찾습니다.
|
|
373
|
-
*/ (0, _react.useLayoutEffect)(()=>{
|
|
374
|
-
const currentOriginalIndex = items.length > 0 ? sliderInfo.currentIndex % items.length : 0;
|
|
375
|
-
if (currentOriginalIndex !== index) {
|
|
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
|
-
}
|
|
338
|
+
const handleSwipe = (0, _react.useCallback)(async ()=>{
|
|
339
|
+
lastSlideTriggerEvent.current = "swipe";
|
|
340
|
+
await updateStateByPageIndex({
|
|
341
|
+
centerIndex: sliderInfo.currentIndex,
|
|
342
|
+
withAnimate: true
|
|
343
|
+
});
|
|
344
|
+
lastSlideTriggerEvent.current = "pending";
|
|
405
345
|
}, [
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
items.length,
|
|
409
|
-
sliderInfo.currentIndex
|
|
346
|
+
sliderInfo.currentIndex,
|
|
347
|
+
updateStateByPageIndex
|
|
410
348
|
]);
|
|
411
349
|
/**
|
|
412
350
|
* - currentIndex 변경 시 애니메이션을 적용합니다.
|
|
413
351
|
* - 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
|
-
};
|
|
352
|
+
*/ (0, _react.useLayoutEffect)(()=>{
|
|
353
|
+
if (lastSliderInfoCurrentIndex.current !== sliderInfo.currentIndex) {
|
|
354
|
+
lastSliderInfoCurrentIndex.current = sliderInfo.currentIndex;
|
|
355
|
+
handleSwipe();
|
|
431
356
|
}
|
|
432
357
|
}, [
|
|
433
|
-
animationDuration,
|
|
434
358
|
sliderInfo.currentIndex,
|
|
435
|
-
|
|
359
|
+
handleSwipe
|
|
436
360
|
]);
|
|
437
361
|
/**
|
|
438
362
|
* - currentIndex 변경 시 콜백을 호출합니다.
|
|
439
363
|
* - 원본 인덱스로 변환하여 전달합니다.
|
|
440
364
|
*/ (0, _react.useEffect)(()=>{
|
|
441
|
-
if (
|
|
365
|
+
if (prevCallbackIndex.current !== sliderInfo.currentIndex && lastSlideTriggerEvent.current) {
|
|
442
366
|
// 원본 인덱스로 변환
|
|
443
367
|
const originalIndex = items.length > 0 ? sliderInfo.currentIndex % items.length : 0;
|
|
444
368
|
stableOnIndexChange(originalIndex, lastSlideTriggerEvent.current);
|
|
445
|
-
|
|
369
|
+
prevCallbackIndex.current = sliderInfo.currentIndex;
|
|
446
370
|
}
|
|
447
371
|
}, [
|
|
448
372
|
items.length,
|
|
449
373
|
sliderInfo.currentIndex,
|
|
450
374
|
stableOnIndexChange
|
|
451
375
|
]);
|
|
376
|
+
/**
|
|
377
|
+
* - draggable 값을 img, a 등의 요소에 적용합니다.
|
|
378
|
+
*/ (0, _react.useLayoutEffect)(()=>{
|
|
379
|
+
if (!disableDraggableItems) {
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
const targets = wrapRef.current?.querySelectorAll("img, a");
|
|
383
|
+
if (targets) {
|
|
384
|
+
for (const target of targets){
|
|
385
|
+
target.setAttribute("draggable", "false");
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}, [
|
|
389
|
+
disableDraggableItems
|
|
390
|
+
]);
|
|
452
391
|
const { withPointerMove } = (0, _reactmotion.usePointerMove)({
|
|
453
392
|
enabled: enableDrag,
|
|
454
393
|
target: wrapRef,
|
|
@@ -465,10 +404,11 @@ const SliderComponent = ({ animationDuration = 500, animationTimingFunction = "e
|
|
|
465
404
|
* - 트랜잭션을 업데이트 합니다.
|
|
466
405
|
*/ const calculate = primaryPointer?.calculate;
|
|
467
406
|
if (calculate) {
|
|
407
|
+
setEnableScrollAnimator(false);
|
|
468
408
|
updateStateByDrag(calculate.diff);
|
|
469
409
|
if (isEnd) {
|
|
470
410
|
const diffX = Math.abs(calculate.diff.x);
|
|
471
|
-
if (!isCancel && diffX >
|
|
411
|
+
if (!isCancel && diffX > canScrollThreshold.current) {
|
|
472
412
|
lastSlideTriggerEvent.current = "drag";
|
|
473
413
|
if (calculate.diff.x < 0) {
|
|
474
414
|
doNext();
|
|
@@ -477,7 +417,6 @@ const SliderComponent = ({ animationDuration = 500, animationTimingFunction = "e
|
|
|
477
417
|
}
|
|
478
418
|
} else {
|
|
479
419
|
// 제자리로 움직이게 합니다.
|
|
480
|
-
setEnableScrollAnimator(true);
|
|
481
420
|
setSliderInfo((prev)=>({
|
|
482
421
|
...prev,
|
|
483
422
|
elementStates: getNewStatesByItems({
|
|
@@ -497,15 +436,23 @@ const SliderComponent = ({ animationDuration = 500, animationTimingFunction = "e
|
|
|
497
436
|
handler: (event)=>{
|
|
498
437
|
if (event.key === "ArrowLeft") {
|
|
499
438
|
event.preventDefault();
|
|
500
|
-
lastSlideTriggerEvent.current = "swipe";
|
|
501
439
|
doPrev();
|
|
502
440
|
} else if (event.key === "ArrowRight") {
|
|
503
441
|
event.preventDefault();
|
|
504
|
-
lastSlideTriggerEvent.current = "swipe";
|
|
505
442
|
doNext();
|
|
506
443
|
}
|
|
507
444
|
}
|
|
508
445
|
});
|
|
446
|
+
(0, _react.useImperativeHandle)(ref, (0, _react.useCallback)(()=>{
|
|
447
|
+
return {
|
|
448
|
+
doNext,
|
|
449
|
+
doPrev,
|
|
450
|
+
...wrapRef.current
|
|
451
|
+
};
|
|
452
|
+
}, [
|
|
453
|
+
doNext,
|
|
454
|
+
doPrev
|
|
455
|
+
]));
|
|
509
456
|
return /*#__PURE__*/ (0, _jsxruntime.jsx)("ul", {
|
|
510
457
|
"aria-roledescription": "carousel",
|
|
511
458
|
role: "region",
|
|
@@ -545,7 +492,7 @@ const SliderComponent = ({ animationDuration = 500, animationTimingFunction = "e
|
|
|
545
492
|
itemProps?.className
|
|
546
493
|
].join(" "),
|
|
547
494
|
style: state ? {
|
|
548
|
-
transform: `
|
|
495
|
+
transform: `translate3d(${state.point.x}px, ${state.point.y}px, 0px)`,
|
|
549
496
|
transition: enableScrollAnimator && state.enableAnimation ? `transform ${animationDuration}ms ${animationTimingFunction}` : undefined,
|
|
550
497
|
zIndex: state.zIndex
|
|
551
498
|
} : 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,
|
|
12
|
+
const SliderComponent = ({ animationDuration = 500, animationTimingFunction = "ease", defaultIndex = 0, enableDrag = true, gap = 0, itemProps, contentProps, items, visibleCount = 1, disableDraggableItems = true, 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;
|
|
@@ -300,145 +325,59 @@ const SliderComponent = ({ animationDuration = 500, animationTimingFunction = "e
|
|
|
300
325
|
extendedItems,
|
|
301
326
|
getNewStatesByItems
|
|
302
327
|
]);
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
return;
|
|
311
|
-
}
|
|
312
|
-
// 높이, 드래그 임계값 계산
|
|
313
|
-
let estimatedHeight = 0;
|
|
314
|
-
let estimatedScrollThreshold = 0;
|
|
315
|
-
if (estimateSizeFromEveryElements) {
|
|
316
|
-
for (const [, { content }] of elementInfos.current){
|
|
317
|
-
if (content) {
|
|
318
|
-
const rect = content.getBoundingClientRect();
|
|
319
|
-
const { height } = rect;
|
|
320
|
-
if (height > estimatedHeight) {
|
|
321
|
-
estimatedHeight = height;
|
|
322
|
-
}
|
|
323
|
-
if (rect.width > estimatedScrollThreshold) {
|
|
324
|
-
estimatedScrollThreshold = rect.width * CAN_SCROLL_THRESHOLD_RATIO;
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
} else {
|
|
329
|
-
const firstElementInfo = elementInfos.current.get(0);
|
|
330
|
-
const rect = firstElementInfo?.content?.getBoundingClientRect();
|
|
331
|
-
estimatedHeight = rect?.height ?? 0;
|
|
332
|
-
estimatedScrollThreshold = (rect?.width ?? DEFAULT_SCROLL_THRESHOLD) * CAN_SCROLL_THRESHOLD_RATIO;
|
|
333
|
-
}
|
|
334
|
-
wrapRef.current.style.height = `${estimatedHeight}px`;
|
|
335
|
-
canScrollThresholdRef.current = estimatedScrollThreshold;
|
|
336
|
-
// 아이템 위치 계산
|
|
337
|
-
setSliderInfo((prevSliderInfo)=>{
|
|
338
|
-
return {
|
|
339
|
-
...prevSliderInfo,
|
|
340
|
-
elementStates: getNewStatesByItems({
|
|
341
|
-
centerIndex: prevSliderInfo.currentIndex,
|
|
342
|
-
itemIndexs: prevSliderInfo.elementStates.map((_, index)=>index),
|
|
343
|
-
prevStates: prevSliderInfo.elementStates
|
|
344
|
-
})
|
|
345
|
-
};
|
|
346
|
-
});
|
|
347
|
-
});
|
|
348
|
-
};
|
|
349
|
-
// 초기 로드 시 높이 계산
|
|
350
|
-
handleResize();
|
|
351
|
-
window.addEventListener("resize", handleResize);
|
|
352
|
-
return ()=>{
|
|
353
|
-
window.removeEventListener("resize", handleResize);
|
|
354
|
-
};
|
|
355
|
-
}, [
|
|
356
|
-
calcElementState,
|
|
357
|
-
estimateSizeFromEveryElements,
|
|
358
|
-
getNewStatesByItems
|
|
359
|
-
]);
|
|
360
|
-
/**
|
|
361
|
-
* - 외부 index 프롭스 변경 시 currentIndex에 반영합니다.
|
|
362
|
-
* - 외부 index는 원본 인덱스이므로, 현재 위치에서 가장 가까운 확장 인덱스를 찾습니다.
|
|
363
|
-
*/ useLayoutEffect(()=>{
|
|
364
|
-
const currentOriginalIndex = items.length > 0 ? sliderInfo.currentIndex % items.length : 0;
|
|
365
|
-
if (currentOriginalIndex !== index) {
|
|
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
|
-
}
|
|
328
|
+
const handleSwipe = useCallback(async ()=>{
|
|
329
|
+
lastSlideTriggerEvent.current = "swipe";
|
|
330
|
+
await updateStateByPageIndex({
|
|
331
|
+
centerIndex: sliderInfo.currentIndex,
|
|
332
|
+
withAnimate: true
|
|
333
|
+
});
|
|
334
|
+
lastSlideTriggerEvent.current = "pending";
|
|
395
335
|
}, [
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
items.length,
|
|
399
|
-
sliderInfo.currentIndex
|
|
336
|
+
sliderInfo.currentIndex,
|
|
337
|
+
updateStateByPageIndex
|
|
400
338
|
]);
|
|
401
339
|
/**
|
|
402
340
|
* - currentIndex 변경 시 애니메이션을 적용합니다.
|
|
403
341
|
* - 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
|
-
};
|
|
342
|
+
*/ useLayoutEffect(()=>{
|
|
343
|
+
if (lastSliderInfoCurrentIndex.current !== sliderInfo.currentIndex) {
|
|
344
|
+
lastSliderInfoCurrentIndex.current = sliderInfo.currentIndex;
|
|
345
|
+
handleSwipe();
|
|
421
346
|
}
|
|
422
347
|
}, [
|
|
423
|
-
animationDuration,
|
|
424
348
|
sliderInfo.currentIndex,
|
|
425
|
-
|
|
349
|
+
handleSwipe
|
|
426
350
|
]);
|
|
427
351
|
/**
|
|
428
352
|
* - currentIndex 변경 시 콜백을 호출합니다.
|
|
429
353
|
* - 원본 인덱스로 변환하여 전달합니다.
|
|
430
354
|
*/ useEffect(()=>{
|
|
431
|
-
if (
|
|
355
|
+
if (prevCallbackIndex.current !== sliderInfo.currentIndex && lastSlideTriggerEvent.current) {
|
|
432
356
|
// 원본 인덱스로 변환
|
|
433
357
|
const originalIndex = items.length > 0 ? sliderInfo.currentIndex % items.length : 0;
|
|
434
358
|
stableOnIndexChange(originalIndex, lastSlideTriggerEvent.current);
|
|
435
|
-
|
|
359
|
+
prevCallbackIndex.current = sliderInfo.currentIndex;
|
|
436
360
|
}
|
|
437
361
|
}, [
|
|
438
362
|
items.length,
|
|
439
363
|
sliderInfo.currentIndex,
|
|
440
364
|
stableOnIndexChange
|
|
441
365
|
]);
|
|
366
|
+
/**
|
|
367
|
+
* - draggable 값을 img, a 등의 요소에 적용합니다.
|
|
368
|
+
*/ useLayoutEffect(()=>{
|
|
369
|
+
if (!disableDraggableItems) {
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
const targets = wrapRef.current?.querySelectorAll("img, a");
|
|
373
|
+
if (targets) {
|
|
374
|
+
for (const target of targets){
|
|
375
|
+
target.setAttribute("draggable", "false");
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}, [
|
|
379
|
+
disableDraggableItems
|
|
380
|
+
]);
|
|
442
381
|
const { withPointerMove } = usePointerMove({
|
|
443
382
|
enabled: enableDrag,
|
|
444
383
|
target: wrapRef,
|
|
@@ -455,10 +394,11 @@ const SliderComponent = ({ animationDuration = 500, animationTimingFunction = "e
|
|
|
455
394
|
* - 트랜잭션을 업데이트 합니다.
|
|
456
395
|
*/ const calculate = primaryPointer?.calculate;
|
|
457
396
|
if (calculate) {
|
|
397
|
+
setEnableScrollAnimator(false);
|
|
458
398
|
updateStateByDrag(calculate.diff);
|
|
459
399
|
if (isEnd) {
|
|
460
400
|
const diffX = Math.abs(calculate.diff.x);
|
|
461
|
-
if (!isCancel && diffX >
|
|
401
|
+
if (!isCancel && diffX > canScrollThreshold.current) {
|
|
462
402
|
lastSlideTriggerEvent.current = "drag";
|
|
463
403
|
if (calculate.diff.x < 0) {
|
|
464
404
|
doNext();
|
|
@@ -467,7 +407,6 @@ const SliderComponent = ({ animationDuration = 500, animationTimingFunction = "e
|
|
|
467
407
|
}
|
|
468
408
|
} else {
|
|
469
409
|
// 제자리로 움직이게 합니다.
|
|
470
|
-
setEnableScrollAnimator(true);
|
|
471
410
|
setSliderInfo((prev)=>({
|
|
472
411
|
...prev,
|
|
473
412
|
elementStates: getNewStatesByItems({
|
|
@@ -487,15 +426,23 @@ const SliderComponent = ({ animationDuration = 500, animationTimingFunction = "e
|
|
|
487
426
|
handler: (event)=>{
|
|
488
427
|
if (event.key === "ArrowLeft") {
|
|
489
428
|
event.preventDefault();
|
|
490
|
-
lastSlideTriggerEvent.current = "swipe";
|
|
491
429
|
doPrev();
|
|
492
430
|
} else if (event.key === "ArrowRight") {
|
|
493
431
|
event.preventDefault();
|
|
494
|
-
lastSlideTriggerEvent.current = "swipe";
|
|
495
432
|
doNext();
|
|
496
433
|
}
|
|
497
434
|
}
|
|
498
435
|
});
|
|
436
|
+
useImperativeHandle(ref, useCallback(()=>{
|
|
437
|
+
return {
|
|
438
|
+
doNext,
|
|
439
|
+
doPrev,
|
|
440
|
+
...wrapRef.current
|
|
441
|
+
};
|
|
442
|
+
}, [
|
|
443
|
+
doNext,
|
|
444
|
+
doPrev
|
|
445
|
+
]));
|
|
499
446
|
return /*#__PURE__*/ _jsx("ul", {
|
|
500
447
|
"aria-roledescription": "carousel",
|
|
501
448
|
role: "region",
|
|
@@ -535,7 +482,7 @@ const SliderComponent = ({ animationDuration = 500, animationTimingFunction = "e
|
|
|
535
482
|
itemProps?.className
|
|
536
483
|
].join(" "),
|
|
537
484
|
style: state ? {
|
|
538
|
-
transform: `
|
|
485
|
+
transform: `translate3d(${state.point.x}px, ${state.point.y}px, 0px)`,
|
|
539
486
|
transition: enableScrollAnimator && state.enableAnimation ? `transform ${animationDuration}ms ${animationTimingFunction}` : undefined,
|
|
540
487
|
zIndex: state.zIndex
|
|
541
488
|
} : undefined,
|
package/dist/style.css
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
.watcha-react-slider-wrap{display:
|
|
1
|
+
.watcha-react-slider-wrap{display:grid;grid-template-columns:1fr;justify-content:center;list-style:none;margin:0;padding:0;position:relative}.watcha-react-slider-item{grid-area:1/1;margin:0;padding:0}.watcha-react-slider-content{position:relative}
|
|
@@ -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>;
|
|
@@ -7,12 +11,6 @@ type SliderProps<ItemType> = {
|
|
|
7
11
|
items: Array<ItemType>;
|
|
8
12
|
onCreateItemView: (item: ItemType, index: number) => React.ReactNode;
|
|
9
13
|
onItemKey: (item: ItemType) => React.Key;
|
|
10
|
-
/**
|
|
11
|
-
* - 높이, 드래그 임계값 등의 사이즈 관련 값을 추정하는 방식을 선택 합니다. 기본값은 false 입니다.
|
|
12
|
-
* - true: 모든 자식요소로부터 추정합니다. 가장 큰 사이즈값이 선택 됩니다.
|
|
13
|
-
* - false: 첫번째 자식요소의 사이즈를 사용합니다.
|
|
14
|
-
*/
|
|
15
|
-
estimateSizeFromEveryElements?: boolean;
|
|
16
14
|
/**
|
|
17
15
|
* - item 간 거리입니다.
|
|
18
16
|
* - 기본값은 0 입니다.
|
|
@@ -33,11 +31,6 @@ type SliderProps<ItemType> = {
|
|
|
33
31
|
* - 기본값은 0 입니다.
|
|
34
32
|
*/
|
|
35
33
|
defaultIndex?: number;
|
|
36
|
-
/**
|
|
37
|
-
* - currentIndex 입니다. 이 값이 변경되면 애니메이션이 동작 될 수 있습니다.
|
|
38
|
-
* - 기본값은 defaultIndex 입니다.
|
|
39
|
-
*/
|
|
40
|
-
index?: number;
|
|
41
34
|
/**
|
|
42
35
|
* - 중앙 기준 좌우로 보여줄 요소 개수입니다.
|
|
43
36
|
* - 예: 1이면 좌1 + 중앙1 + 우1 = 3개, 2이면 좌2 + 중앙1 + 우2 = 5개
|
|
@@ -54,6 +47,11 @@ type SliderProps<ItemType> = {
|
|
|
54
47
|
* - 기본값은 true 입니다.
|
|
55
48
|
*/
|
|
56
49
|
enableDrag?: boolean;
|
|
50
|
+
/**
|
|
51
|
+
* - 렌더링된 아이템의 a, img 등의 요소에 draggable 값을 제한 합니다.
|
|
52
|
+
* - 기본값은 true 입니다.
|
|
53
|
+
*/
|
|
54
|
+
disableDraggableItems?: boolean;
|
|
57
55
|
onDraggingNow?: (isDragging: boolean) => void;
|
|
58
56
|
};
|
|
59
57
|
/**
|