@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/LICENSE +21 -0
- package/README.md +188 -2
- package/dist/index.cjs +1342 -0
- package/dist/index.d.cts +158 -0
- package/dist/index.d.ts +158 -0
- package/dist/index.js +1338 -0
- package/package.json +64 -5
package/dist/index.js
ADDED
|
@@ -0,0 +1,1338 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { createContext, use, useMemo, useEffectEvent, useLayoutEffect, useCallback, useState, useSyncExternalStore, useReducer, useId } from 'react';
|
|
3
|
+
import { jsx, jsxs } from 'react/jsx-runtime';
|
|
4
|
+
|
|
5
|
+
const CarouselContext = createContext(null);
|
|
6
|
+
const CarouselStateContext = createContext(null);
|
|
7
|
+
const SlideContext = createContext(null);
|
|
8
|
+
function useCarouselContext(caller) {
|
|
9
|
+
const context = use(CarouselContext);
|
|
10
|
+
const state = 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 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 = useEffectEvent(listener);
|
|
65
|
+
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 = 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 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] = useState(defaultIndex);
|
|
1079
|
+
const isControlled = indexProp !== undefined;
|
|
1080
|
+
const index = isControlled ? indexProp : uncontrolledIndex;
|
|
1081
|
+
const [initialIndex] = useState(index);
|
|
1082
|
+
const [engine] = useState(()=>new CarouselEngine(initialIndex));
|
|
1083
|
+
const snapshot = useSyncExternalStore(engine.subscribe, engine.getSnapshot, getServerSnapshot);
|
|
1084
|
+
const [, requestSettle] = useReducer((count)=>count + 1, 0);
|
|
1085
|
+
const baseId = useId();
|
|
1086
|
+
const rootRef = useMergedRefs(ref, engine.registerRoot);
|
|
1087
|
+
const handleIndexRequest = 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
|
+
useLayoutEffect(()=>engine.onIndexRequest((next)=>handleIndexRequest(next)), [
|
|
1096
|
+
engine
|
|
1097
|
+
]);
|
|
1098
|
+
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
|
+
useLayoutEffect(()=>engine.start(), [
|
|
1115
|
+
engine
|
|
1116
|
+
]);
|
|
1117
|
+
useLayoutEffect(()=>{
|
|
1118
|
+
engine.settle(index);
|
|
1119
|
+
});
|
|
1120
|
+
const context = 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 = 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__*/ jsx(CarouselContext, {
|
|
1144
|
+
value: context,
|
|
1145
|
+
children: /*#__PURE__*/ jsx(CarouselStateContext, {
|
|
1146
|
+
value: state,
|
|
1147
|
+
children: /*#__PURE__*/ 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__*/ 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__*/ 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__*/ 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 = 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 = useMemo(()=>({
|
|
1200
|
+
index,
|
|
1201
|
+
isActive
|
|
1202
|
+
}), [
|
|
1203
|
+
index,
|
|
1204
|
+
isActive
|
|
1205
|
+
]);
|
|
1206
|
+
return /*#__PURE__*/ jsx(SlideContext, {
|
|
1207
|
+
value: slide,
|
|
1208
|
+
children: /*#__PURE__*/ 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__*/ 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 = 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__*/ 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__*/ 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__*/ 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__*/ jsx("button", {
|
|
1302
|
+
type: "button",
|
|
1303
|
+
"aria-label": "Previous slide",
|
|
1304
|
+
...props,
|
|
1305
|
+
...stepProps,
|
|
1306
|
+
"data-spring-carousel-prev": "",
|
|
1307
|
+
children: children ?? /*#__PURE__*/ jsx(Chevron, {
|
|
1308
|
+
direction: "left"
|
|
1309
|
+
})
|
|
1310
|
+
});
|
|
1311
|
+
}
|
|
1312
|
+
function CarouselNext({ onClick, children, ...props }) {
|
|
1313
|
+
const stepProps = useStepButton('next', onClick);
|
|
1314
|
+
return /*#__PURE__*/ jsx("button", {
|
|
1315
|
+
type: "button",
|
|
1316
|
+
"aria-label": "Next slide",
|
|
1317
|
+
...props,
|
|
1318
|
+
...stepProps,
|
|
1319
|
+
"data-spring-carousel-next": "",
|
|
1320
|
+
children: children ?? /*#__PURE__*/ 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
|
+
export { parts as Carousel, presence, useCarousel, useCarouselProgress, useSlide };
|