@haventmet/carousel 1.0.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.
Files changed (67) hide show
  1. package/.mcp.json +8 -0
  2. package/CLAUDE.md +177 -0
  3. package/LICENSE +21 -0
  4. package/README.md +76 -0
  5. package/biome.json +174 -0
  6. package/package.json +33 -0
  7. package/packages/alpine/index.html +50 -0
  8. package/packages/alpine/package.json +70 -0
  9. package/packages/alpine/src/index.ts +46 -0
  10. package/packages/alpine/style.css +76 -0
  11. package/packages/alpine/tsconfig.json +28 -0
  12. package/packages/alpine/vite.config.ts +28 -0
  13. package/packages/core/README.md +69 -0
  14. package/packages/core/package.json +45 -0
  15. package/packages/core/src/carousel.ts +842 -0
  16. package/packages/core/src/index.ts +2 -0
  17. package/packages/core/src/style.css +46 -0
  18. package/packages/core/vite.config.js +14 -0
  19. package/packages/react/.vite/deps/_metadata.json +25 -0
  20. package/packages/react/.vite/deps/chunk-7CQ73C3Q.js +1908 -0
  21. package/packages/react/.vite/deps/chunk-7CQ73C3Q.js.map +7 -0
  22. package/packages/react/.vite/deps/package.json +3 -0
  23. package/packages/react/.vite/deps/react-dom_client.js +21712 -0
  24. package/packages/react/.vite/deps/react-dom_client.js.map +7 -0
  25. package/packages/react/.vite/deps/react.js +4 -0
  26. package/packages/react/.vite/deps/react.js.map +7 -0
  27. package/packages/react/README.md +75 -0
  28. package/packages/react/index.html +12 -0
  29. package/packages/react/package.json +74 -0
  30. package/packages/react/src/App.tsx +38 -0
  31. package/packages/react/src/BlossomCarousel.tsx +77 -0
  32. package/packages/react/src/index.ts +2 -0
  33. package/packages/react/src/main.jsx +6 -0
  34. package/packages/react/src/style.css +50 -0
  35. package/packages/react/tsconfig.json +17 -0
  36. package/packages/react/vite.config.js +27 -0
  37. package/packages/svelte/README.md +58 -0
  38. package/packages/svelte/index.html +12 -0
  39. package/packages/svelte/package.json +71 -0
  40. package/packages/svelte/src/App.svelte +12 -0
  41. package/packages/svelte/src/BlossomCarousel.svelte +39 -0
  42. package/packages/svelte/src/BlossomCarousel.svelte.d.ts +8 -0
  43. package/packages/svelte/src/index.ts +2 -0
  44. package/packages/svelte/src/main.js +10 -0
  45. package/packages/svelte/src/style.css +52 -0
  46. package/packages/svelte/tsconfig.json +16 -0
  47. package/packages/svelte/vite.config.js +45 -0
  48. package/packages/vue/README.md +70 -0
  49. package/packages/vue/index.html +12 -0
  50. package/packages/vue/package.json +69 -0
  51. package/packages/vue/src/App.vue +82 -0
  52. package/packages/vue/src/BlossomCarousel.vue +52 -0
  53. package/packages/vue/src/index.ts +2 -0
  54. package/packages/vue/src/main.js +5 -0
  55. package/packages/vue/src/style.css +18 -0
  56. package/packages/vue/vite.config.js +25 -0
  57. package/packages/web/README.md +44 -0
  58. package/packages/web/index.html +29 -0
  59. package/packages/web/package.json +65 -0
  60. package/packages/web/src/index.ts +27 -0
  61. package/packages/web/src/style.css +53 -0
  62. package/packages/web/style.css +54 -0
  63. package/packages/web/tsconfig.json +18 -0
  64. package/packages/web/vite.config.ts +23 -0
  65. package/pnpm-workspace.yaml +8 -0
  66. package/public/cover.jpg +0 -0
  67. package/turbo.json +12 -0
@@ -0,0 +1,842 @@
1
+ interface Point {
2
+ x: number
3
+ y: number
4
+ }
5
+
6
+ interface HasOverflow {
7
+ x: boolean
8
+ y: boolean
9
+ }
10
+
11
+ export interface CarouselOptions {
12
+ repeat?: boolean
13
+ }
14
+
15
+ interface CurrentIndex {
16
+ value: number
17
+ onChange: ((index: number) => void) | null
18
+ }
19
+
20
+ export const Blossom = (scroller: HTMLElement, options: CarouselOptions) => {
21
+ let snap = <boolean>true
22
+ const pointerStart: Point = { x: 0, y: 0 }
23
+ const target: Point = { x: 0, y: 0 }
24
+ const velocity: Point = { x: 0, y: 0 }
25
+ const distanceMovedSincePointerDown: Point = new Proxy(
26
+ { x: 0, y: 0 },
27
+ {
28
+ set(target, prop: keyof Point, value) {
29
+ if (!nativeScroll) {
30
+ const old = target[prop]
31
+ if (old === value) return true
32
+
33
+ target[prop] = value
34
+
35
+ if (target.x >= 10 || target.y >= 10) {
36
+ setIsTicking(true)
37
+ }
38
+ }
39
+
40
+ return true
41
+ },
42
+ },
43
+ )
44
+
45
+ const hasOverflow: HasOverflow = new Proxy(
46
+ { x: false, y: false },
47
+ {
48
+ set(target, prop: keyof HasOverflow, value) {
49
+ const old = target[prop]
50
+ if (old === value) return true
51
+
52
+ target[prop] = value
53
+
54
+ if (target.x || target.y) {
55
+ scroller.setAttribute('has-overflow', 'true')
56
+ scroller.addEventListener('touchstart', onPointerDown, { passive: false })
57
+ scroller.addEventListener('pointerdown', onPointerDown, { passive: false })
58
+ scroller.addEventListener('wheel', onWheel, { passive: false })
59
+ } else {
60
+ scroller.removeAttribute('has-overflow')
61
+ scroller.removeEventListener('touchstart', onPointerDown)
62
+ scroller.removeEventListener('pointerdown', onPointerDown)
63
+ scroller.removeEventListener('wheel', onWheel)
64
+ }
65
+
66
+ return true
67
+ },
68
+ },
69
+ )
70
+
71
+ const currentIndex: CurrentIndex = new Proxy(
72
+ { value: 0, onChange: null },
73
+ {
74
+ set(target, prop: keyof CurrentIndex, value: any) {
75
+ const old = target[prop]
76
+ if (old === value) return true
77
+
78
+ ;(target as any)[prop] = value
79
+
80
+ // Dispatch custom event when value changes
81
+ if (prop === 'value') {
82
+ const event = new CustomEvent('change', {
83
+ bubbles: true,
84
+ detail: { index: value },
85
+ })
86
+ scroller?.dispatchEvent(event)
87
+ }
88
+
89
+ return true
90
+ },
91
+ },
92
+ )
93
+
94
+ let end = 300
95
+ let raf: number | null = null
96
+ let isDragging = false
97
+ let scrollerScrollWidth = 300
98
+ let scrollerWidth = 300
99
+ let scrollerScrollHeight = 300
100
+ let scrollerHeight = 300
101
+ const securityMargin = 0
102
+ const padding = { start: 0, end: 0 }
103
+ const scrollPadding = { start: 0, end: 0 }
104
+ let gap = 0
105
+ let snapPoints: number[] = []
106
+ let snapElements: HTMLElement[] = []
107
+ let snapAlignments: ScrollLogicalPosition[] = []
108
+ const virtualSnapPoints: number[] = []
109
+ let slides: HTMLElement[] = []
110
+ let resizeObserver: ResizeObserver | null = null
111
+ let mutationObserver: MutationObserver | null = null
112
+ let hasSnap = false
113
+ let hasMouse = false
114
+ let nativeScroll = true
115
+ let restoreScrollMethods: () => void
116
+ let dir = 1
117
+
118
+ function init() {
119
+ scroller?.setAttribute('blossom-carousel', 'true')
120
+ slides = Array.from(scroller.children) as HTMLElement[]
121
+
122
+ window.addEventListener('keydown', onKeydown)
123
+ scroller.addEventListener('scroll', onScroll)
124
+
125
+ dir = scroller.closest('[dir="rtl"]') ? -1 : 1
126
+
127
+ const { scrollSnapType } = window.getComputedStyle(scroller)
128
+ hasSnap = scrollSnapType !== 'none'
129
+ scroller.style.setProperty('--snap-type', scrollSnapType)
130
+ scroller.setAttribute('has-repeat', options?.repeat ? 'true' : 'false')
131
+
132
+ hasMouse = window.matchMedia('(hover: hover) and (pointer: fine)').matches
133
+
134
+ nativeScroll = !hasMouse && !options?.repeat
135
+ if (!nativeScroll) {
136
+ scroller.style.scrollSnapType = 'none'
137
+ }
138
+
139
+ restoreScrollMethods = interceptScrollIntoViewCalls((target) => {
140
+ if (target === scroller || scroller.contains(target)) setIsTicking(false)
141
+ })
142
+
143
+ resizeObserver = new ResizeObserver(onResize)
144
+ // If scroller width matches parent width, observe parent for resize events
145
+ // (ResizeObserver may not fire on elements sized by their containers)
146
+ const parent = scroller.parentElement
147
+ if (parent && scroller.clientWidth === parent.clientWidth) {
148
+ resizeObserver.observe(parent)
149
+ } else {
150
+ resizeObserver.observe(scroller)
151
+ }
152
+
153
+ mutationObserver = new MutationObserver(onMutation)
154
+ mutationObserver.observe(scroller, {
155
+ attributes: false,
156
+ childList: true,
157
+ subtree: false,
158
+ })
159
+ }
160
+
161
+ function destroy() {
162
+ scroller.removeAttribute('blossom-carousel')
163
+ resizeObserver?.disconnect()
164
+ mutationObserver?.disconnect()
165
+ if (raf) cancelAnimationFrame(raf)
166
+
167
+ window.removeEventListener('keydown', onKeydown)
168
+ scroller.removeEventListener('scroll', onScroll)
169
+
170
+ restoreScrollMethods?.()
171
+ }
172
+
173
+ function onResize(): void {
174
+ if (!scroller) return
175
+
176
+ setIsTicking(false)
177
+
178
+ // reset translates
179
+ for (let i = 0; i < snapElements.length; i++) {
180
+ const el = snapElements[i]
181
+ el.style.translate = ''
182
+ }
183
+
184
+ scrollerScrollWidth = scroller.scrollWidth
185
+ scrollerWidth = scroller.clientWidth
186
+ scrollerScrollHeight = scroller.scrollHeight
187
+ scrollerHeight = scroller.clientHeight
188
+
189
+ const styles = window.getComputedStyle(scroller)
190
+ hasOverflow.x =
191
+ // !hasTouch &&
192
+ scrollerScrollWidth > scrollerWidth && ['auto', 'scroll'].includes(styles.getPropertyValue('overflow-x'))
193
+ hasOverflow.y =
194
+ // !hasTouch &&
195
+ scrollerScrollHeight > scrollerHeight && ['auto', 'scroll'].includes(styles.getPropertyValue('overflow-y'))
196
+ padding.start = Number.parseInt(styles.paddingInlineStart, 10) || 0
197
+ padding.end = Number.parseInt(styles.paddingInlineEnd, 10) || 0
198
+ scrollPadding.start = Number.parseInt(styles.scrollPaddingInlineStart, 10) || 0
199
+ scrollPadding.end = Number.parseInt(styles.scrollPaddingInlineEnd, 10) || 0
200
+ dir = scroller.closest('[dir="rtl"]') ? -1 : 1
201
+ gap = Number.parseInt(styles.gap, 10) || Number.parseInt(styles.columnGap, 10) || 0
202
+ end = (scrollerScrollWidth - scrollerWidth - securityMargin + gap) * dir
203
+
204
+ const result = !hasSnap ? { points: [], elements: [], alignments: [], sizes: [] } : findSnapPoints(scroller)
205
+ snapPoints = result.points
206
+ snapElements = result.elements
207
+ snapAlignments = result.alignments
208
+
209
+ target.x = virtualScroll.x = snapPoints[currentIndex.value]
210
+ scroller.scrollTo({ left: virtualScroll.x, behavior: 'instant' })
211
+ if (options?.repeat) {
212
+ onRepeat()
213
+ }
214
+ }
215
+
216
+ function onMutation(): void {
217
+ onResize()
218
+ }
219
+
220
+ function findSnapPoints(scroller: HTMLElement): {
221
+ points: number[]
222
+ elements: HTMLElement[]
223
+ alignments: ScrollLogicalPosition[]
224
+ } {
225
+ const points: { align: string; el: HTMLElement | Element }[] = []
226
+
227
+ let cycles = 0
228
+ const traverseDOM = (node: HTMLElement | Element) => {
229
+ cycles++
230
+ // break if the max depth is reached
231
+ if (cycles > 100) return
232
+
233
+ const styles = window.getComputedStyle(node)
234
+ const scrollSnapAlign = styles.scrollSnapAlign
235
+
236
+ // break if a snap-type value is found
237
+ if (scrollSnapAlign !== 'none') {
238
+ points.push({
239
+ align: scrollSnapAlign,
240
+ el: node,
241
+ })
242
+ return
243
+ }
244
+
245
+ // traverse all children
246
+ const children = node.children
247
+ if (children.length === 0) return
248
+ for (const child of children) {
249
+ traverseDOM(child)
250
+ }
251
+ }
252
+ traverseDOM(scroller)
253
+
254
+ // precompute snap point for all slides
255
+ const scrollerRect = scroller.getBoundingClientRect()
256
+ const snapData = points.map(({ el, align }) => {
257
+ const elementRect = (el as HTMLElement).getBoundingClientRect()
258
+ const clientWidth = (el as HTMLElement).clientWidth
259
+ const left = elementRect.left - scrollerRect.left + scroller.scrollLeft
260
+
261
+ let position: number | null = null
262
+ switch (align) {
263
+ case 'start':
264
+ position = left - scrollPadding.start
265
+ break
266
+ case 'end':
267
+ position = left + clientWidth - scrollerWidth + scrollPadding.end
268
+ break
269
+ case 'center':
270
+ position = left + clientWidth * 0.5 - scrollerWidth / 2
271
+ break
272
+ }
273
+
274
+ return { position, element: el as HTMLElement, align }
275
+ })
276
+
277
+ // Filter out duplicates (i.e. in case of multiple rows)
278
+ const filteredData: { position: number; element: HTMLElement; align: ScrollLogicalPosition }[] = []
279
+ const seenPositions = new Set<number>()
280
+
281
+ for (const item of snapData) {
282
+ if (item.position !== null && !seenPositions.has(item.position)) {
283
+ seenPositions.add(item.position)
284
+ filteredData.push({ position: item.position, element: item.element, align: item.align as ScrollLogicalPosition })
285
+ }
286
+ }
287
+
288
+ return {
289
+ points: filteredData.map((d) => d.position),
290
+ elements: filteredData.map((d) => d.element),
291
+ alignments: filteredData.map((d) => d.align),
292
+ }
293
+ }
294
+
295
+ function onScroll() {
296
+ if (isDragging || !scroller) return
297
+
298
+ if (!isTicking) {
299
+ virtualScroll.x = target.x = scroller.scrollLeft
300
+ }
301
+
302
+ if (options?.repeat) {
303
+ onRepeat()
304
+ updateCurrentIndex()
305
+ return
306
+ }
307
+
308
+ const scrollStart = scroller.scrollLeft
309
+ const maxScroll = getMaxScrollPosition()
310
+
311
+ if (scrollStart < 0) {
312
+ const left = scrollStart * -1
313
+ dispatchOverscrollEvent(left)
314
+ } else if (scrollStart > maxScroll) {
315
+ const left = scrollStart * -1 + maxScroll
316
+ dispatchOverscrollEvent(left)
317
+ }
318
+
319
+ updateCurrentIndex()
320
+ }
321
+
322
+ /*********************
323
+ **** Drag events ****
324
+ *********************/
325
+
326
+ const virtualScroll: Point = {
327
+ x: 0,
328
+ y: 0,
329
+ }
330
+
331
+ function addPointerListeners(): void {
332
+ window.addEventListener('pointermove', onPointerMove, { passive: true })
333
+ window.addEventListener('touchmove', onPointerMove, { passive: true })
334
+ window.addEventListener('pointerup', onPointerUp, { passive: true })
335
+ window.addEventListener('touchend', onPointerUp, { passive: true })
336
+ }
337
+
338
+ function removePointerListeners(): void {
339
+ window.removeEventListener('pointermove', onPointerMove)
340
+ window.removeEventListener('touchmove', onPointerMove)
341
+ window.removeEventListener('pointerup', onPointerUp)
342
+ window.removeEventListener('touchend', onPointerUp)
343
+ }
344
+
345
+ function onPointerDown(e: PointerEvent | TouchEvent): void {
346
+ if (!scroller) return
347
+
348
+ const clientX = 'touches' in e ? e.touches[0]?.clientX : e.clientX
349
+ const clientY = 'touches' in e ? e.touches[0]?.clientY : e.clientY
350
+
351
+ if (hasOverflow.x) handleAxisPointerDown('x', clientX)
352
+ if (hasOverflow.y) handleAxisPointerDown('y', clientY)
353
+
354
+ distanceMovedSincePointerDown.x = 0
355
+ isDragging = true
356
+
357
+ addPointerListeners()
358
+ }
359
+
360
+ function onPointerMove(e: PointerEvent | TouchEvent): void {
361
+ const clientX = 'touches' in e ? e.touches[0]?.clientX : e.clientX
362
+ const clientY = 'touches' in e ? e.touches[0]?.clientY : e.clientY
363
+
364
+ if (hasOverflow.x) handleAxisPointerMove('x', clientX)
365
+ if (hasOverflow.y) handleAxisPointerMove('y', clientY)
366
+
367
+ if (distanceMovedSincePointerDown.x > 2 || distanceMovedSincePointerDown.y > 2) scroller.classList.add('blossom-dragging')
368
+ }
369
+
370
+ function onPointerUp(): void {
371
+ removePointerListeners()
372
+
373
+ isDragging = false
374
+ scroller.classList.remove('blossom-dragging')
375
+
376
+ if (distanceMovedSincePointerDown.x <= 10) return
377
+ if (hasOverflow.x) velocity.x *= 2
378
+ if (hasOverflow.y) velocity.y *= 2
379
+
380
+ dragSnap()
381
+ }
382
+
383
+ function onWheel(e: WheelEvent): void {
384
+ if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) {
385
+ setIsTicking(false)
386
+ if (isDragging || !scroller) return
387
+ if (hasOverflow.x) virtualScroll.x = scroller.scrollLeft
388
+ if (hasOverflow.y) virtualScroll.y = scroller.scrollTop
389
+ }
390
+ }
391
+
392
+ function onKeydown(e: KeyboardEvent): void {
393
+ if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) setIsTicking(false)
394
+ }
395
+
396
+ function dragSnap(): void {
397
+ //TODO: add support for vertical snapping
398
+ let slideX: number | undefined
399
+
400
+ if (options?.repeat && snapElements.length > 0) {
401
+ // Strict single-step using helpers
402
+ const n = snapPoints.length
403
+ if (n >= 2) {
404
+ const ci = ((currentIndex.value % n) + n) % n
405
+ const step: 1 | -1 = velocity.x * dir >= 0 ? 1 : -1
406
+ const targetIdx = (ci + step + n) % n
407
+ slideX = computeRepeatSlideX(targetIdx, step)
408
+ } else {
409
+ slideX = snapSelect({ axis: 'x' })
410
+ }
411
+ } else if (!options?.repeat && snapElements.length) {
412
+ const bounds = getScrollBounds()
413
+ slideX = clamp(snapSelect({ axis: 'x' }), bounds.min, bounds.max)
414
+ }
415
+
416
+ if (slideX === undefined) return
417
+
418
+ animateToSlideX(slideX)
419
+ }
420
+
421
+ /*********************
422
+ ***** WRAP *****
423
+ *********************/
424
+
425
+ // Reuse these allocations to avoid GC pressure in repeat mode
426
+ const repeatVirtualCache: number[] = []
427
+ const repeatElementOffsetCache = new Map<HTMLElement, number>()
428
+
429
+ function onRepeat(): void {
430
+ if (!scroller) return
431
+
432
+ const loopWidth = getLoopWidth()
433
+ if (loopWidth === 0) return
434
+
435
+ if (virtualScroll.x >= end) {
436
+ virtualScroll.x -= loopWidth
437
+ target.x -= loopWidth
438
+ if (!isTicking) {
439
+ scroller.scrollTo({ left: virtualScroll.x, behavior: 'instant' })
440
+ }
441
+ } else if (virtualScroll.x <= securityMargin) {
442
+ virtualScroll.x += loopWidth
443
+ target.x += loopWidth
444
+ if (!isTicking) {
445
+ scroller.scrollTo({ left: virtualScroll.x, behavior: 'instant' })
446
+ }
447
+ }
448
+
449
+ const scrollLeft = virtualScroll.x
450
+
451
+ // Clear and reuse cached arrays/maps
452
+ repeatElementOffsetCache.clear()
453
+ repeatVirtualCache.length = snapPoints.length
454
+
455
+ // Compute offsets for snapped elements first to produce a dense virtualSnapPoints
456
+ for (let i = 0; i < snapElements.length; i++) {
457
+ const el = snapElements[i]
458
+ const basePoint = snapPoints[i] ?? 0
459
+ const alignement = snapAlignments[i] ?? 'start'
460
+ const dx = alignement === 'start' ? 0 : alignement === 'end' ? (scrollerWidth - el.clientWidth) / 2 : (-scrollerWidth + el.clientWidth) / 2
461
+ const raw = (scrollLeft - basePoint + dx) / loopWidth
462
+ const k = Math.round(raw + (velocity.x >= 0 ? 0.001 : -0.001))
463
+ const offsetX = k * loopWidth
464
+ el.style.translate = `${offsetX}px 0`
465
+ repeatElementOffsetCache.set(el, offsetX)
466
+ repeatVirtualCache[i] = basePoint + offsetX
467
+ }
468
+
469
+ // Translate non-snap slides to the nearest previous snapped slide's cycle
470
+ for (let i = 0; i < slides.length; i++) {
471
+ const el = slides[i]
472
+ if (repeatElementOffsetCache.has(el)) continue
473
+ let prevTranslate = 0
474
+ for (let j = i - 1; j >= 0; j--) {
475
+ const prevEl = slides[j]
476
+ const val = repeatElementOffsetCache.get(prevEl)
477
+ if (val !== undefined) {
478
+ prevTranslate = val
479
+ break
480
+ }
481
+ }
482
+ el.style.translate = `${prevTranslate}px 0`
483
+ }
484
+
485
+ // Replace contents of virtualSnapPoints with the dense array
486
+ virtualSnapPoints.length = 0
487
+ for (let i = 0; i < repeatVirtualCache.length; i++) virtualSnapPoints.push(repeatVirtualCache[i])
488
+ }
489
+
490
+ /******************
491
+ ***** Ticker *****
492
+ ******************/
493
+
494
+ const FRICTION = 0.72
495
+ const DAMPING = 0.12
496
+ let isTicking = false
497
+
498
+ function setIsTicking(bool: boolean): void {
499
+ if (!scroller) return
500
+
501
+ if (bool && !isTicking) {
502
+ lastTick = performance.now()
503
+ if (hasOverflow.x) target.x = scroller.scrollLeft
504
+ if (hasOverflow.y) target.y = scroller.scrollTop
505
+
506
+ if (!raf) {
507
+ raf = requestAnimationFrame(tick)
508
+ }
509
+ } else if (!bool && raf) {
510
+ cancelAnimationFrame(raf)
511
+ raf = null
512
+ }
513
+
514
+ isTicking = bool
515
+ snap = !bool
516
+
517
+ scroller.setAttribute('has-snap', snap ? 'true' : 'false')
518
+ }
519
+
520
+ let frameDelta = 0
521
+ let lastTick = 0
522
+ function tick(t: number): void {
523
+ raf = requestAnimationFrame(tick)
524
+ frameDelta = t - lastTick
525
+
526
+ if (!scroller) return
527
+
528
+ if (hasOverflow.x) handleAxisTick('x')
529
+ if (hasOverflow.y) handleAxisTick('y')
530
+
531
+ if (options?.repeat) {
532
+ onRepeat()
533
+ } else {
534
+ applyRubberBanding(round(virtualScroll.x, 2))
535
+ }
536
+
537
+ __scrollingInternally = true
538
+ scroller.scrollTo({
539
+ left: virtualScroll.x,
540
+ top: virtualScroll.y,
541
+ behavior: 'instant' as ScrollBehavior,
542
+ })
543
+
544
+ updateCurrentIndex()
545
+
546
+ lastTick = t
547
+ }
548
+
549
+ let rubberBandOffset = 0
550
+ function applyRubberBanding(left: number): void {
551
+ if (!scroller) return
552
+
553
+ //TODO: add support for vertical rubber banding
554
+ const edge = end
555
+
556
+ let targetOffset = 0
557
+ if (left * dir <= 0) {
558
+ targetOffset = isDragging ? left * -0.2 : 0
559
+ } else if (left * dir > edge * dir) {
560
+ targetOffset = isDragging ? (left - edge) * -0.2 : 0
561
+ }
562
+ rubberBandOffset = damp(rubberBandOffset, targetOffset, isDragging ? 0.8 : DAMPING, frameDelta)
563
+
564
+ if (Math.abs(rubberBandOffset) > 0.01) {
565
+ const evt = dispatchOverscrollEvent(rubberBandOffset)
566
+ if (evt.defaultPrevented) return
567
+ scroller.style.transform = `translateX(${round(rubberBandOffset, 3)}px)`
568
+ return
569
+ }
570
+
571
+ scroller.style.transform = ''
572
+ rubberBandOffset = 0
573
+ }
574
+
575
+ function dispatchOverscrollEvent(left: number): CustomEvent<{ left: number }> {
576
+ const overscrollEvent = new CustomEvent('overscroll', {
577
+ bubbles: true,
578
+ cancelable: true,
579
+ detail: { left },
580
+ })
581
+ scroller?.dispatchEvent(overscrollEvent)
582
+ return overscrollEvent
583
+ }
584
+
585
+ /******************************
586
+ ********* METHODS **************
587
+ ******************************/
588
+
589
+ let __scrollingInternally = false
590
+
591
+ const scrollTo = scroller.scrollTo.bind(scroller)
592
+ scroller.scrollTo = ((optionsOrX?: ScrollToOptions | number, y?: number) => {
593
+ const internal = __scrollingInternally === true
594
+ if (!internal) setIsTicking(false)
595
+ __scrollingInternally = false
596
+ if (typeof optionsOrX === 'number') {
597
+ scrollTo(optionsOrX, y ?? 0)
598
+ } else {
599
+ scrollTo(optionsOrX)
600
+ }
601
+ }) as typeof scroller.scrollTo
602
+
603
+ const scrollBy = scroller.scrollBy.bind(scroller)
604
+ scroller.scrollBy = ((optionsOrX?: ScrollToOptions | number, y?: number) => {
605
+ const internal = __scrollingInternally === true
606
+ if (!internal) setIsTicking(false)
607
+ __scrollingInternally = false
608
+ if (typeof optionsOrX === 'number') {
609
+ scrollBy(optionsOrX, y ?? 0)
610
+ } else {
611
+ scrollBy(optionsOrX)
612
+ }
613
+ }) as typeof scroller.scrollBy
614
+
615
+ function interceptScrollIntoViewCalls(onExternalScroll: (target: Element, method: string, args: any[]) => void) {
616
+ const stopFns: Array<() => void> = []
617
+
618
+ // Patch scrollIntoView for all elements
619
+ const originalScrollIntoView = Element.prototype.scrollIntoView
620
+ if (originalScrollIntoView) {
621
+ Element.prototype.scrollIntoView = function (arg?: boolean | ScrollIntoViewOptions): void {
622
+ onExternalScroll(this, 'scrollIntoView', [arg])
623
+ originalScrollIntoView.call(this, arg)
624
+ }
625
+ stopFns.push(() => {
626
+ Element.prototype.scrollIntoView = originalScrollIntoView
627
+ })
628
+ }
629
+
630
+ return () =>
631
+ stopFns.forEach((fn) => {
632
+ fn()
633
+ })
634
+ }
635
+
636
+ /******************************
637
+ ********* UTILS **************
638
+ ******************************/
639
+
640
+ interface AxisOption {
641
+ axis: 'x' | 'y'
642
+ }
643
+
644
+ function handleAxisPointerDown(axis: 'x' | 'y', clientPos: number): void {
645
+ const scrollProp = axis === 'x' ? 'scrollLeft' : 'scrollTop'
646
+ virtualScroll[axis] = scroller[scrollProp]
647
+ target[axis] = scroller[scrollProp]
648
+ pointerStart[axis] = clientPos
649
+ velocity[axis] = 0
650
+ }
651
+
652
+ function handleAxisPointerMove(axis: 'x' | 'y', clientPos: number): void {
653
+ const delta = pointerStart[axis] - clientPos
654
+ target[axis] += delta
655
+ velocity[axis] += delta
656
+ pointerStart[axis] = clientPos
657
+ distanceMovedSincePointerDown[axis] += Math.abs(delta)
658
+ }
659
+
660
+ function handleAxisTick(axis: 'x' | 'y'): void {
661
+ velocity[axis] *= FRICTION
662
+ if (!isDragging) {
663
+ target[axis] += velocity[axis]
664
+ virtualScroll[axis] = damp(virtualScroll[axis], target[axis], DAMPING, frameDelta)
665
+ } else {
666
+ virtualScroll[axis] = damp(virtualScroll[axis], target[axis], FRICTION, frameDelta)
667
+ }
668
+ }
669
+
670
+ function project({ axis = 'x' }: AxisOption): number {
671
+ return target[axis] + velocity[axis] / (1 - FRICTION)
672
+ }
673
+
674
+ function snapSelect({ axis = 'x' }: AxisOption): number {
675
+ const restingX = project({ axis })
676
+ const points = virtualSnapPoints.length ? virtualSnapPoints : snapPoints
677
+ return points.reduce((prev, curr) => (Math.abs(curr - restingX) < Math.abs(prev - restingX) ? curr : prev))
678
+ }
679
+
680
+ function lerp(x: number, y: number, t: number): number {
681
+ return (1 - t) * x + t * y
682
+ }
683
+
684
+ function damp(x: number, y: number, t: number, delta: number): number {
685
+ return lerp(x, y, 1 - Math.exp(Math.log(1 - t) * (delta / (1000 / 60))))
686
+ }
687
+
688
+ function clamp(value: number, min: number, max: number): number {
689
+ if (value < min) return min
690
+ if (value > max) return max
691
+ return value
692
+ }
693
+
694
+ function round(value: number, precision = 0): number {
695
+ const multiplier = 10 ** precision
696
+ return Math.round(value * multiplier) / multiplier
697
+ }
698
+
699
+ /******************************
700
+ ********* REPEAT UTILS *********
701
+ ******************************/
702
+
703
+ function getLoopWidth(): number {
704
+ return scrollerScrollWidth - scrollerWidth + gap
705
+ }
706
+
707
+ function getMaxScrollPosition(): number {
708
+ return scrollerScrollWidth - scrollerWidth
709
+ }
710
+
711
+ function getScrollBounds(): { min: number; max: number } {
712
+ const maxScroll = getMaxScrollPosition()
713
+ return {
714
+ min: Math.min(maxScroll * dir, 0),
715
+ max: Math.max(maxScroll * dir, 0),
716
+ }
717
+ }
718
+
719
+ function normalizeToCycle(point: number, reference: number): number {
720
+ const w = getLoopWidth()
721
+ if (w <= 0) return point
722
+ const k = Math.round((reference - point) / w)
723
+ return point + k * w
724
+ }
725
+
726
+ function animateToSlideX(slideX: number, enforceDir?: 1 | -1): void {
727
+ if (slideX === undefined || Number.isNaN(slideX)) return
728
+
729
+ setIsTicking(true)
730
+ target.x = virtualScroll.x
731
+ let distance = slideX - virtualScroll.x
732
+ if (options?.repeat) {
733
+ const w = getLoopWidth()
734
+ if (w > 0) {
735
+ // shortest path wrapping
736
+ distance = distance - Math.round(distance / w) * w
737
+ // optionally enforce intended direction (for prev/next)
738
+ if (enforceDir === 1 && distance * dir <= 0) distance += w * dir
739
+ if (enforceDir === -1 && distance * dir >= 0) distance -= w * dir
740
+ }
741
+ }
742
+ const force = distance * (1 - FRICTION) * (1 / FRICTION)
743
+ velocity.x = force
744
+ }
745
+
746
+ function computeRepeatSlideX(targetIndex: number, directionSign: 1 | -1): number {
747
+ const base = snapPoints[targetIndex] ?? 0
748
+ const candidate = normalizeToCycle(base, virtualScroll.x)
749
+ const w = getLoopWidth()
750
+ if (w <= 0) return candidate
751
+ const delta = candidate - virtualScroll.x
752
+ if (directionSign === 1 && delta * dir <= 0) return candidate + w * dir
753
+ if (directionSign === -1 && delta * dir >= 0) return candidate - w * dir
754
+ return candidate
755
+ }
756
+
757
+ /******************************
758
+ ********* NAVIGATION *********
759
+ ******************************/
760
+
761
+ function getCurrentSnapIndex(): number {
762
+ const points = options?.repeat && virtualSnapPoints.length ? virtualSnapPoints : snapPoints
763
+ if (!points.length) return 0
764
+
765
+ const currentScroll = scroller.scrollLeft
766
+ let closestIndex = 0
767
+ let closestDistance = Math.abs(points[0] - currentScroll)
768
+
769
+ for (let i = 1; i < points.length; i++) {
770
+ const distance = Math.abs(points[i] - currentScroll)
771
+ if (distance < closestDistance) {
772
+ closestDistance = distance
773
+ closestIndex = i
774
+ }
775
+ }
776
+
777
+ return closestIndex
778
+ }
779
+
780
+ function updateCurrentIndex(): void {
781
+ const newIndex = getCurrentSnapIndex()
782
+ if (currentIndex.value !== newIndex) {
783
+ currentIndex.value = newIndex
784
+ }
785
+ }
786
+
787
+ function navigate(direction: 1 | -1): void {
788
+ if (snapElements.length <= 1) return
789
+
790
+ const points = snapPoints
791
+ const targetIndex = options?.repeat
792
+ ? (currentIndex.value + direction + points.length) % points.length
793
+ : direction === 1
794
+ ? Math.min(currentIndex.value + 1, points.length - 1)
795
+ : Math.max(currentIndex.value - 1, 0)
796
+
797
+ if (nativeScroll) {
798
+ const align = snapAlignments[targetIndex] ?? 'start'
799
+ ;(snapElements[targetIndex] as HTMLElement).scrollIntoView({
800
+ behavior: 'smooth',
801
+ block: 'nearest',
802
+ inline: align,
803
+ })
804
+ return
805
+ }
806
+
807
+ let slideX: number | undefined
808
+ if (options?.repeat && points.length >= 2) {
809
+ const loopWidth = getLoopWidth()
810
+ const base = points[targetIndex]
811
+ const k = loopWidth > 0 ? Math.round((virtualScroll.x - base) / loopWidth) : 0
812
+ let candidate = base + k * loopWidth
813
+ const delta = candidate - virtualScroll.x
814
+ if (direction === 1 && delta * dir <= 0) candidate += loopWidth * dir
815
+ if (direction === -1 && delta * dir >= 0) candidate -= loopWidth * dir
816
+ slideX = candidate
817
+ } else {
818
+ const bounds = getScrollBounds()
819
+ slideX = clamp(points[targetIndex], bounds.min, bounds.max)
820
+ }
821
+
822
+ animateToSlideX(slideX, direction)
823
+ }
824
+
825
+ function next(): void {
826
+ navigate(1)
827
+ }
828
+
829
+ function prev(): void {
830
+ navigate(-1)
831
+ }
832
+
833
+ return {
834
+ init,
835
+ snap,
836
+ hasOverflow,
837
+ currentIndex: () => currentIndex.value,
838
+ next,
839
+ prev,
840
+ destroy,
841
+ }
842
+ }