aplance-ui 1.1.1 → 1.2.0

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.
@@ -0,0 +1,1174 @@
1
+ import {
2
+ Children,
3
+ isValidElement,
4
+ useCallback,
5
+ useEffect,
6
+ useId,
7
+ useImperativeHandle,
8
+ useLayoutEffect,
9
+ useMemo,
10
+ useRef,
11
+ useState,
12
+ type CSSProperties,
13
+ type PointerEvent as ReactPointerEvent,
14
+ type ReactNode,
15
+ type TransitionEvent as ReactTransitionEvent,
16
+ type WheelEvent as ReactWheelEvent,
17
+ } from "react";
18
+ import clsx from "clsx";
19
+ import { IconChevronLeft, IconChevronRight } from "@tabler/icons-react";
20
+ import { useResizeObserver } from "../../../hooks/useResizeObserver";
21
+ import { SliderContext, useSlider, type SliderApi } from "./slider-context";
22
+ import type {
23
+ SliderAutoplayOptions,
24
+ SliderNavigationOptions,
25
+ SliderPaginationOptions,
26
+ SliderPaginationType,
27
+ SliderProps,
28
+ SliderSlideState,
29
+ } from "./types";
30
+ import {
31
+ clamp,
32
+ getSnapMax,
33
+ normalizeIndex,
34
+ resolveResponsive,
35
+ shouldIgnoreDrag,
36
+ wrap,
37
+ } from "./utils";
38
+ import s from "./slider.module.css";
39
+
40
+ export {
41
+ sliderAlignments,
42
+ sliderEffects,
43
+ sliderPaginationTypes,
44
+ } from "./types";
45
+ export type {
46
+ SliderAlign,
47
+ SliderAutoplayOptions,
48
+ SliderEffect,
49
+ SliderHandle,
50
+ SliderNavigationOptions,
51
+ SliderPaginationOptions,
52
+ SliderPaginationType,
53
+ SliderProps,
54
+ SliderResponsive,
55
+ SliderResponsiveOptions,
56
+ SliderSlideState,
57
+ SliderSlidesPerView,
58
+ } from "./types";
59
+
60
+ // ─── Slider ───────────────────────────────────────────────────────────────────
61
+
62
+ export const Slider = ({
63
+ children,
64
+ className,
65
+ style,
66
+ ref,
67
+ ariaLabel = "Slider",
68
+ effect: effectProp = "slide",
69
+ align: alignProp = "start",
70
+ centered: centeredProp = false,
71
+ slidesPerView: slidesPerViewProp = 1,
72
+ slidesPerGroup: slidesPerGroupProp = 1,
73
+ spaceBetween: spaceBetweenProp = 0,
74
+ responsive,
75
+ index: controlledIndex,
76
+ defaultIndex = 0,
77
+ onIndexChange,
78
+ onSlideChange,
79
+ loop = false,
80
+ rewind = false,
81
+ speed = 500,
82
+ easing = "cubic-bezier(0.4, 0, 0.2, 1)",
83
+ draggable = true,
84
+ swipeThreshold = 30,
85
+ dragIgnoreSelector = "a, button, input, textarea, select, summary, [role='button'], [data-slider-no-drag]",
86
+ keyboard = false,
87
+ mousewheel = false,
88
+ autoHeight = false,
89
+ autoplay = false,
90
+ pauseOnHover = true,
91
+ navigation = false,
92
+ pagination = false,
93
+ renderControls,
94
+ renderSlide,
95
+ onKeyDown,
96
+ onWheel,
97
+ onMouseEnter,
98
+ onMouseLeave,
99
+ onFocus,
100
+ onBlur,
101
+ ...rest
102
+ }: SliderProps) => {
103
+ const uid = useId();
104
+ const slides = useMemo(
105
+ () =>
106
+ Children.toArray(children).filter(
107
+ (child) => child !== null && child !== undefined,
108
+ ),
109
+ [children],
110
+ );
111
+ const count = slides.length;
112
+
113
+ // ─── Measurement ────────────────────────────────────────────────────────────
114
+ // We read real `offsetLeft`/`offsetWidth` per slide instead of computing from
115
+ // `slidesPerView`/`spaceBetween` alone. This keeps `auto` widths, subpixel
116
+ // gaps, and `align=center` math accurate even when slide content changes.
117
+
118
+ const viewportRef = useRef<HTMLDivElement>(null);
119
+ const trackRef = useRef<HTMLDivElement>(null);
120
+ const slideRefs = useRef<Array<HTMLDivElement | null>>([]);
121
+ const [viewportWidth, setViewportWidth] = useState(0);
122
+ const [slideOffsets, setSlideOffsets] = useState<number[]>([]);
123
+ const [slideSizes, setSlideSizes] = useState<number[]>([]);
124
+ const [trackWidth, setTrackWidth] = useState(0);
125
+ const [hasMeasured, setHasMeasured] = useState(false);
126
+ const [isMotionReady, setIsMotionReady] = useState(false);
127
+ const [activeHeight, setActiveHeight] = useState<number | undefined>();
128
+
129
+ const measure = useCallback(() => {
130
+ const viewport = viewportRef.current;
131
+ const track = trackRef.current;
132
+ if (!viewport || !track) return;
133
+
134
+ setViewportWidth(viewport.clientWidth);
135
+ const offsets = slideRefs.current.map((node) => node?.offsetLeft ?? 0);
136
+ const sizes = slideRefs.current.map((node) => node?.offsetWidth ?? 0);
137
+ setSlideOffsets(offsets);
138
+ setSlideSizes(sizes);
139
+ setTrackWidth(track.scrollWidth);
140
+ setHasMeasured(true);
141
+
142
+ if (autoHeight) {
143
+ const activeNode = slideRefs.current.find(
144
+ (node) => node?.dataset.active === "true",
145
+ );
146
+ setActiveHeight(activeNode?.offsetHeight);
147
+ }
148
+ }, [autoHeight]);
149
+
150
+ useLayoutEffect(() => {
151
+ measure();
152
+ }, [measure]);
153
+ useResizeObserver(viewportRef, measure);
154
+ useResizeObserver(trackRef, measure);
155
+
156
+ // ─── Responsive resolution ──────────────────────────────────────────────────
157
+
158
+ const responsiveOptions = useMemo(
159
+ () =>
160
+ resolveResponsive(
161
+ viewportWidth,
162
+ {
163
+ slidesPerView: slidesPerViewProp,
164
+ slidesPerGroup: slidesPerGroupProp,
165
+ spaceBetween: spaceBetweenProp,
166
+ centered: centeredProp,
167
+ align: alignProp,
168
+ effect: effectProp,
169
+ },
170
+ responsive,
171
+ ),
172
+ [
173
+ alignProp,
174
+ centeredProp,
175
+ effectProp,
176
+ responsive,
177
+ slidesPerGroupProp,
178
+ slidesPerViewProp,
179
+ spaceBetweenProp,
180
+ viewportWidth,
181
+ ],
182
+ );
183
+
184
+ const slidesPerView =
185
+ responsiveOptions.slidesPerView === "auto"
186
+ ? "auto"
187
+ : Number.isFinite(responsiveOptions.slidesPerView)
188
+ ? Math.max(1, responsiveOptions.slidesPerView)
189
+ : 1;
190
+ const slidesPerGroup = Number.isFinite(responsiveOptions.slidesPerGroup)
191
+ ? Math.max(1, Math.floor(responsiveOptions.slidesPerGroup))
192
+ : 1;
193
+ const spaceBetween = Number.isFinite(responsiveOptions.spaceBetween)
194
+ ? Math.max(0, responsiveOptions.spaceBetween)
195
+ : 0;
196
+ const transitionDuration = Number.isFinite(speed) ? Math.max(0, speed) : 500;
197
+ const resolvedSwipeThreshold = Number.isFinite(swipeThreshold)
198
+ ? Math.max(1, swipeThreshold)
199
+ : 30;
200
+ const align = responsiveOptions.centered ? "center" : responsiveOptions.align;
201
+ const effect = responsiveOptions.effect;
202
+ const effectMode = effect;
203
+ const canLoop = loop && count > 1 && effectMode === "slide";
204
+ const baseMaxIndex =
205
+ effectMode === "slide" ? getSnapMax(count, slidesPerView) : Math.max(0, count - 1);
206
+ const maxIndex = useMemo(() => {
207
+ if (
208
+ canLoop ||
209
+ effectMode !== "slide" ||
210
+ !hasMeasured ||
211
+ viewportWidth <= 0 ||
212
+ trackWidth <= viewportWidth ||
213
+ slideOffsets.length < count
214
+ ) {
215
+ return baseMaxIndex;
216
+ }
217
+
218
+ const maxOffset = Math.max(0, trackWidth - viewportWidth);
219
+ const firstFlushIndex = slideOffsets
220
+ .slice(0, count)
221
+ .findIndex((offset) => offset >= maxOffset - 1);
222
+
223
+ return firstFlushIndex >= 0
224
+ ? Math.min(baseMaxIndex, firstFlushIndex)
225
+ : baseMaxIndex;
226
+ }, [
227
+ baseMaxIndex,
228
+ canLoop,
229
+ count,
230
+ effectMode,
231
+ hasMeasured,
232
+ slideOffsets,
233
+ trackWidth,
234
+ viewportWidth,
235
+ ]);
236
+ const snapCount = canLoop ? count : maxIndex + 1;
237
+
238
+ // ─── Index + clone bookkeeping ──────────────────────────────────────────────
239
+
240
+ const isControlled = controlledIndex !== undefined;
241
+
242
+ const [internalIndex, setInternalIndex] = useState(() =>
243
+ normalizeIndex(defaultIndex, count, maxIndex, loop),
244
+ );
245
+ const logicalIndex = normalizeIndex(
246
+ isControlled ? controlledIndex : internalIndex,
247
+ count,
248
+ maxIndex,
249
+ loop,
250
+ );
251
+
252
+ useEffect(() => {
253
+ if (!isControlled) {
254
+ setInternalIndex((current) => normalizeIndex(current, count, maxIndex, loop));
255
+ }
256
+ }, [count, isControlled, loop, maxIndex]);
257
+
258
+ // Clone N slides on each side so dragging/grouping past the edge always
259
+ // has real DOM under the cursor. `+1` is a buffer for partial-view layouts;
260
+ // `slidesPerView=auto` is unknowable up-front so we clone the full set.
261
+ const cloneCount = canLoop
262
+ ? slidesPerView === "auto"
263
+ ? count
264
+ : Math.min(
265
+ count,
266
+ Math.max(Math.ceil(slidesPerView), slidesPerGroup) + 1,
267
+ )
268
+ : 0;
269
+
270
+ const renderedSlides = useMemo(() => {
271
+ if (effectMode !== "slide" || cloneCount === 0) return slides;
272
+ return [
273
+ ...slides.slice(-cloneCount),
274
+ ...slides,
275
+ ...slides.slice(0, cloneCount),
276
+ ];
277
+ }, [cloneCount, effectMode, slides]);
278
+
279
+ useEffect(() => {
280
+ slideRefs.current.length = renderedSlides.length;
281
+ }, [renderedSlides.length]);
282
+
283
+ // ─── Visual state + drag refs ───────────────────────────────────────────────
284
+ // `visualIndex` is the position inside `renderedSlides` (includes clones);
285
+ // `logicalIndex` is what the parent sees (0..count-1). They diverge during
286
+ // loop wraparound and re-sync on the next snap-back.
287
+
288
+ const [visualIndex, setVisualIndex] = useState(() => cloneCount + logicalIndex);
289
+ const [transitionOff, setTransitionOff] = useState(false);
290
+ const [isAnimating, setIsAnimating] = useState(false);
291
+ const [dragOffset, setDragOffset] = useState(0);
292
+ const [isDragging, setIsDragging] = useState(false);
293
+ const [isHovering, setIsHovering] = useState(false);
294
+ const [isFocusWithin, setIsFocusWithin] = useState(false);
295
+ const [autoplayStopped, setAutoplayStopped] = useState(false);
296
+ const wheelLockRef = useRef(false);
297
+ const layoutSignatureRef = useRef("");
298
+ const dragStartRef = useRef<{
299
+ x: number;
300
+ y: number;
301
+ visualIndex: number;
302
+ logicalIndex: number;
303
+ } | null>(null);
304
+ const verticalLockRef = useRef(false);
305
+ const layoutSignature = [
306
+ align,
307
+ cloneCount,
308
+ effectMode,
309
+ renderedSlides.length,
310
+ slidesPerGroup,
311
+ slidesPerView,
312
+ spaceBetween,
313
+ viewportWidth,
314
+ ].join("|");
315
+
316
+ // When layout shape changes (responsive breakpoint flip, count change,
317
+ // align swap), drop transitions for the snap-into-place, then re-enable.
318
+ useLayoutEffect(() => {
319
+ if (layoutSignatureRef.current === layoutSignature) return;
320
+ layoutSignatureRef.current = layoutSignature;
321
+ setIsMotionReady(false);
322
+ setVisualIndex(cloneCount + logicalIndex);
323
+ measure();
324
+ }, [
325
+ cloneCount,
326
+ layoutSignature,
327
+ logicalIndex,
328
+ measure,
329
+ ]);
330
+
331
+ // Two rAFs: frame 1 commits the no-transition translate; frame 2 re-enables
332
+ // the transition so the NEXT user action animates from the settled state.
333
+ useEffect(() => {
334
+ if (!hasMeasured) return;
335
+ let secondFrame = 0;
336
+ const firstFrame = requestAnimationFrame(() => {
337
+ secondFrame = requestAnimationFrame(() => {
338
+ setIsMotionReady(true);
339
+ });
340
+ });
341
+
342
+ return () => {
343
+ cancelAnimationFrame(firstFrame);
344
+ cancelAnimationFrame(secondFrame);
345
+ };
346
+ }, [
347
+ hasMeasured,
348
+ layoutSignature,
349
+ ]);
350
+
351
+ // ─── Geometry ───────────────────────────────────────────────────────────────
352
+
353
+ const averageStride = useMemo(() => {
354
+ if (slideOffsets.length > 1) {
355
+ const diffs = slideOffsets
356
+ .slice(1)
357
+ .map((offset, index) => offset - slideOffsets[index])
358
+ .filter((value) => value > 0);
359
+ if (diffs.length > 0) {
360
+ return diffs.reduce((sum, value) => sum + value, 0) / diffs.length;
361
+ }
362
+ }
363
+
364
+ if (slidesPerView !== "auto" && viewportWidth > 0) {
365
+ return Math.max(
366
+ 1,
367
+ (viewportWidth - (slidesPerView - 1) * spaceBetween) / slidesPerView +
368
+ spaceBetween,
369
+ );
370
+ }
371
+
372
+ return 1;
373
+ }, [slideOffsets, slidesPerView, spaceBetween, viewportWidth]);
374
+
375
+ const alignmentOffset = useMemo(() => {
376
+ if (effectMode !== "slide" || viewportWidth <= 0) return 0;
377
+ const width = slideSizes[visualIndex] ?? averageStride - spaceBetween;
378
+ if (align === "center") return Math.max(0, (viewportWidth - width) / 2);
379
+ if (align === "end") return Math.max(0, viewportWidth - width);
380
+ return 0;
381
+ }, [align, averageStride, effectMode, slideSizes, spaceBetween, viewportWidth, visualIndex]);
382
+
383
+ const baseOffset = effectMode === "slide" ? slideOffsets[visualIndex] ?? visualIndex * averageStride : 0;
384
+ const maxScroll = Math.max(0, trackWidth - viewportWidth);
385
+ const targetOffset =
386
+ effectMode === "slide" && !canLoop
387
+ ? clamp(baseOffset - alignmentOffset, 0, maxScroll)
388
+ : baseOffset - alignmentOffset;
389
+ const translate = -targetOffset + dragOffset;
390
+ const progress = snapCount <= 1 ? 1 : clamp(logicalIndex / (snapCount - 1), 0, 1);
391
+ const canNavigateLoop = loop && count > 1;
392
+ const isFirst = !canNavigateLoop && logicalIndex <= 0;
393
+ const isLast = !canNavigateLoop && logicalIndex >= maxIndex;
394
+ const autoplayOptions = useMemo<Required<SliderAutoplayOptions> | null>(
395
+ () => {
396
+ if (!autoplay) return null;
397
+
398
+ const options = typeof autoplay === "object" ? autoplay : {};
399
+ return {
400
+ delay:
401
+ typeof options.delay === "number" && Number.isFinite(options.delay)
402
+ ? Math.max(0, options.delay)
403
+ : 4000,
404
+ pauseOnHover: options.pauseOnHover ?? true,
405
+ pauseOnFocus: options.pauseOnFocus ?? true,
406
+ stopOnInteraction: options.stopOnInteraction ?? false,
407
+ reverseDirection: options.reverseDirection ?? false,
408
+ };
409
+ },
410
+ [autoplay],
411
+ );
412
+ const isAutoplayPaused =
413
+ Boolean(autoplayOptions) &&
414
+ ((autoplayOptions?.pauseOnHover && pauseOnHover && isHovering) ||
415
+ (autoplayOptions?.pauseOnFocus && isFocusWithin) ||
416
+ isDragging);
417
+
418
+ // ─── Imperative actions ─────────────────────────────────────────────────────
419
+
420
+ // Single setter for index + visual cursor. Skipping the change callbacks when
421
+ // the index doesn't actually move avoids spamming controlled parents during
422
+ // settle / clone snap-back.
423
+ const commitIndex = useCallback(
424
+ (nextIndex: number, nextVisualIndex?: number, animated = true) => {
425
+ if (count === 0) return;
426
+ const normalized = normalizeIndex(nextIndex, count, maxIndex, loop);
427
+ if (!isControlled) setInternalIndex(normalized);
428
+ if (normalized !== logicalIndex) {
429
+ onIndexChange?.(normalized);
430
+ onSlideChange?.(normalized);
431
+ }
432
+ setIsAnimating(animated && transitionDuration > 0 && effectMode === "slide");
433
+ setVisualIndex(nextVisualIndex ?? cloneCount + normalized);
434
+ },
435
+ [
436
+ cloneCount,
437
+ count,
438
+ effectMode,
439
+ isControlled,
440
+ logicalIndex,
441
+ loop,
442
+ maxIndex,
443
+ onIndexChange,
444
+ onSlideChange,
445
+ transitionDuration,
446
+ ],
447
+ );
448
+
449
+ const stopAutoplay = useCallback(() => {
450
+ setAutoplayStopped(true);
451
+ }, []);
452
+
453
+ const markInteraction = useCallback(() => {
454
+ if (autoplayOptions?.stopOnInteraction) {
455
+ stopAutoplay();
456
+ }
457
+ }, [autoplayOptions, stopAutoplay]);
458
+
459
+ const resolveStep = useCallback(
460
+ (steps: number | undefined) => {
461
+ const step = typeof steps === "number" && Number.isFinite(steps)
462
+ ? Math.floor(steps)
463
+ : slidesPerGroup;
464
+ const maxStep = canLoop && cloneCount > 0
465
+ ? cloneCount
466
+ : Number.MAX_SAFE_INTEGER;
467
+ return Math.min(Math.max(1, step), maxStep);
468
+ },
469
+ [canLoop, cloneCount, slidesPerGroup],
470
+ );
471
+
472
+ const next = useCallback(
473
+ (steps = slidesPerGroup) => {
474
+ if (count === 0) return;
475
+ markInteraction();
476
+ const step = resolveStep(steps);
477
+ if (!loop && isLast) {
478
+ if (rewind) commitIndex(0);
479
+ return;
480
+ }
481
+ commitIndex(logicalIndex + step, canLoop ? visualIndex + step : undefined);
482
+ },
483
+ [
484
+ canLoop,
485
+ commitIndex,
486
+ count,
487
+ isLast,
488
+ logicalIndex,
489
+ loop,
490
+ markInteraction,
491
+ rewind,
492
+ resolveStep,
493
+ slidesPerGroup,
494
+ visualIndex,
495
+ ],
496
+ );
497
+
498
+ const prev = useCallback(
499
+ (steps = slidesPerGroup) => {
500
+ if (count === 0) return;
501
+ markInteraction();
502
+ const step = resolveStep(steps);
503
+ if (!loop && isFirst) {
504
+ if (rewind) commitIndex(maxIndex);
505
+ return;
506
+ }
507
+ commitIndex(logicalIndex - step, canLoop ? visualIndex - step : undefined);
508
+ },
509
+ [
510
+ canLoop,
511
+ commitIndex,
512
+ count,
513
+ isFirst,
514
+ logicalIndex,
515
+ loop,
516
+ markInteraction,
517
+ maxIndex,
518
+ rewind,
519
+ resolveStep,
520
+ slidesPerGroup,
521
+ visualIndex,
522
+ ],
523
+ );
524
+
525
+ const goTo = useCallback(
526
+ (index: number) => {
527
+ if (count === 0) return;
528
+ markInteraction();
529
+ const normalized = normalizeIndex(index, count, maxIndex, loop);
530
+ // In loop mode, prefer the visual candidate closest to the current
531
+ // position so the track takes the short path instead of unwinding
532
+ // across the whole strip on a clicked dot.
533
+ const forwardDistance = wrap(normalized - logicalIndex, count);
534
+ const backwardDistance = wrap(logicalIndex - normalized, count);
535
+ let nextVisual = cloneCount + normalized;
536
+
537
+ if (canLoop) {
538
+ const maxRenderedIndex = renderedSlides.length - 1;
539
+ const candidates = [
540
+ visualIndex + forwardDistance,
541
+ visualIndex - backwardDistance,
542
+ nextVisual,
543
+ ].filter((candidate) => candidate >= 0 && candidate <= maxRenderedIndex);
544
+
545
+ nextVisual = candidates.reduce((best, candidate) =>
546
+ Math.abs(candidate - visualIndex) < Math.abs(best - visualIndex)
547
+ ? candidate
548
+ : best,
549
+ );
550
+ }
551
+
552
+ commitIndex(normalized, nextVisual);
553
+ },
554
+ [
555
+ canLoop,
556
+ cloneCount,
557
+ commitIndex,
558
+ count,
559
+ logicalIndex,
560
+ loop,
561
+ markInteraction,
562
+ maxIndex,
563
+ renderedSlides.length,
564
+ visualIndex,
565
+ ],
566
+ );
567
+
568
+ useEffect(() => {
569
+ if (isDragging || isAnimating) return;
570
+ setVisualIndex(cloneCount + logicalIndex);
571
+ }, [cloneCount, isAnimating, isDragging, logicalIndex]);
572
+
573
+ // ─── Loop snap + autoplay ───────────────────────────────────────────────────
574
+
575
+ // After a transition lands inside clone territory, jump (transition: none)
576
+ // to the equivalent real slot so wrap-around feels seamless.
577
+ const snapVisualIndex = useCallback(() => {
578
+ if (!canLoop || count === 0) return;
579
+ if (visualIndex < cloneCount) {
580
+ setTransitionOff(true);
581
+ setVisualIndex((current) => current + count);
582
+ } else if (visualIndex >= cloneCount + count) {
583
+ setTransitionOff(true);
584
+ setVisualIndex((current) => current - count);
585
+ }
586
+ }, [canLoop, cloneCount, count, visualIndex]);
587
+
588
+ const handleTrackTransitionEnd = useCallback((event: ReactTransitionEvent<HTMLDivElement>) => {
589
+ if (event.target !== event.currentTarget || event.propertyName !== "transform") {
590
+ return;
591
+ }
592
+ setIsAnimating(false);
593
+ snapVisualIndex();
594
+ measure();
595
+ }, [measure, snapVisualIndex]);
596
+
597
+ useEffect(() => {
598
+ if (!transitionOff) return;
599
+ let secondFrame = 0;
600
+ const firstFrame = requestAnimationFrame(() => {
601
+ secondFrame = requestAnimationFrame(() => setTransitionOff(false));
602
+ });
603
+ return () => {
604
+ cancelAnimationFrame(firstFrame);
605
+ cancelAnimationFrame(secondFrame);
606
+ };
607
+ }, [transitionOff]);
608
+
609
+ useEffect(() => {
610
+ if (!autoplayOptions || autoplayStopped || count <= 1) return;
611
+
612
+ if (autoplayOptions.pauseOnHover && pauseOnHover && isHovering) return;
613
+ if (autoplayOptions.pauseOnFocus && isFocusWithin) return;
614
+ if (isDragging || isAnimating) return;
615
+
616
+ const id = window.setTimeout(() => {
617
+ if (autoplayOptions.reverseDirection) prev();
618
+ else next();
619
+ }, autoplayOptions.delay);
620
+
621
+ return () => window.clearTimeout(id);
622
+ }, [
623
+ autoplayOptions,
624
+ autoplayStopped,
625
+ count,
626
+ isAnimating,
627
+ isDragging,
628
+ isFocusWithin,
629
+ isHovering,
630
+ logicalIndex,
631
+ next,
632
+ pauseOnHover,
633
+ prev,
634
+ ]);
635
+
636
+ // ─── Pointer / keyboard / wheel ─────────────────────────────────────────────
637
+
638
+ const handlePointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
639
+ if (
640
+ !draggable ||
641
+ count <= 1 ||
642
+ averageStride <= 0 ||
643
+ shouldIgnoreDrag(event.target, dragIgnoreSelector)
644
+ ) {
645
+ return;
646
+ }
647
+ if (event.pointerType === "mouse" && event.button !== 0) return;
648
+
649
+ markInteraction();
650
+ dragStartRef.current = {
651
+ x: event.clientX,
652
+ y: event.clientY,
653
+ visualIndex,
654
+ logicalIndex,
655
+ };
656
+ verticalLockRef.current = false;
657
+ setIsDragging(true);
658
+ setIsAnimating(false);
659
+ event.currentTarget.setPointerCapture?.(event.pointerId);
660
+ };
661
+
662
+ const handlePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
663
+ const start = dragStartRef.current;
664
+ if (!start) return;
665
+
666
+ const dx = event.clientX - start.x;
667
+ const dy = event.clientY - start.y;
668
+
669
+ if (event.pointerType !== "mouse" && !verticalLockRef.current) {
670
+ if (Math.abs(dy) > Math.abs(dx) && Math.abs(dy) > 8) {
671
+ verticalLockRef.current = true;
672
+ dragStartRef.current = null;
673
+ setIsDragging(false);
674
+ setDragOffset(0);
675
+ event.currentTarget.releasePointerCapture?.(event.pointerId);
676
+ return;
677
+ }
678
+ }
679
+
680
+ // Rubber-band at non-loop edges: dampen drag so users feel the bound
681
+ // without breaking the snap-back on release.
682
+ const shouldRubberBand =
683
+ !loop && ((isFirst && dx > 0) || (isLast && dx < 0));
684
+ setDragOffset(shouldRubberBand ? dx * 0.35 : dx);
685
+ };
686
+
687
+ const finishDrag = (clientX: number) => {
688
+ const start = dragStartRef.current;
689
+ if (!start) return;
690
+
691
+ const dx = clientX - start.x;
692
+ dragStartRef.current = null;
693
+ setIsDragging(false);
694
+ setDragOffset(0);
695
+
696
+ if (Math.abs(dx) < resolvedSwipeThreshold) return;
697
+
698
+ const steps = Math.max(1, Math.round(Math.abs(dx) / Math.max(1, averageStride)));
699
+ if (dx < 0) next(steps);
700
+ else prev(steps);
701
+ };
702
+
703
+ const handlePointerUp = (event: ReactPointerEvent<HTMLDivElement>) => {
704
+ finishDrag(event.clientX);
705
+ event.currentTarget.releasePointerCapture?.(event.pointerId);
706
+ };
707
+
708
+ const handlePointerCancel = (event: ReactPointerEvent<HTMLDivElement>) => {
709
+ dragStartRef.current = null;
710
+ setIsDragging(false);
711
+ setDragOffset(0);
712
+ event.currentTarget.releasePointerCapture?.(event.pointerId);
713
+ };
714
+
715
+ const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
716
+ onKeyDown?.(event);
717
+ if (event.defaultPrevented || !keyboard) return;
718
+ if (event.key === "ArrowRight") {
719
+ event.preventDefault();
720
+ next();
721
+ } else if (event.key === "ArrowLeft") {
722
+ event.preventDefault();
723
+ prev();
724
+ } else if (event.key === "Home") {
725
+ event.preventDefault();
726
+ goTo(0);
727
+ } else if (event.key === "End") {
728
+ event.preventDefault();
729
+ goTo(maxIndex);
730
+ }
731
+ };
732
+
733
+ const handleWheel = (event: ReactWheelEvent<HTMLDivElement>) => {
734
+ onWheel?.(event);
735
+ if (event.defaultPrevented || !mousewheel || wheelLockRef.current) return;
736
+ const delta = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY;
737
+ if (Math.abs(delta) < 20) return;
738
+
739
+ wheelLockRef.current = true;
740
+ window.setTimeout(() => {
741
+ wheelLockRef.current = false;
742
+ }, Math.max(250, transitionDuration));
743
+
744
+ if (delta > 0) next();
745
+ else prev();
746
+ };
747
+
748
+ // ─── Context + render ───────────────────────────────────────────────────────
749
+
750
+ const ctxValue: SliderApi = useMemo(
751
+ () => ({
752
+ index: logicalIndex,
753
+ activeIndex: logicalIndex,
754
+ count,
755
+ snapCount,
756
+ slidesPerView,
757
+ slidesPerGroup,
758
+ progress,
759
+ isLoop: loop,
760
+ isDragging,
761
+ isAutoplayPaused,
762
+ isFirst,
763
+ isLast,
764
+ canSlidePrev: !isFirst,
765
+ canSlideNext: !isLast,
766
+ next,
767
+ prev,
768
+ goTo,
769
+ slideTo: goTo,
770
+ stopAutoplay,
771
+ }),
772
+ [
773
+ count,
774
+ goTo,
775
+ isAutoplayPaused,
776
+ isDragging,
777
+ isFirst,
778
+ isLast,
779
+ logicalIndex,
780
+ loop,
781
+ next,
782
+ prev,
783
+ progress,
784
+ slidesPerGroup,
785
+ slidesPerView,
786
+ snapCount,
787
+ stopAutoplay,
788
+ ],
789
+ );
790
+
791
+ useImperativeHandle(
792
+ ref,
793
+ () => ({
794
+ next,
795
+ prev,
796
+ goTo,
797
+ slideTo: goTo,
798
+ stopAutoplay,
799
+ index: logicalIndex,
800
+ count,
801
+ snapCount,
802
+ }),
803
+ [count, goTo, logicalIndex, next, prev, snapCount, stopAutoplay],
804
+ );
805
+
806
+ const trackStyle: CSSProperties =
807
+ effectMode === "slide"
808
+ ? {
809
+ gap: `${spaceBetween}px`,
810
+ transform: `translate3d(${translate}px, 0, 0)`,
811
+ transition:
812
+ !isMotionReady || transitionOff || isDragging
813
+ ? "none"
814
+ : `transform ${transitionDuration}ms ${easing}`,
815
+ }
816
+ : {
817
+ transition: transitionOff ? "none" : `height ${transitionDuration}ms ${easing}`,
818
+ };
819
+
820
+ const slideStyle: CSSProperties =
821
+ slidesPerView === "auto"
822
+ ? { flex: "0 0 auto" }
823
+ : effectMode === "slide"
824
+ ? {
825
+ flex: "0 0 auto",
826
+ width:
827
+ viewportWidth > 0
828
+ ? `${Math.max(0, (viewportWidth - (slidesPerView - 1) * spaceBetween) / slidesPerView)}px`
829
+ : `max(0px, calc((100% - ${(slidesPerView - 1) * spaceBetween}px) / ${slidesPerView}))`,
830
+ }
831
+ : {};
832
+
833
+ const viewportStyle: CSSProperties | undefined = autoHeight && activeHeight
834
+ ? { height: activeHeight }
835
+ : undefined;
836
+
837
+ const navOpts: SliderNavigationOptions =
838
+ typeof navigation === "object" && navigation !== null ? navigation : {};
839
+ const paginationOpts: SliderPaginationOptions =
840
+ typeof pagination === "object" && pagination !== null ? pagination : {};
841
+
842
+ return (
843
+ <SliderContext.Provider value={ctxValue}>
844
+ <div
845
+ className={clsx(
846
+ s.container,
847
+ effectMode !== "slide" && s.container_effect,
848
+ className,
849
+ )}
850
+ style={style}
851
+ role="region"
852
+ aria-roledescription="carousel"
853
+ aria-label={ariaLabel}
854
+ tabIndex={keyboard ? 0 : undefined}
855
+ onMouseEnter={(event) => {
856
+ onMouseEnter?.(event);
857
+ setIsHovering(true);
858
+ }}
859
+ onMouseLeave={(event) => {
860
+ onMouseLeave?.(event);
861
+ setIsHovering(false);
862
+ }}
863
+ onFocus={(event) => {
864
+ onFocus?.(event);
865
+ setIsFocusWithin(true);
866
+ }}
867
+ onBlur={(event) => {
868
+ onBlur?.(event);
869
+ if (!event.currentTarget.contains(event.relatedTarget)) {
870
+ setIsFocusWithin(false);
871
+ }
872
+ }}
873
+ onKeyDown={handleKeyDown}
874
+ onWheel={handleWheel}
875
+ {...rest}
876
+ >
877
+ <div
878
+ ref={viewportRef}
879
+ className={s.viewport}
880
+ style={{
881
+ ...viewportStyle,
882
+ visibility: hasMeasured ? undefined : "hidden",
883
+ cursor: draggable ? (isDragging ? "grabbing" : "grab") : undefined,
884
+ touchAction: draggable ? "pan-y" : undefined,
885
+ }}
886
+ onPointerDown={handlePointerDown}
887
+ onPointerMove={handlePointerMove}
888
+ onPointerUp={handlePointerUp}
889
+ onPointerCancel={handlePointerCancel}
890
+ onDragStart={(event) => event.preventDefault()}
891
+ >
892
+ <div
893
+ ref={trackRef}
894
+ className={clsx(
895
+ s.track,
896
+ effectMode === "fade" && s.track_fade,
897
+ effectMode === "scale" && s.track_scale,
898
+ )}
899
+ style={trackStyle}
900
+ onTransitionEnd={handleTrackTransitionEnd}
901
+ >
902
+ {renderedSlides.map((child, renderedIndex) => {
903
+ const realIndex = canLoop
904
+ ? wrap(renderedIndex - cloneCount, count)
905
+ : renderedIndex;
906
+ const isClone =
907
+ canLoop && (renderedIndex < cloneCount || renderedIndex >= cloneCount + count);
908
+ const visibleCount =
909
+ slidesPerView === "auto" ? 1 : Math.ceil(slidesPerView);
910
+ const isActive =
911
+ realIndex === logicalIndex &&
912
+ (effectMode === "slide" ? renderedIndex === visualIndex : !isClone);
913
+ const isVisible = effectMode !== "slide"
914
+ ? realIndex === logicalIndex
915
+ : renderedIndex >= visualIndex &&
916
+ renderedIndex < visualIndex + visibleCount;
917
+ const state: SliderSlideState = {
918
+ index: realIndex,
919
+ renderedIndex,
920
+ isActive,
921
+ isVisible,
922
+ isClone,
923
+ };
924
+
925
+ return (
926
+ <div
927
+ key={
928
+ isValidElement(child) && child.key != null
929
+ ? `${String(child.key)}-${renderedIndex}`
930
+ : `${realIndex}-${renderedIndex}`
931
+ }
932
+ ref={(node) => {
933
+ slideRefs.current[renderedIndex] = node;
934
+ }}
935
+ id={!isClone ? `${uid}-slide-${realIndex}` : undefined}
936
+ className={clsx(
937
+ s.slide,
938
+ effectMode !== "slide" && s.slide_overlay,
939
+ isActive && s.slide_active,
940
+ isVisible && s.slide_visible,
941
+ )}
942
+ style={slideStyle}
943
+ aria-hidden={isClone || (!isVisible && effectMode !== "slide") || undefined}
944
+ data-active={isActive ? "true" : undefined}
945
+ data-visible={isVisible ? "true" : undefined}
946
+ data-clone={isClone ? "true" : undefined}
947
+ >
948
+ {renderSlide ? renderSlide(child, state) : child}
949
+ </div>
950
+ );
951
+ })}
952
+ </div>
953
+
954
+ {navigation ? (
955
+ <>
956
+ <SliderNavButton
957
+ direction="prev"
958
+ icon={navOpts.prevIcon}
959
+ ariaLabel={navOpts.ariaPrev ?? "Previous slide"}
960
+ className={navOpts.className}
961
+ />
962
+ <SliderNavButton
963
+ direction="next"
964
+ icon={navOpts.nextIcon}
965
+ ariaLabel={navOpts.ariaNext ?? "Next slide"}
966
+ className={navOpts.className}
967
+ />
968
+ </>
969
+ ) : null}
970
+ </div>
971
+
972
+ {pagination ? (
973
+ <SliderPaginationView
974
+ type={paginationOpts.type ?? "dots"}
975
+ clickable={paginationOpts.clickable ?? true}
976
+ items={paginationOpts.items ?? "snaps"}
977
+ className={paginationOpts.className}
978
+ />
979
+ ) : null}
980
+
981
+ {renderControls?.(ctxValue)}
982
+ </div>
983
+ </SliderContext.Provider>
984
+ );
985
+ };
986
+
987
+ // ─── Built-in nav + pagination ────────────────────────────────────────────────
988
+
989
+ type SliderNavButtonProps = {
990
+ direction: "prev" | "next";
991
+ icon?: ReactNode;
992
+ ariaLabel: string;
993
+ className?: string;
994
+ };
995
+
996
+ const SliderNavButton = ({
997
+ direction,
998
+ icon,
999
+ ariaLabel,
1000
+ className,
1001
+ }: SliderNavButtonProps) => {
1002
+ const { next, prev, isFirst, isLast } = useSlider();
1003
+ const disabled = direction === "prev" ? isFirst : isLast;
1004
+ const onClick = direction === "prev" ? prev : next;
1005
+ const defaultIcon =
1006
+ direction === "prev" ? <IconChevronLeft /> : <IconChevronRight />;
1007
+
1008
+ return (
1009
+ <button
1010
+ type="button"
1011
+ className={clsx(s.nav, direction === "prev" ? s.nav_prev : s.nav_next, className)}
1012
+ onClick={() => onClick()}
1013
+ disabled={disabled}
1014
+ aria-label={ariaLabel}
1015
+ data-slider-no-drag
1016
+ >
1017
+ {icon ?? defaultIcon}
1018
+ </button>
1019
+ );
1020
+ };
1021
+
1022
+ type SliderPaginationViewProps = {
1023
+ type: SliderPaginationType;
1024
+ clickable: boolean;
1025
+ items: "snaps" | "slides";
1026
+ className?: string;
1027
+ };
1028
+
1029
+ const SliderPaginationView = ({
1030
+ type,
1031
+ clickable,
1032
+ items,
1033
+ className,
1034
+ }: SliderPaginationViewProps) => {
1035
+ const { index, count, snapCount, progress, goTo } = useSlider();
1036
+ const total = items === "slides" ? count : snapCount;
1037
+ if (total <= 0) return null;
1038
+
1039
+ if (type === "fraction") {
1040
+ return (
1041
+ <div className={clsx(s.fraction, className)} aria-live="polite">
1042
+ <span>{Math.min(index + 1, total)}</span>
1043
+ <span className={s.fraction_sep}>/</span>
1044
+ <span>{total}</span>
1045
+ </div>
1046
+ );
1047
+ }
1048
+
1049
+ if (type === "progress") {
1050
+ return (
1051
+ <div
1052
+ className={clsx(s.progress, className)}
1053
+ role="progressbar"
1054
+ aria-valuemin={0}
1055
+ aria-valuemax={100}
1056
+ aria-valuenow={Math.round(progress * 100)}
1057
+ >
1058
+ <div className={s.progress_bar} style={{ width: `${progress * 100}%` }} />
1059
+ </div>
1060
+ );
1061
+ }
1062
+
1063
+ return (
1064
+ <div className={clsx(s.dots, className)} role="tablist" aria-label="Slider pagination">
1065
+ {Array.from({ length: total }).map((_, dotIndex) => {
1066
+ const isActive = dotIndex === Math.min(index, total - 1);
1067
+ return (
1068
+ <button
1069
+ key={dotIndex}
1070
+ type="button"
1071
+ role="tab"
1072
+ aria-selected={isActive}
1073
+ aria-label={`Go to slide ${dotIndex + 1}`}
1074
+ tabIndex={clickable ? 0 : -1}
1075
+ disabled={!clickable}
1076
+ className={clsx(s.dot, isActive && s.dot_active)}
1077
+ onClick={clickable ? () => goTo(dotIndex) : undefined}
1078
+ data-slider-no-drag
1079
+ />
1080
+ );
1081
+ })}
1082
+ </div>
1083
+ );
1084
+ };
1085
+
1086
+ // ─── Sub-components for custom layouts ────────────────────────────────────────
1087
+ // Drop these inside `renderControls` (or any descendant of <Slider>) for full
1088
+ // composition. Each one consumes the same `useSlider()` context as the built-in
1089
+ // UI, so disabled/active states stay in sync.
1090
+
1091
+ export const SliderPrev = ({
1092
+ className,
1093
+ children,
1094
+ ariaLabel = "Previous slide",
1095
+ }: {
1096
+ className?: string;
1097
+ children?: ReactNode;
1098
+ ariaLabel?: string;
1099
+ }) => {
1100
+ const { prev, isFirst } = useSlider();
1101
+ return (
1102
+ <button
1103
+ type="button"
1104
+ className={clsx(s.custom_btn, className)}
1105
+ onClick={() => prev()}
1106
+ disabled={isFirst}
1107
+ aria-label={ariaLabel}
1108
+ data-slider-no-drag
1109
+ >
1110
+ {children ?? <IconChevronLeft />}
1111
+ </button>
1112
+ );
1113
+ };
1114
+
1115
+ export const SliderNext = ({
1116
+ className,
1117
+ children,
1118
+ ariaLabel = "Next slide",
1119
+ }: {
1120
+ className?: string;
1121
+ children?: ReactNode;
1122
+ ariaLabel?: string;
1123
+ }) => {
1124
+ const { next, isLast } = useSlider();
1125
+ return (
1126
+ <button
1127
+ type="button"
1128
+ className={clsx(s.custom_btn, className)}
1129
+ onClick={() => next()}
1130
+ disabled={isLast}
1131
+ aria-label={ariaLabel}
1132
+ data-slider-no-drag
1133
+ >
1134
+ {children ?? <IconChevronRight />}
1135
+ </button>
1136
+ );
1137
+ };
1138
+
1139
+ export const SliderDots = ({
1140
+ className,
1141
+ clickable = true,
1142
+ items = "snaps",
1143
+ }: {
1144
+ className?: string;
1145
+ clickable?: boolean;
1146
+ items?: "snaps" | "slides";
1147
+ }) => (
1148
+ <SliderPaginationView
1149
+ type="dots"
1150
+ clickable={clickable}
1151
+ items={items}
1152
+ className={clsx(s.dots_static, className)}
1153
+ />
1154
+ );
1155
+
1156
+ export const SliderFraction = ({ className }: { className?: string }) => (
1157
+ <SliderPaginationView
1158
+ type="fraction"
1159
+ clickable={false}
1160
+ items="snaps"
1161
+ className={className}
1162
+ />
1163
+ );
1164
+
1165
+ export const SliderProgress = ({ className }: { className?: string }) => (
1166
+ <SliderPaginationView
1167
+ type="progress"
1168
+ clickable={false}
1169
+ items="snaps"
1170
+ className={className}
1171
+ />
1172
+ );
1173
+
1174
+ export { useSlider };