@ycparak/spring-carousel 0.0.0-stage → 0.1.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.
package/dist/index.cjs ADDED
@@ -0,0 +1,1342 @@
1
+ 'use client';
2
+ var react = require('react');
3
+ var jsxRuntime = require('react/jsx-runtime');
4
+
5
+ const CarouselContext = react.createContext(null);
6
+ const CarouselStateContext = react.createContext(null);
7
+ const SlideContext = react.createContext(null);
8
+ function useCarouselContext(caller) {
9
+ const context = react.use(CarouselContext);
10
+ const state = react.use(CarouselStateContext);
11
+ if (!context || !state) {
12
+ throw new Error(`spring-carousel: \`${caller}\` must be rendered inside \`<Carousel.Root>\`.`);
13
+ }
14
+ return {
15
+ context,
16
+ state
17
+ };
18
+ }
19
+ function slideId(baseId, index) {
20
+ return `${baseId}-slide-${index}`;
21
+ }
22
+ function tabId(baseId, index) {
23
+ return `${baseId}-tab-${index}`;
24
+ }
25
+ function trackId(baseId) {
26
+ return `${baseId}-track`;
27
+ }
28
+
29
+ /**
30
+ * Reads the carousel's state and controls from any component inside
31
+ * `<Carousel.Root>`. Re-renders when the index, count or drag state changes, never
32
+ * per frame; use `useCarouselProgress` for per-frame work.
33
+ */ function useCarousel() {
34
+ const { context, state } = useCarouselContext('useCarousel');
35
+ const { engine, goTo, next, prev } = context;
36
+ const { index, count, isDragging } = state;
37
+ return react.useMemo(()=>({
38
+ index,
39
+ count,
40
+ isDragging,
41
+ canPrev: index > 0,
42
+ canNext: index < count - 1,
43
+ goTo,
44
+ next,
45
+ prev,
46
+ progress: engine.progress
47
+ }), [
48
+ index,
49
+ count,
50
+ isDragging,
51
+ goTo,
52
+ next,
53
+ prev,
54
+ engine
55
+ ]);
56
+ }
57
+ /**
58
+ * Calls `listener` with the carousel's position on every frame it moves, and once
59
+ * on mount. Use it to drive styles imperatively (captions, parallax, progress bars)
60
+ * without re-rendering. The listener always sees the latest props and state.
61
+ */ function useCarouselProgress(listener) {
62
+ const { context } = useCarouselContext('useCarouselProgress');
63
+ const { progress } = context.engine;
64
+ const onProgress = react.useEffectEvent(listener);
65
+ react.useLayoutEffect(()=>progress.subscribe((value)=>onProgress(value)), [
66
+ progress
67
+ ]);
68
+ }
69
+ /** Reads the enclosing `<Carousel.Slide>`'s index and whether it is the current slide. */ function useSlide() {
70
+ const slide = react.use(SlideContext);
71
+ if (!slide) {
72
+ throw new Error('spring-carousel: `useSlide` must be called inside `<Carousel.Slide>`.');
73
+ }
74
+ return slide;
75
+ }
76
+
77
+ function clamp(value, min, max) {
78
+ return Math.min(Math.max(value, min), max);
79
+ }
80
+ /** How present an item is at a given progress: 1 when it is the current slide, 0 a slide or more away. */ function presence(progress, index) {
81
+ return clamp(1 - Math.abs(index - progress), 0, 1);
82
+ }
83
+ /**
84
+ * Reads a position at a fractional index from a list of positions, extrapolating
85
+ * past either end with the nearest spacing so rubber-banding keeps moving.
86
+ */ function interpolate(points, value) {
87
+ const count = points.length;
88
+ const first = points[0];
89
+ if (first === undefined) return 0;
90
+ if (count === 1) return first;
91
+ const last = points[count - 1];
92
+ if (value <= 0) return first + value * (points[1] - first);
93
+ if (value >= count - 1) return last + (value - (count - 1)) * (last - points[count - 2]);
94
+ const startIndex = Math.floor(value);
95
+ const start = points[startIndex];
96
+ const end = points[startIndex + 1];
97
+ return start + (end - start) * (value - startIndex);
98
+ }
99
+ /** Distance between neighbouring positions around `value`, used to turn pixels into slides. */ function spacingAt(points, value, fallback) {
100
+ if (points.length < 2) return fallback;
101
+ const startIndex = clamp(Math.floor(value), 0, points.length - 2);
102
+ const spacing = Math.abs(points[startIndex + 1] - points[startIndex]);
103
+ return spacing > 0 ? spacing : fallback;
104
+ }
105
+ /**
106
+ * Picks the slide a gesture should settle on.
107
+ *
108
+ * - A flick advances to the next whole slide in the direction of travel.
109
+ * - A trackpad swipe advances one slide in the direction it moved.
110
+ * - A slow drag advances once it has covered `distanceThreshold` of a slide.
111
+ */ function resolveReleaseTarget({ position, anchor, velocity, count, kind, flickVelocity, distanceThreshold = 0.3 }) {
112
+ if (count <= 0) return 0;
113
+ const lastIndex = count - 1;
114
+ const travelled = position - anchor;
115
+ let target = anchor;
116
+ if (Math.abs(velocity) >= flickVelocity) {
117
+ target = velocity > 0 ? Math.floor(position) + 1 : Math.ceil(position) - 1;
118
+ } else if (kind === 'wheel') {
119
+ if (travelled !== 0) target = anchor + Math.sign(travelled);
120
+ } else if (travelled > 0) {
121
+ target = Math.floor(position + 1 - distanceThreshold);
122
+ } else if (travelled < 0) {
123
+ target = Math.ceil(position - 1 + distanceThreshold);
124
+ }
125
+ return clamp(target, 0, lastIndex);
126
+ }
127
+ function snapToDevicePixels(value, pixelRatio) {
128
+ const ratio = pixelRatio || 1;
129
+ return Math.round(value * ratio) / ratio;
130
+ }
131
+
132
+ class ProgressValue {
133
+ #value;
134
+ #velocity;
135
+ #listeners;
136
+ constructor(initial){
137
+ this.#velocity = 0;
138
+ this.#listeners = new Set();
139
+ this.get = ()=>this.#value;
140
+ this.getVelocity = ()=>this.#velocity;
141
+ this.subscribe = (listener)=>{
142
+ this.#listeners.add(listener);
143
+ listener(this.#value);
144
+ return ()=>{
145
+ this.#listeners.delete(listener);
146
+ };
147
+ };
148
+ this.#value = initial;
149
+ }
150
+ set(value, velocity) {
151
+ this.#velocity = velocity;
152
+ if (value === this.#value) return;
153
+ this.#value = value;
154
+ for (const listener of this.#listeners)listener(value);
155
+ }
156
+ }
157
+ const HISTORY = 300;
158
+ const RECENT_WINDOW = 32;
159
+ const IDLE_AFTER = 50;
160
+ const STILL_DISTANCE = 2;
161
+ /** Estimates pointer or wheel velocity from the last 300ms of samples. */ class VelocityTracker {
162
+ #samples;
163
+ reset() {
164
+ this.#samples = [];
165
+ }
166
+ add(time, position) {
167
+ this.#samples.push({
168
+ time,
169
+ position
170
+ });
171
+ while(this.#samples.length > 2 && time - (this.#samples[0]?.time ?? time) > HISTORY){
172
+ this.#samples.shift();
173
+ }
174
+ }
175
+ /**
176
+ * Units per second over the final two frames or so of movement before any pause,
177
+ * however long ago that was: which way, and how hard, the input was heading when it
178
+ * let go. The pixel or two a held pointer drifts, or a click nudges, is not movement.
179
+ */ intentVelocity() {
180
+ const lastIndex = this.#samples.length - 1;
181
+ const last = this.#samples[lastIndex];
182
+ if (!last) return 0;
183
+ let end = lastIndex;
184
+ while(end > 0 && Math.abs(this.#samples[end - 1].position - last.position) < STILL_DISTANCE){
185
+ end -= 1;
186
+ }
187
+ return this.#velocityEndingAt(end);
188
+ }
189
+ /** Units per second at `now`: 0 once the input has been still for 50ms. */ velocity(now) {
190
+ const lastIndex = this.#samples.length - 1;
191
+ const last = this.#samples[lastIndex];
192
+ if (!last || now - last.time > IDLE_AFTER) return 0;
193
+ return this.#velocityEndingAt(lastIndex);
194
+ }
195
+ #velocityEndingAt(endIndex) {
196
+ const end = this.#samples[endIndex];
197
+ if (!end) return 0;
198
+ let start = end;
199
+ for(let index = endIndex - 1; index >= 0; index -= 1){
200
+ start = this.#samples[index];
201
+ if (end.time - start.time >= RECENT_WINDOW) break;
202
+ }
203
+ const elapsed = end.time - start.time;
204
+ return elapsed > 0 ? (end.position - start.position) / elapsed * 1000 : 0;
205
+ }
206
+ constructor(){
207
+ this.#samples = [];
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Physical spring parameters. Defaults reproduce the original Kami carousel.
213
+ */ const DEFAULT_SPRING = {
214
+ stiffness: 90,
215
+ damping: 18.1,
216
+ mass: 0.9
217
+ };
218
+ /** Positions are in slides, so this is a tenth of a pixel on a 1000px slide. */ const REST_DELTA = 0.0001;
219
+ const REST_SPEED = 0.001;
220
+ const STEP_SECONDS = 0.001;
221
+ const MAX_FRAME_SECONDS = 1;
222
+ function resolveSpring(options) {
223
+ if (!options) return DEFAULT_SPRING;
224
+ if (options.duration !== undefined) {
225
+ const duration = Math.max(options.duration, 0.01);
226
+ const bounce = Math.min(Math.max(options.bounce ?? 0, -1), 1);
227
+ const mass = 1;
228
+ const stiffness = (2 * Math.PI / duration) ** 2 * mass;
229
+ const damping = bounce >= 0 ? (1 - bounce) * 4 * Math.PI * mass / duration : 4 * Math.PI * mass / (duration * (1 + bounce + Number.EPSILON));
230
+ return {
231
+ stiffness,
232
+ damping,
233
+ mass
234
+ };
235
+ }
236
+ return {
237
+ stiffness: positive(options.stiffness, DEFAULT_SPRING.stiffness),
238
+ damping: Math.max(options.damping ?? DEFAULT_SPRING.damping, 0),
239
+ mass: positive(options.mass, DEFAULT_SPRING.mass)
240
+ };
241
+ }
242
+ function positive(value, fallback) {
243
+ return value !== undefined && value > 0 ? value : fallback;
244
+ }
245
+ /**
246
+ * Advances a spring towards `target` by `elapsed` seconds with fixed 1ms
247
+ * semi-implicit Euler steps, which stay stable for any stiffness a person would
248
+ * reasonably choose. Integrating the real elapsed time keeps the motion on time
249
+ * when frames are throttled; gaps over a second (a backgrounded tab) are capped.
250
+ *
251
+ * Returns true once the spring has settled; the state is then exactly `target`.
252
+ */ function stepSpring(state, target, spring, elapsed) {
253
+ const steps = Math.ceil(Math.min(Math.max(elapsed, 0), MAX_FRAME_SECONDS) / STEP_SECONDS);
254
+ for(let step = 0; step < steps; step += 1){
255
+ const displacement = state.position - target;
256
+ const acceleration = (-spring.stiffness * displacement - spring.damping * state.velocity) / spring.mass;
257
+ state.velocity += acceleration * STEP_SECONDS;
258
+ state.position += state.velocity * STEP_SECONDS;
259
+ }
260
+ const settled = Math.abs(state.position - target) < REST_DELTA && Math.abs(state.velocity) < REST_SPEED;
261
+ if (settled) {
262
+ state.position = target;
263
+ state.velocity = 0;
264
+ }
265
+ return settled;
266
+ }
267
+
268
+ const DRAG_SLOP = 4;
269
+ const EDGE_RESISTANCE = 0.5;
270
+ const FLICK_PIXELS_PER_SECOND = 250;
271
+ const MAX_RELEASE_VELOCITY = 12;
272
+ const KEYBOARD_EDGE_DISTANCE = 0.03;
273
+ const WHEEL_SLIP = 0.1;
274
+ const WHEEL_GESTURE_DURATION = 150;
275
+ const WHEEL_RESTART_DELAY = 1000;
276
+ const WHEEL_INTENT_THRESHOLD = 8;
277
+ const WHEEL_HORIZONTAL_DOMINANCE = 1.4;
278
+ const WHEEL_INTENT_RESET_DELAY = 120;
279
+ const WHEEL_LINE_HEIGHT = 16;
280
+ const EDITABLE_SELECTOR = 'input, textarea, select';
281
+ const NO_DRAG_SELECTOR = `${EDITABLE_SELECTOR}, [data-carousel-no-drag]`;
282
+ const ARROW_KEY_WIDGET_SELECTOR = '[role="slider"], [role="tablist"], [role="radiogroup"], [role="menu"], [role="menubar"], [role="listbox"], [role="grid"], [role="tree"], [role="spinbutton"]';
283
+ const OFFSET_PROPERTY = '--carousel-offset';
284
+ const PRESENCE_PROPERTY = '--carousel-presence';
285
+ const PROGRESS_PROPERTY = '--carousel-progress';
286
+ const SERVER_SNAPSHOT = Object.freeze({
287
+ count: 0,
288
+ tabCount: 0,
289
+ isDragging: false
290
+ });
291
+ function matches(target, selector) {
292
+ return target instanceof HTMLElement && (target.isContentEditable || target.closest(selector) !== null);
293
+ }
294
+ function format(value) {
295
+ return value.toFixed(4);
296
+ }
297
+ /**
298
+ * Owns everything that happens per frame: measuring, gestures, keyboard input and
299
+ * the spring. React tells it which elements exist and which index to rest on; it
300
+ * tells React when a gesture asks for a new index. It never re-renders React during
301
+ * an animation.
302
+ */ class CarouselEngine {
303
+ #options;
304
+ #onIndexRequest;
305
+ #snapshot;
306
+ #snapshotListeners;
307
+ #root;
308
+ #viewport;
309
+ #track;
310
+ #tabList;
311
+ #slides;
312
+ #tabs;
313
+ #slideCenters;
314
+ #tabCenters;
315
+ #trackCenter;
316
+ #trackWidth;
317
+ #target;
318
+ #springTarget;
319
+ #springState;
320
+ #frame;
321
+ #lastFrameTime;
322
+ #measureFrame;
323
+ #started;
324
+ #unbind;
325
+ #resizeObserver;
326
+ #motionQuery;
327
+ #reduceMotion;
328
+ #pointer;
329
+ #releasePointerListeners;
330
+ #suppressClick;
331
+ #tracker;
332
+ #wheelCanStart;
333
+ #wheelTracking;
334
+ #wheelAxis;
335
+ #wheelAnchor;
336
+ #wheelTravel;
337
+ #wheelAccumulatedX;
338
+ #wheelAccumulatedY;
339
+ #wheelEndTimer;
340
+ #wheelRestartTimer;
341
+ #wheelIntentTimer;
342
+ #keyboardRubberbanding;
343
+ constructor(initialIndex){
344
+ this.#options = {
345
+ spring: DEFAULT_SPRING,
346
+ keyboard: 'focus',
347
+ wheel: true,
348
+ draggable: true,
349
+ reducedMotion: 'user'
350
+ };
351
+ this.#onIndexRequest = null;
352
+ this.#snapshot = SERVER_SNAPSHOT;
353
+ this.#snapshotListeners = new Set();
354
+ this.#root = null;
355
+ this.#viewport = null;
356
+ this.#track = null;
357
+ this.#tabList = null;
358
+ this.#slides = new Map();
359
+ this.#tabs = new Map();
360
+ this.#slideCenters = [];
361
+ this.#tabCenters = [];
362
+ this.#trackCenter = 0;
363
+ this.#trackWidth = 0;
364
+ this.#springState = {
365
+ position: 0,
366
+ velocity: 0
367
+ };
368
+ this.#frame = 0;
369
+ this.#lastFrameTime = 0;
370
+ this.#measureFrame = 0;
371
+ this.#started = false;
372
+ this.#unbind = null;
373
+ this.#resizeObserver = null;
374
+ this.#motionQuery = null;
375
+ this.#reduceMotion = false;
376
+ this.#pointer = null;
377
+ this.#releasePointerListeners = null;
378
+ this.#suppressClick = false;
379
+ this.#tracker = new VelocityTracker();
380
+ this.#wheelCanStart = true;
381
+ this.#wheelTracking = false;
382
+ this.#wheelAxis = null;
383
+ this.#wheelAnchor = 0;
384
+ this.#wheelTravel = 0;
385
+ this.#wheelAccumulatedX = 0;
386
+ this.#wheelAccumulatedY = 0;
387
+ this.#wheelEndTimer = 0;
388
+ this.#wheelRestartTimer = 0;
389
+ this.#wheelIntentTimer = 0;
390
+ this.#keyboardRubberbanding = false;
391
+ // External store, read by React through useSyncExternalStore.
392
+ this.getSnapshot = ()=>this.#snapshot;
393
+ this.subscribe = (listener)=>{
394
+ this.#snapshotListeners.add(listener);
395
+ return ()=>{
396
+ this.#snapshotListeners.delete(listener);
397
+ };
398
+ };
399
+ // Element registration. Each returns a cleanup for React 19 callback refs.
400
+ this.registerRoot = (element)=>this.#registerElement('root', element);
401
+ this.registerViewport = (element)=>this.#registerElement('viewport', element);
402
+ this.registerTrack = (element)=>this.#registerElement('track', element);
403
+ this.registerTabList = (element)=>this.#registerElement('tabList', element);
404
+ this.#updateReducedMotion = ()=>{
405
+ const mode = this.#options.reducedMotion;
406
+ this.#reduceMotion = mode === 'always' || mode === 'user' && !!this.#motionQuery?.matches;
407
+ };
408
+ this.#tick = (time)=>{
409
+ const elapsed = (time - this.#lastFrameTime) / 1000;
410
+ this.#lastFrameTime = time;
411
+ const settled = stepSpring(this.#springState, this.#springTarget, this.#options.spring, elapsed);
412
+ this.#setPosition(this.#springState.position, this.#springState.velocity);
413
+ this.#frame = settled ? 0 : requestAnimationFrame(this.#tick);
414
+ };
415
+ // Pointer dragging.
416
+ this.#onPointerDown = (event)=>{
417
+ if (!this.#options.draggable || !event.isPrimary || event.button !== 0) return;
418
+ if (this.#trackWidth === 0 || matches(event.target, NO_DRAG_SELECTOR)) return;
419
+ // A new primary press means the last gesture's pointerup never arrived.
420
+ if (this.#pointer) this.#endPointer(null);
421
+ this.#suppressClick = false;
422
+ this.#cancelAnimation();
423
+ this.#tracker.reset();
424
+ this.#tracker.add(event.timeStamp, event.clientX);
425
+ this.#pointer = {
426
+ id: event.pointerId,
427
+ startX: event.clientX,
428
+ startY: event.clientY,
429
+ lastX: event.clientX,
430
+ anchor: this.#target,
431
+ dragging: false
432
+ };
433
+ // Listened for on the window, because the pointer is only captured once a drag
434
+ // begins: capturing on press would retarget clicks on links inside slides. Losing
435
+ // capture is not an end: Chromium can fire lostpointercapture before pointerup
436
+ // when a trackpad click lifts, which would discard the flick.
437
+ window.addEventListener('pointermove', this.#onPointerMove);
438
+ window.addEventListener('pointerup', this.#onPointerUp);
439
+ window.addEventListener('pointercancel', this.#onPointerCancel);
440
+ this.#releasePointerListeners = ()=>{
441
+ window.removeEventListener('pointermove', this.#onPointerMove);
442
+ window.removeEventListener('pointerup', this.#onPointerUp);
443
+ window.removeEventListener('pointercancel', this.#onPointerCancel);
444
+ };
445
+ };
446
+ this.#onPointerMove = (event)=>{
447
+ const pointer = this.#pointer;
448
+ if (!pointer || event.pointerId !== pointer.id) return;
449
+ if (event.buttons === 0) {
450
+ this.#endPointer(event);
451
+ return;
452
+ }
453
+ if (!pointer.dragging) {
454
+ const distanceX = Math.abs(event.clientX - pointer.startX);
455
+ const distanceY = Math.abs(event.clientY - pointer.startY);
456
+ if (distanceX < DRAG_SLOP && distanceY < DRAG_SLOP) return;
457
+ if (distanceY > distanceX) {
458
+ this.#endPointer(null);
459
+ return;
460
+ }
461
+ pointer.dragging = true;
462
+ this.#suppressClick = true;
463
+ this.#viewport?.setPointerCapture(pointer.id);
464
+ this.#updateSnapshot({
465
+ isDragging: true
466
+ });
467
+ }
468
+ const delta = event.clientX - pointer.lastX;
469
+ pointer.lastX = event.clientX;
470
+ this.#tracker.add(event.timeStamp, event.clientX);
471
+ this.#scrollBy(-delta, -this.#tracker.velocity(event.timeStamp));
472
+ };
473
+ this.#onPointerUp = (event)=>{
474
+ if (this.#pointer?.id === event.pointerId) this.#endPointer(event);
475
+ };
476
+ this.#onPointerCancel = (event)=>{
477
+ if (this.#pointer?.id === event.pointerId) this.#endPointer(null);
478
+ };
479
+ this.#onClickCapture = (event)=>{
480
+ if (!this.#suppressClick) return;
481
+ this.#suppressClick = false;
482
+ event.preventDefault();
483
+ event.stopPropagation();
484
+ };
485
+ this.#onDragStart = (event)=>{
486
+ if (this.#options.draggable) event.preventDefault();
487
+ };
488
+ // Trackpad and horizontal wheel swipes, which page one slide per gesture.
489
+ this.#onWheel = (event)=>{
490
+ if (!this.#options.wheel || this.#trackWidth === 0) return;
491
+ const { x, y } = this.#wheelDelta(event);
492
+ if (x === 0 && y === 0) return;
493
+ if (this.#wheelAxis === 'horizontal') {
494
+ event.preventDefault();
495
+ if (this.#wheelTracking && x !== 0) this.#trackWheel(event.timeStamp, x);
496
+ return;
497
+ }
498
+ if (!this.#wheelCanStart) return;
499
+ if (this.#wheelAxis === 'vertical') {
500
+ this.#scheduleWheelIntentReset();
501
+ return;
502
+ }
503
+ this.#wheelAxis = 'pending';
504
+ this.#wheelAccumulatedX += Math.abs(x);
505
+ this.#wheelAccumulatedY += Math.abs(y);
506
+ this.#scheduleWheelIntentReset();
507
+ if (Math.max(this.#wheelAccumulatedX, this.#wheelAccumulatedY) < WHEEL_INTENT_THRESHOLD) return;
508
+ if (this.#wheelAccumulatedY >= this.#wheelAccumulatedX) {
509
+ this.#wheelAxis = 'vertical';
510
+ return;
511
+ }
512
+ if (this.#wheelAccumulatedX < this.#wheelAccumulatedY * WHEEL_HORIZONTAL_DOMINANCE) return;
513
+ event.preventDefault();
514
+ this.#startWheelGesture();
515
+ this.#trackWheel(event.timeStamp, x);
516
+ };
517
+ this.#resetWheelIntent = ()=>{
518
+ window.clearTimeout(this.#wheelIntentTimer);
519
+ this.#wheelAxis = null;
520
+ this.#wheelAccumulatedX = 0;
521
+ this.#wheelAccumulatedY = 0;
522
+ };
523
+ // Keyboard.
524
+ this.#onKeyDown = (event)=>{
525
+ if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey) return;
526
+ const target = event.target;
527
+ if (matches(target, EDITABLE_SELECTOR) || this.#belongsToOtherWidget(target)) return;
528
+ const fromTab = target instanceof Element && this.#isOwnTab(target);
529
+ const lastIndex = this.count - 1;
530
+ if (lastIndex < 0) return;
531
+ if (fromTab && (event.key === 'Home' || event.key === 'End')) {
532
+ event.preventDefault();
533
+ this.navigate(event.key === 'Home' ? 0 : lastIndex);
534
+ this.#focusTab(this.#target);
535
+ return;
536
+ }
537
+ if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;
538
+ event.preventDefault();
539
+ const direction = event.key === 'ArrowRight' ? 1 : -1;
540
+ const atBoundary = direction < 0 ? this.#target <= 0 : this.#target >= lastIndex;
541
+ if (!atBoundary) {
542
+ this.#keyboardRubberbanding = false;
543
+ this.navigate(this.#target + direction);
544
+ if (fromTab) this.#focusTab(this.#target);
545
+ return;
546
+ }
547
+ if (!event.repeat && !this.#reduceMotion && !this.#isTrackingInput()) {
548
+ this.#keyboardRubberbanding = true;
549
+ this.#animate(this.#target + direction * KEYBOARD_EDGE_DISTANCE, this.progress.getVelocity());
550
+ }
551
+ };
552
+ this.#onKeyUp = (event)=>{
553
+ if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;
554
+ if (!this.#keyboardRubberbanding) return;
555
+ this.#keyboardRubberbanding = false;
556
+ this.#animate(this.#target, this.progress.getVelocity());
557
+ };
558
+ this.#target = initialIndex;
559
+ this.#springTarget = initialIndex;
560
+ this.progress = new ProgressValue(initialIndex);
561
+ }
562
+ #updateSnapshot(patch) {
563
+ const next = {
564
+ ...this.#snapshot,
565
+ ...patch
566
+ };
567
+ if (next.count === this.#snapshot.count && next.tabCount === this.#snapshot.tabCount && next.isDragging === this.#snapshot.isDragging) {
568
+ return;
569
+ }
570
+ this.#snapshot = next;
571
+ for (const listener of this.#snapshotListeners)listener();
572
+ }
573
+ get count() {
574
+ return this.#slides.size;
575
+ }
576
+ get target() {
577
+ return this.#target;
578
+ }
579
+ // Configuration.
580
+ setOptions(options) {
581
+ const previous = this.#options;
582
+ this.#options = options;
583
+ if (!this.#started) return;
584
+ if (previous.reducedMotion !== options.reducedMotion) this.#updateReducedMotion();
585
+ if (previous.keyboard !== options.keyboard) this.#rebind();
586
+ if (!options.draggable && this.#pointer) this.#endPointer(null);
587
+ }
588
+ onIndexRequest(listener) {
589
+ this.#onIndexRequest = listener;
590
+ return ()=>{
591
+ if (this.#onIndexRequest === listener) this.#onIndexRequest = null;
592
+ };
593
+ }
594
+ registerSlide(index, element) {
595
+ return this.#registerItem(this.#slides, index, element);
596
+ }
597
+ registerTab(index, element) {
598
+ return this.#registerItem(this.#tabs, index, element);
599
+ }
600
+ #registerElement(slot, element) {
601
+ this.#setElement(slot, element);
602
+ this.#elementsChanged(element);
603
+ return ()=>{
604
+ if (this.#getElement(slot) !== element) return;
605
+ this.#setElement(slot, null);
606
+ this.#elementsChanged(null);
607
+ };
608
+ }
609
+ #registerItem(items, index, element) {
610
+ items.set(index, element);
611
+ this.#itemsChanged(element);
612
+ return ()=>{
613
+ if (items.get(index) !== element) return;
614
+ items.delete(index);
615
+ this.#itemsChanged(null);
616
+ };
617
+ }
618
+ #getElement(slot) {
619
+ if (slot === 'root') return this.#root;
620
+ if (slot === 'viewport') return this.#viewport;
621
+ if (slot === 'track') return this.#track;
622
+ return this.#tabList;
623
+ }
624
+ #setElement(slot, element) {
625
+ if (slot === 'root') this.#root = element;
626
+ else if (slot === 'viewport') this.#viewport = element;
627
+ else if (slot === 'track') this.#track = element;
628
+ else this.#tabList = element;
629
+ }
630
+ #elementsChanged(added) {
631
+ if (!this.#started) return;
632
+ if (added) this.#resizeObserver?.observe(added);
633
+ this.#rebind();
634
+ this.#scheduleMeasure();
635
+ }
636
+ #itemsChanged(added) {
637
+ this.#updateSnapshot({
638
+ count: this.#slides.size,
639
+ tabCount: this.#tabs.size
640
+ });
641
+ if (!this.#started) return;
642
+ if (added) this.#resizeObserver?.observe(added);
643
+ this.#scheduleMeasure();
644
+ }
645
+ // Lifecycle.
646
+ start() {
647
+ if (this.#started) return ()=>this.stop();
648
+ this.#started = true;
649
+ this.#motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
650
+ this.#motionQuery.addEventListener('change', this.#updateReducedMotion);
651
+ this.#updateReducedMotion();
652
+ this.#resizeObserver = new ResizeObserver(()=>this.#measure());
653
+ for (const element of this.#observedElements())this.#resizeObserver.observe(element);
654
+ this.#rebind();
655
+ this.#measure();
656
+ this.#jump(this.#target);
657
+ return ()=>this.stop();
658
+ }
659
+ stop() {
660
+ if (!this.#started) return;
661
+ this.#started = false;
662
+ this.#cancelAnimation();
663
+ cancelAnimationFrame(this.#measureFrame);
664
+ this.#unbind?.();
665
+ this.#unbind = null;
666
+ this.#resizeObserver?.disconnect();
667
+ this.#resizeObserver = null;
668
+ this.#motionQuery?.removeEventListener('change', this.#updateReducedMotion);
669
+ this.#motionQuery = null;
670
+ this.#resetWheel();
671
+ window.clearTimeout(this.#wheelRestartTimer);
672
+ this.#wheelCanStart = true;
673
+ this.#releasePointerListeners?.();
674
+ this.#releasePointerListeners = null;
675
+ this.#pointer = null;
676
+ this.#updateSnapshot({
677
+ isDragging: false
678
+ });
679
+ }
680
+ *#observedElements() {
681
+ for (const element of [
682
+ this.#viewport,
683
+ this.#track,
684
+ this.#tabList
685
+ ]){
686
+ if (element) yield element;
687
+ }
688
+ yield* this.#slides.values();
689
+ yield* this.#tabs.values();
690
+ }
691
+ #updateReducedMotion;
692
+ #rebind() {
693
+ this.#unbind?.();
694
+ this.#unbind = null;
695
+ if (!this.#started) return;
696
+ const cleanups = [];
697
+ const listen = (target, type, listener, options)=>{
698
+ target.addEventListener(type, listener, options);
699
+ cleanups.push(()=>target.removeEventListener(type, listener, options));
700
+ };
701
+ const viewport = this.#viewport;
702
+ if (viewport) {
703
+ listen(viewport, 'pointerdown', this.#onPointerDown);
704
+ listen(viewport, 'click', this.#onClickCapture, {
705
+ capture: true
706
+ });
707
+ listen(viewport, 'dragstart', this.#onDragStart);
708
+ listen(viewport, 'wheel', this.#onWheel, {
709
+ passive: false
710
+ });
711
+ }
712
+ const keyboard = this.#options.keyboard;
713
+ const keyTarget = keyboard === 'global' ? document : keyboard === 'focus' ? this.#root : null;
714
+ if (keyTarget) {
715
+ listen(keyTarget, 'keydown', this.#onKeyDown);
716
+ listen(keyTarget, 'keyup', this.#onKeyUp);
717
+ }
718
+ this.#unbind = ()=>{
719
+ for (const cleanup of cleanups)cleanup();
720
+ };
721
+ }
722
+ // Measuring and rendering.
723
+ #scheduleMeasure() {
724
+ cancelAnimationFrame(this.#measureFrame);
725
+ this.#measureFrame = requestAnimationFrame(()=>this.#measure());
726
+ }
727
+ #measure() {
728
+ const track = this.#track;
729
+ if (!track) return;
730
+ const width = track.clientWidth;
731
+ if (width === 0) return;
732
+ this.#trackWidth = width;
733
+ this.#trackCenter = width / 2;
734
+ this.#slideCenters = this.#centers(this.#slides, width);
735
+ const tabList = this.#tabList;
736
+ if (tabList && this.#tabs.size > 0) {
737
+ const pixelRatio = window.devicePixelRatio;
738
+ this.#tabCenters = this.#centers(this.#tabs, 0).map((center)=>snapToDevicePixels(center, pixelRatio));
739
+ tabList.setAttribute('data-measured', '');
740
+ }
741
+ const lastIndex = Math.max(this.count - 1, 0);
742
+ if (this.#target > lastIndex) {
743
+ this.navigate(lastIndex, {
744
+ animate: false
745
+ });
746
+ return;
747
+ }
748
+ this.#render(this.progress.get());
749
+ }
750
+ #centers(items, fallbackSpacing) {
751
+ const centers = [];
752
+ for(let index = 0; index < items.size; index += 1){
753
+ const element = items.get(index);
754
+ const previous = centers[index - 1];
755
+ centers.push(element ? element.offsetLeft + element.offsetWidth / 2 : previous === undefined ? fallbackSpacing / 2 : previous + fallbackSpacing);
756
+ }
757
+ return centers;
758
+ }
759
+ #render(position) {
760
+ if (this.#trackWidth === 0) return;
761
+ const track = this.#track;
762
+ if (track) {
763
+ const x = this.#trackCenter - interpolate(this.#slideCenters, position);
764
+ track.style.transform = `translate3d(${x}px, 0, 0)`;
765
+ }
766
+ const tabList = this.#tabList;
767
+ if (tabList && this.#tabCenters.length > 0) {
768
+ const x = -interpolate(this.#tabCenters, position);
769
+ tabList.style.transform = `translate3d(${x}px, 0, 0)`;
770
+ }
771
+ this.#root?.style.setProperty(PROGRESS_PROPERTY, format(position));
772
+ this.#writeItemProperties(this.#slides, position);
773
+ this.#writeItemProperties(this.#tabs, position);
774
+ }
775
+ #writeItemProperties(items, position) {
776
+ for (const [index, element] of items){
777
+ element.style.setProperty(OFFSET_PROPERTY, format(index - position));
778
+ element.style.setProperty(PRESENCE_PROPERTY, format(presence(position, index)));
779
+ }
780
+ }
781
+ #setPosition(position, velocity) {
782
+ this.progress.set(position, velocity);
783
+ this.#render(position);
784
+ }
785
+ // Animation.
786
+ #isTrackingInput() {
787
+ return this.#pointer !== null || this.#wheelTracking;
788
+ }
789
+ #cancelAnimation() {
790
+ cancelAnimationFrame(this.#frame);
791
+ this.#frame = 0;
792
+ }
793
+ #jump(position) {
794
+ this.#cancelAnimation();
795
+ this.#springTarget = position;
796
+ this.#setPosition(position, 0);
797
+ }
798
+ #animate(target, velocity) {
799
+ this.#springTarget = target;
800
+ if (this.#reduceMotion || !this.#started) {
801
+ this.#jump(target);
802
+ return;
803
+ }
804
+ this.#springState.position = this.progress.get();
805
+ this.#springState.velocity = velocity;
806
+ if (this.#frame !== 0) return;
807
+ this.#lastFrameTime = performance.now();
808
+ this.#frame = requestAnimationFrame(this.#tick);
809
+ }
810
+ #tick;
811
+ // Navigation.
812
+ /** Moves to `index` and asks React to make it the current index. */ navigate(index, options = {}) {
813
+ const target = clamp(Math.round(index), 0, Math.max(this.count - 1, 0));
814
+ this.#target = target;
815
+ if (options.animate === false) this.#jump(target);
816
+ else this.#animate(target, options.velocity ?? this.progress.getVelocity());
817
+ this.#onIndexRequest?.(target);
818
+ }
819
+ next(options) {
820
+ this.navigate(this.#target + 1, options);
821
+ }
822
+ prev(options) {
823
+ this.navigate(this.#target - 1, options);
824
+ }
825
+ /**
826
+ * Called by React after every index change or rejected request: brings the
827
+ * carousel to rest on the index React holds. A no-op when it is already heading
828
+ * there, and deferred while a finger or trackpad is moving it.
829
+ */ settle(index) {
830
+ if (this.#isTrackingInput()) return;
831
+ const target = this.count > 0 ? clamp(index, 0, this.count - 1) : index;
832
+ if (target === this.#target && target === this.#springTarget) return;
833
+ this.#target = target;
834
+ this.#animate(target, this.progress.getVelocity());
835
+ }
836
+ /**
837
+ * Settles a gesture. `intent` picks the slide and `momentum` starts the spring,
838
+ * both in pixels per second towards later slides.
839
+ */ #release(kind, anchor, intent, momentum) {
840
+ const position = this.progress.get();
841
+ const spacing = spacingAt(this.#slideCenters, position, this.#trackWidth);
842
+ const velocity = clamp(momentum / spacing, -MAX_RELEASE_VELOCITY, MAX_RELEASE_VELOCITY);
843
+ const target = resolveReleaseTarget({
844
+ position,
845
+ anchor,
846
+ velocity: intent / spacing,
847
+ count: this.count,
848
+ kind,
849
+ flickVelocity: FLICK_PIXELS_PER_SECOND / spacing
850
+ });
851
+ this.navigate(target, {
852
+ velocity
853
+ });
854
+ }
855
+ /** Moves by `pixels` of scroll: positive travels towards later slides. */ #scrollBy(pixels, scrollVelocity) {
856
+ if (this.#trackWidth === 0) return;
857
+ const position = this.progress.get();
858
+ const spacing = spacingAt(this.#slideCenters, position, this.#trackWidth);
859
+ let delta = pixels / spacing;
860
+ const next = position + delta;
861
+ if (next < 0 || next > this.count - 1) delta *= EDGE_RESISTANCE;
862
+ this.#setPosition(position + delta, scrollVelocity / spacing);
863
+ }
864
+ #onPointerDown;
865
+ #onPointerMove;
866
+ #onPointerUp;
867
+ #onPointerCancel;
868
+ /** Ends the gesture. A null event (cancelled or abandoned) settles by distance alone. */ #endPointer(event) {
869
+ const pointer = this.#pointer;
870
+ if (!pointer) return;
871
+ this.#pointer = null;
872
+ this.#releasePointerListeners?.();
873
+ this.#releasePointerListeners = null;
874
+ const viewport = this.#viewport;
875
+ if (viewport?.hasPointerCapture(pointer.id)) viewport.releasePointerCapture(pointer.id);
876
+ if (!pointer.dragging) {
877
+ this.#animate(this.#target, 0);
878
+ return;
879
+ }
880
+ this.#updateSnapshot({
881
+ isDragging: false
882
+ });
883
+ // A click-drag usually stops for a few frames before the button lifts, so the
884
+ // target follows the final stretch of movement, as Kami's did, while the spring
885
+ // only inherits the velocity the pointer still has.
886
+ const intent = event ? this.#tracker.intentVelocity() : 0;
887
+ const momentum = event ? this.#tracker.velocity(event.timeStamp) : 0;
888
+ this.#release('drag', pointer.anchor, -intent, -momentum);
889
+ }
890
+ #onClickCapture;
891
+ #onDragStart;
892
+ #onWheel;
893
+ #wheelDelta(event) {
894
+ if (event.deltaMode === WheelEvent.DOM_DELTA_LINE) {
895
+ return {
896
+ x: event.deltaX * WHEEL_LINE_HEIGHT,
897
+ y: event.deltaY * WHEEL_LINE_HEIGHT
898
+ };
899
+ }
900
+ const viewport = this.#viewport;
901
+ if (event.deltaMode === WheelEvent.DOM_DELTA_PAGE && viewport) {
902
+ return {
903
+ x: event.deltaX * viewport.clientWidth,
904
+ y: event.deltaY * viewport.clientHeight
905
+ };
906
+ }
907
+ return {
908
+ x: event.deltaX,
909
+ y: event.deltaY
910
+ };
911
+ }
912
+ #startWheelGesture() {
913
+ this.#wheelAxis = 'horizontal';
914
+ this.#wheelCanStart = false;
915
+ this.#wheelTracking = true;
916
+ this.#wheelAnchor = this.#target;
917
+ this.#wheelTravel = 0;
918
+ window.clearTimeout(this.#wheelIntentTimer);
919
+ this.#cancelAnimation();
920
+ this.#tracker.reset();
921
+ this.#wheelEndTimer = window.setTimeout(()=>{
922
+ this.#wheelTracking = false;
923
+ this.#release('wheel', this.#wheelAnchor, this.#tracker.intentVelocity(), this.#tracker.velocity(performance.now()));
924
+ this.#wheelRestartTimer = window.setTimeout(()=>{
925
+ this.#wheelCanStart = true;
926
+ this.#resetWheelIntent();
927
+ }, WHEEL_RESTART_DELAY);
928
+ }, WHEEL_GESTURE_DURATION);
929
+ }
930
+ #trackWheel(time, deltaX) {
931
+ const pixels = deltaX * WHEEL_SLIP;
932
+ this.#wheelTravel += pixels;
933
+ this.#tracker.add(time, this.#wheelTravel);
934
+ this.#scrollBy(pixels, this.#tracker.velocity(time));
935
+ }
936
+ #scheduleWheelIntentReset() {
937
+ window.clearTimeout(this.#wheelIntentTimer);
938
+ this.#wheelIntentTimer = window.setTimeout(this.#resetWheelIntent, WHEEL_INTENT_RESET_DELAY);
939
+ }
940
+ #resetWheelIntent;
941
+ #resetWheel() {
942
+ window.clearTimeout(this.#wheelEndTimer);
943
+ this.#wheelTracking = false;
944
+ this.#resetWheelIntent();
945
+ }
946
+ #onKeyDown;
947
+ #onKeyUp;
948
+ #isOwnTab(element) {
949
+ for (const tab of this.#tabs.values())if (tab === element) return true;
950
+ return false;
951
+ }
952
+ /** Leaves arrow keys to sliders, menus and other widgets that use them, except our tabs. */ #belongsToOtherWidget(target) {
953
+ if (!(target instanceof Element)) return false;
954
+ const widget = target.closest(ARROW_KEY_WIDGET_SELECTOR);
955
+ return widget !== null && widget !== this.#tabList;
956
+ }
957
+ #focusTab(index) {
958
+ this.#tabs.get(index)?.focus({
959
+ preventScroll: true
960
+ });
961
+ }
962
+ }
963
+
964
+ /**
965
+ * Structural styles only: what the carousel needs to lay out and move. Every
966
+ * selector is wrapped in `:where()`, so any class you write overrides it.
967
+ */ const styles = `
968
+ :where([data-spring-carousel]) {
969
+ position: relative;
970
+ overflow-x: clip;
971
+ }
972
+
973
+ :where([data-spring-carousel-viewport]) {
974
+ position: relative;
975
+ overflow: hidden;
976
+ touch-action: pan-y pinch-zoom;
977
+ user-select: none;
978
+ -webkit-user-select: none;
979
+ }
980
+
981
+ :where([data-spring-carousel-viewport][data-draggable]) {
982
+ cursor: grab;
983
+ }
984
+
985
+ :where([data-spring-carousel-viewport][data-draggable]:active),
986
+ :where([data-spring-carousel][data-dragging] [data-spring-carousel-viewport]) {
987
+ cursor: grabbing;
988
+ }
989
+
990
+ :where([data-spring-carousel-track]) {
991
+ position: relative;
992
+ display: flex;
993
+ gap: var(--carousel-gap, 0px);
994
+ transform: translate3d(
995
+ calc(
996
+ (100% - var(--carousel-slide-size, 100%)) / 2 - var(--carousel-initial-index, 0) *
997
+ (var(--carousel-slide-size, 100%) + var(--carousel-gap, 0px))
998
+ ),
999
+ 0,
1000
+ 0
1001
+ );
1002
+ will-change: transform;
1003
+ }
1004
+
1005
+ :where([data-spring-carousel-slide]) {
1006
+ position: relative;
1007
+ flex: 0 0 var(--carousel-slide-size, 100%);
1008
+ min-width: 0;
1009
+ }
1010
+
1011
+ :where([data-spring-carousel-slide] :is(img, video)) {
1012
+ -webkit-user-drag: none;
1013
+ }
1014
+
1015
+ :where([data-spring-carousel-tabs]) {
1016
+ position: relative;
1017
+ display: flex;
1018
+ width: max-content;
1019
+ margin-inline-start: 50%;
1020
+ white-space: nowrap;
1021
+ will-change: transform;
1022
+ }
1023
+
1024
+ :where([data-spring-carousel-tabs]:not([data-measured])) {
1025
+ visibility: hidden;
1026
+ }
1027
+
1028
+ :where([data-spring-carousel-tab]) {
1029
+ --carousel-inactive-opacity: 0.25;
1030
+ opacity: calc(
1031
+ var(--carousel-inactive-opacity) + (1 - var(--carousel-inactive-opacity)) *
1032
+ var(--carousel-presence, 0)
1033
+ );
1034
+ }
1035
+ `;
1036
+
1037
+ function assignRef(ref, node) {
1038
+ if (typeof ref === 'function') {
1039
+ const cleanup = ref(node);
1040
+ return typeof cleanup === 'function' ? cleanup : ()=>ref(null);
1041
+ }
1042
+ if (ref) {
1043
+ ref.current = node;
1044
+ return ()=>{
1045
+ ref.current = null;
1046
+ };
1047
+ }
1048
+ return ()=>{};
1049
+ }
1050
+ /**
1051
+ * Combines a ref passed in as a prop with a part's own registration callback,
1052
+ * using React 19 callback-ref cleanups so each element is unregistered exactly
1053
+ * once, including under StrictMode's double mount.
1054
+ */ function useMergedRefs(forwarded, own) {
1055
+ return react.useCallback((node)=>{
1056
+ if (node === null) return;
1057
+ const cleanupOwn = own(node);
1058
+ const cleanupForwarded = assignRef(forwarded, node);
1059
+ return ()=>{
1060
+ cleanupForwarded();
1061
+ cleanupOwn();
1062
+ };
1063
+ }, [
1064
+ forwarded,
1065
+ own
1066
+ ]);
1067
+ }
1068
+
1069
+ const getServerSnapshot = ()=>SERVER_SNAPSHOT;
1070
+ function initialItemStyle(index, initialIndex, style) {
1071
+ return {
1072
+ [OFFSET_PROPERTY]: index - initialIndex,
1073
+ [PRESENCE_PROPERTY]: index === initialIndex ? 1 : 0,
1074
+ ...style
1075
+ };
1076
+ }
1077
+ function CarouselRoot({ index: indexProp, defaultIndex = 0, onIndexChange, spring, keyboard = 'focus', wheel = true, draggable = true, reducedMotion = 'user', ref, style, children, ...props }) {
1078
+ const [uncontrolledIndex, setUncontrolledIndex] = react.useState(defaultIndex);
1079
+ const isControlled = indexProp !== undefined;
1080
+ const index = isControlled ? indexProp : uncontrolledIndex;
1081
+ const [initialIndex] = react.useState(index);
1082
+ const [engine] = react.useState(()=>new CarouselEngine(initialIndex));
1083
+ const snapshot = react.useSyncExternalStore(engine.subscribe, engine.getSnapshot, getServerSnapshot);
1084
+ const [, requestSettle] = react.useReducer((count)=>count + 1, 0);
1085
+ const baseId = react.useId();
1086
+ const rootRef = useMergedRefs(ref, engine.registerRoot);
1087
+ const handleIndexRequest = react.useEffectEvent((next)=>{
1088
+ // Re-render even when the index is unchanged, so a controlled parent that
1089
+ // rejects the request still sends the carousel back to the index it holds.
1090
+ requestSettle();
1091
+ if (next === index) return;
1092
+ if (!isControlled) setUncontrolledIndex(next);
1093
+ onIndexChange?.(next);
1094
+ });
1095
+ react.useLayoutEffect(()=>engine.onIndexRequest((next)=>handleIndexRequest(next)), [
1096
+ engine
1097
+ ]);
1098
+ react.useLayoutEffect(()=>{
1099
+ engine.setOptions({
1100
+ spring: resolveSpring(spring),
1101
+ keyboard,
1102
+ wheel,
1103
+ draggable,
1104
+ reducedMotion
1105
+ });
1106
+ }, [
1107
+ engine,
1108
+ spring,
1109
+ keyboard,
1110
+ wheel,
1111
+ draggable,
1112
+ reducedMotion
1113
+ ]);
1114
+ react.useLayoutEffect(()=>engine.start(), [
1115
+ engine
1116
+ ]);
1117
+ react.useLayoutEffect(()=>{
1118
+ engine.settle(index);
1119
+ });
1120
+ const context = react.useMemo(()=>({
1121
+ engine,
1122
+ baseId,
1123
+ initialIndex,
1124
+ draggable,
1125
+ goTo: (target, options)=>engine.navigate(target, options),
1126
+ next: (options)=>engine.next(options),
1127
+ prev: (options)=>engine.prev(options)
1128
+ }), [
1129
+ engine,
1130
+ baseId,
1131
+ initialIndex,
1132
+ draggable
1133
+ ]);
1134
+ const state = react.useMemo(()=>({
1135
+ index,
1136
+ count: snapshot.count,
1137
+ isDragging: snapshot.isDragging,
1138
+ hasTabs: snapshot.tabCount > 0
1139
+ }), [
1140
+ index,
1141
+ snapshot
1142
+ ]);
1143
+ return /*#__PURE__*/ jsxRuntime.jsx(CarouselContext, {
1144
+ value: context,
1145
+ children: /*#__PURE__*/ jsxRuntime.jsx(CarouselStateContext, {
1146
+ value: state,
1147
+ children: /*#__PURE__*/ jsxRuntime.jsxs("section", {
1148
+ "aria-roledescription": "carousel",
1149
+ ...props,
1150
+ ref: rootRef,
1151
+ "data-spring-carousel": "",
1152
+ "data-dragging": snapshot.isDragging ? '' : undefined,
1153
+ style: {
1154
+ ...style,
1155
+ '--carousel-initial-index': initialIndex
1156
+ },
1157
+ children: [
1158
+ /*#__PURE__*/ jsxRuntime.jsx("style", {
1159
+ href: "spring-carousel",
1160
+ precedence: "default",
1161
+ children: styles
1162
+ }),
1163
+ children
1164
+ ]
1165
+ })
1166
+ })
1167
+ });
1168
+ }
1169
+ function CarouselViewport({ ref, ...props }) {
1170
+ const { context } = useCarouselContext('Carousel.Viewport');
1171
+ const viewportRef = useMergedRefs(ref, context.engine.registerViewport);
1172
+ return /*#__PURE__*/ jsxRuntime.jsx("div", {
1173
+ ...props,
1174
+ ref: viewportRef,
1175
+ "data-spring-carousel-viewport": "",
1176
+ "data-draggable": context.draggable ? '' : undefined
1177
+ });
1178
+ }
1179
+ function CarouselTrack({ ref, ...props }) {
1180
+ const { context } = useCarouselContext('Carousel.Track');
1181
+ const trackRef = useMergedRefs(ref, context.engine.registerTrack);
1182
+ return /*#__PURE__*/ jsxRuntime.jsx("div", {
1183
+ "aria-live": "polite",
1184
+ ...props,
1185
+ ref: trackRef,
1186
+ id: trackId(context.baseId),
1187
+ "data-spring-carousel-track": ""
1188
+ });
1189
+ }
1190
+ function CarouselSlide({ index, ref, style, children, ...props }) {
1191
+ const { context, state } = useCarouselContext('Carousel.Slide');
1192
+ const { engine, baseId, initialIndex } = context;
1193
+ const register = react.useCallback((element)=>engine.registerSlide(index, element), [
1194
+ engine,
1195
+ index
1196
+ ]);
1197
+ const slideRef = useMergedRefs(ref, register);
1198
+ const isActive = index === state.index;
1199
+ const slide = react.useMemo(()=>({
1200
+ index,
1201
+ isActive
1202
+ }), [
1203
+ index,
1204
+ isActive
1205
+ ]);
1206
+ return /*#__PURE__*/ jsxRuntime.jsx(SlideContext, {
1207
+ value: slide,
1208
+ children: /*#__PURE__*/ jsxRuntime.jsx("div", {
1209
+ role: state.hasTabs ? 'tabpanel' : 'group',
1210
+ "aria-roledescription": "slide",
1211
+ "aria-label": !state.hasTabs && state.count > 0 ? `${index + 1} of ${state.count}` : undefined,
1212
+ "aria-labelledby": state.hasTabs ? tabId(baseId, index) : undefined,
1213
+ ...props,
1214
+ ref: slideRef,
1215
+ id: slideId(baseId, index),
1216
+ inert: !isActive,
1217
+ "data-spring-carousel-slide": "",
1218
+ "data-active": isActive ? '' : undefined,
1219
+ style: initialItemStyle(index, initialIndex, style),
1220
+ children: children
1221
+ })
1222
+ });
1223
+ }
1224
+ function CarouselTabs({ ref, ...props }) {
1225
+ const { context } = useCarouselContext('Carousel.Tabs');
1226
+ const tabsRef = useMergedRefs(ref, context.engine.registerTabList);
1227
+ return /*#__PURE__*/ jsxRuntime.jsx("div", {
1228
+ "aria-label": "Choose a slide",
1229
+ ...props,
1230
+ ref: tabsRef,
1231
+ role: "tablist",
1232
+ "aria-orientation": "horizontal",
1233
+ "data-spring-carousel-tabs": ""
1234
+ });
1235
+ }
1236
+ function CarouselTab({ index, ref, style, onClick, ...props }) {
1237
+ const { context, state } = useCarouselContext('Carousel.Tab');
1238
+ const { engine, baseId, initialIndex, goTo } = context;
1239
+ const register = react.useCallback((element)=>engine.registerTab(index, element), [
1240
+ engine,
1241
+ index
1242
+ ]);
1243
+ const tabRef = useMergedRefs(ref, register);
1244
+ const isActive = index === state.index;
1245
+ function handleClick(event) {
1246
+ onClick?.(event);
1247
+ if (!event.defaultPrevented) goTo(index);
1248
+ }
1249
+ return /*#__PURE__*/ jsxRuntime.jsx("button", {
1250
+ type: "button",
1251
+ ...props,
1252
+ ref: tabRef,
1253
+ id: tabId(baseId, index),
1254
+ role: "tab",
1255
+ "aria-selected": isActive,
1256
+ "aria-controls": slideId(baseId, index),
1257
+ tabIndex: isActive ? 0 : -1,
1258
+ "data-spring-carousel-tab": "",
1259
+ "data-active": isActive ? '' : undefined,
1260
+ style: initialItemStyle(index, initialIndex, style),
1261
+ onClick: handleClick
1262
+ });
1263
+ }
1264
+ function Chevron({ direction }) {
1265
+ return /*#__PURE__*/ jsxRuntime.jsx("svg", {
1266
+ "aria-hidden": "true",
1267
+ width: "16",
1268
+ height: "16",
1269
+ viewBox: "0 0 16 16",
1270
+ fill: "none",
1271
+ stroke: "currentColor",
1272
+ strokeWidth: "1.5",
1273
+ strokeLinecap: "round",
1274
+ strokeLinejoin: "round",
1275
+ children: /*#__PURE__*/ jsxRuntime.jsx("path", {
1276
+ d: direction === 'left' ? 'M10 3 5 8l5 5' : 'm6 3 5 5-5 5'
1277
+ })
1278
+ });
1279
+ }
1280
+ function useStepButton(direction, onClick) {
1281
+ const { context, state } = useCarouselContext(direction === 'prev' ? 'Carousel.Prev' : 'Carousel.Next');
1282
+ const disabled = direction === 'prev' ? state.index <= 0 : state.index >= state.count - 1;
1283
+ function handleClick(event) {
1284
+ onClick?.(event);
1285
+ if (event.defaultPrevented || disabled) return;
1286
+ if (direction === 'prev') context.prev();
1287
+ else context.next();
1288
+ }
1289
+ return {
1290
+ 'aria-controls': trackId(context.baseId),
1291
+ 'aria-disabled': disabled || undefined,
1292
+ 'data-disabled': disabled ? '' : undefined,
1293
+ onClick: handleClick
1294
+ };
1295
+ }
1296
+ /**
1297
+ * Stays focusable at the first slide (with `aria-disabled`) so keyboard focus is
1298
+ * not dropped to the page when it stops being useful.
1299
+ */ function CarouselPrev({ onClick, children, ...props }) {
1300
+ const stepProps = useStepButton('prev', onClick);
1301
+ return /*#__PURE__*/ jsxRuntime.jsx("button", {
1302
+ type: "button",
1303
+ "aria-label": "Previous slide",
1304
+ ...props,
1305
+ ...stepProps,
1306
+ "data-spring-carousel-prev": "",
1307
+ children: children ?? /*#__PURE__*/ jsxRuntime.jsx(Chevron, {
1308
+ direction: "left"
1309
+ })
1310
+ });
1311
+ }
1312
+ function CarouselNext({ onClick, children, ...props }) {
1313
+ const stepProps = useStepButton('next', onClick);
1314
+ return /*#__PURE__*/ jsxRuntime.jsx("button", {
1315
+ type: "button",
1316
+ "aria-label": "Next slide",
1317
+ ...props,
1318
+ ...stepProps,
1319
+ "data-spring-carousel-next": "",
1320
+ children: children ?? /*#__PURE__*/ jsxRuntime.jsx(Chevron, {
1321
+ direction: "right"
1322
+ })
1323
+ });
1324
+ }
1325
+
1326
+ var parts = {
1327
+ __proto__: null,
1328
+ Next: CarouselNext,
1329
+ Prev: CarouselPrev,
1330
+ Root: CarouselRoot,
1331
+ Slide: CarouselSlide,
1332
+ Tab: CarouselTab,
1333
+ Tabs: CarouselTabs,
1334
+ Track: CarouselTrack,
1335
+ Viewport: CarouselViewport
1336
+ };
1337
+
1338
+ exports.Carousel = parts;
1339
+ exports.presence = presence;
1340
+ exports.useCarousel = useCarousel;
1341
+ exports.useCarouselProgress = useCarouselProgress;
1342
+ exports.useSlide = useSlide;