@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) => console.log("Index changed:", 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
- ```tsx
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
- return (
68
- <div>
69
- <button onClick={() => goToIndex(currentIndex - 1)}>Previous</button>
70
- <span>Current: {currentIndex}</span>
71
- <button onClick={() => goToIndex(currentIndex + 1)}>Next</button>
72
- </div>
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
- <SliderContextProvider>
79
- <Slider items={items} onItemKey={(item) => item.id} onCreateItemView={(item) => <div>{item.title}</div>} />
80
- <CustomNavigation />
81
- </SliderContextProvider>
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, index = defaultIndex, itemProps, contentProps, items, visibleCount = 1, wrapProps, onCreateItemView, onDraggingNow, onIndexChange, onItemKey }, ref)=>{
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: index,
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 prevCallbackIndexRef = (0, _react.useRef)(sliderInfo.currentIndex);
69
+ const prevCallbackIndex = (0, _react.useRef)(sliderInfo.currentIndex);
70
70
  // 드래그 임계값 (px, 동적으로 계산됨)
71
- const canScrollThresholdRef = (0, _react.useRef)(DEFAULT_SCROLL_THRESHOLD);
72
- const lastSlideTriggerEvent = (0, _react.useRef)("swipe");
73
- (0, _react.useImperativeHandle)(ref, ()=>wrapRef.current, []);
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
- */ (0, _react.useLayoutEffect)(()=>{
316
- const handleResize = ()=>{
317
- requestAnimationFrame(()=>{
318
- if (!wrapRef.current) {
319
- console.error("Slider 필수 요소에 접근할 수 없습니다.");
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
- extendedItems,
407
- index,
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
- */ const prevIndexRef = (0, _react.useRef)(sliderInfo.currentIndex);
415
- const animateNow = (0, _react.useRef)(false);
416
- (0, _react.useLayoutEffect)(()=>{
417
- if (prevIndexRef.current !== sliderInfo.currentIndex) {
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
- updateStateByPageIndex
359
+ handleSwipe
436
360
  ]);
437
361
  /**
438
362
  * - currentIndex 변경 시 콜백을 호출합니다.
439
363
  * - 원본 인덱스로 변환하여 전달합니다.
440
364
  */ (0, _react.useEffect)(()=>{
441
- if (prevCallbackIndexRef.current !== sliderInfo.currentIndex) {
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
- prevCallbackIndexRef.current = sliderInfo.currentIndex;
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 > canScrollThresholdRef.current) {
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: `translate(${state.point.x}px, ${state.point.y}px)`,
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, estimateSizeFromEveryElements = false, gap = 0, index = defaultIndex, itemProps, contentProps, items, visibleCount = 1, wrapProps, onCreateItemView, onDraggingNow, onIndexChange, onItemKey }, ref)=>{
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: index,
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 prevCallbackIndexRef = useRef(sliderInfo.currentIndex);
59
+ const prevCallbackIndex = useRef(sliderInfo.currentIndex);
60
60
  // 드래그 임계값 (px, 동적으로 계산됨)
61
- const canScrollThresholdRef = useRef(DEFAULT_SCROLL_THRESHOLD);
62
- const lastSlideTriggerEvent = useRef("swipe");
63
- useImperativeHandle(ref, ()=>wrapRef.current, []);
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
- */ useLayoutEffect(()=>{
306
- const handleResize = ()=>{
307
- requestAnimationFrame(()=>{
308
- if (!wrapRef.current) {
309
- console.error("Slider 필수 요소에 접근할 수 없습니다.");
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
- extendedItems,
397
- index,
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
- */ const prevIndexRef = useRef(sliderInfo.currentIndex);
405
- const animateNow = useRef(false);
406
- useLayoutEffect(()=>{
407
- if (prevIndexRef.current !== sliderInfo.currentIndex) {
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
- updateStateByPageIndex
349
+ handleSwipe
426
350
  ]);
427
351
  /**
428
352
  * - currentIndex 변경 시 콜백을 호출합니다.
429
353
  * - 원본 인덱스로 변환하여 전달합니다.
430
354
  */ useEffect(()=>{
431
- if (prevCallbackIndexRef.current !== sliderInfo.currentIndex) {
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
- prevCallbackIndexRef.current = sliderInfo.currentIndex;
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 > canScrollThresholdRef.current) {
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: `translate(${state.point.x}px, ${state.point.y}px)`,
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:flex;justify-content:center;position:relative}.watcha-react-slider-item{inset:0;position:absolute}.watcha-react-slider-content{position:relative}
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
  /**
@@ -1,4 +1,4 @@
1
1
  /**
2
2
  * - 슬라이드시 이벤트 트리거 종류
3
3
  */
4
- export type SlideTriggerEvent = "drag" | "swipe";
4
+ export type SlideTriggerEvent = "pending" | "drag" | "swipe";
package/package.json CHANGED
@@ -6,7 +6,7 @@
6
6
  "slider",
7
7
  "swiper"
8
8
  ],
9
- "version": "0.1.1",
9
+ "version": "0.1.3",
10
10
  "homepage": "https://github.com/frograms/bistro-house/tree/master/packages/react-slider#readme",
11
11
  "author": {
12
12
  "name": "@watcha-authentic#web-dev-group",