@mitchellslager/carousel 0.0.0-stage → 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +101 -0
- package/dist/index.js +558 -0
- package/dist/rolldown-runtime-D8jQpDG7.js +12 -0
- package/dist/styles.css +54 -0
- package/package.json +78 -4
- package/README.md +0 -3
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { ComponentProps, ReactElement, ReactNode } from "react";
|
|
2
|
+
/**
|
|
3
|
+
* Every text the carousel puts in the accessibility tree. Pass a partial
|
|
4
|
+
* object to `Carousel.Root`'s `labels` prop to translate or reword them.
|
|
5
|
+
* Indexes are zero-based.
|
|
6
|
+
*/
|
|
7
|
+
interface CarouselLabels {
|
|
8
|
+
/** `aria-roledescription` of the root. */
|
|
9
|
+
carousel: string;
|
|
10
|
+
/** `aria-roledescription` of each slide. */
|
|
11
|
+
slide: string;
|
|
12
|
+
/** `aria-label` of each slide. */
|
|
13
|
+
slideLabel: (index: number, total: number) => string;
|
|
14
|
+
/** `aria-label` of `Carousel.Prev`. */
|
|
15
|
+
previous: string;
|
|
16
|
+
/** `aria-label` of `Carousel.Next`. */
|
|
17
|
+
next: string;
|
|
18
|
+
/** `aria-label` of the thumbnail tab list. */
|
|
19
|
+
thumbnails: string;
|
|
20
|
+
/** `aria-label` of each thumbnail tab. */
|
|
21
|
+
thumbnailLabel: (index: number, total: number) => string;
|
|
22
|
+
/** Announced by the live region when the carousel settles on a slide. */
|
|
23
|
+
announcement: (index: number, total: number) => string;
|
|
24
|
+
}
|
|
25
|
+
export declare const defaultLabels: CarouselLabels;
|
|
26
|
+
interface RootProps extends Omit<ComponentProps<"div">, "onChange"> {
|
|
27
|
+
/**
|
|
28
|
+
* The requested slide (controlled). Changing it scrolls there, but a swipe
|
|
29
|
+
* always wins and reports itself through `onIndexChange`.
|
|
30
|
+
*/
|
|
31
|
+
index?: number;
|
|
32
|
+
/** The slide to start on (uncontrolled). Defaults to 0. */
|
|
33
|
+
defaultIndex?: number;
|
|
34
|
+
/** Called once scrolling settles on a different slide. */
|
|
35
|
+
onIndexChange?: (index: number) => void;
|
|
36
|
+
/** Overrides for the accessible texts. */
|
|
37
|
+
labels?: Partial<CarouselLabels>;
|
|
38
|
+
/**
|
|
39
|
+
* `"auto"` follows the user's `prefers-reduced-motion` setting; `"reduce"`
|
|
40
|
+
* always jumps instead of scrolling smoothly.
|
|
41
|
+
*/
|
|
42
|
+
motion?: "auto" | "reduce";
|
|
43
|
+
}
|
|
44
|
+
declare function Root({ index, defaultIndex, onIndexChange, labels: labelOverrides, motion, children, ...props }: RootProps): import("react").JSX.Element;
|
|
45
|
+
type Props = Record<string, unknown>;
|
|
46
|
+
/**
|
|
47
|
+
* Lets a part render as another element, Base UI style:
|
|
48
|
+
* `<Carousel.Next render={<MyButton />} />`. The element's own props are
|
|
49
|
+
* merged with the part's props.
|
|
50
|
+
*/
|
|
51
|
+
type RenderProp = ReactElement<Props>;
|
|
52
|
+
type ViewportProps = ComponentProps<"div">;
|
|
53
|
+
/** The scroll-snap container. Its direct `Carousel.Slide` children are the slides. */
|
|
54
|
+
declare function Viewport({ ref, children, ...props }: ViewportProps): import("react").JSX.Element;
|
|
55
|
+
type SlideProps = ComponentProps<"div">;
|
|
56
|
+
/** One slide. Slides that are not active are `inert`. */
|
|
57
|
+
declare function Slide({ ref, ...props }: SlideProps): import("react").JSX.Element;
|
|
58
|
+
interface NavigationProps extends ComponentProps<"button"> {
|
|
59
|
+
/** Render as another element, for example `render={<MyButton />}`. */
|
|
60
|
+
render?: RenderProp;
|
|
61
|
+
}
|
|
62
|
+
/** Scrolls to the previous slide. `aria-disabled` on the first slide. */
|
|
63
|
+
declare function Prev({ render, ...props }: NavigationProps): import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>>;
|
|
64
|
+
/** Scrolls to the next slide. `aria-disabled` on the last slide. */
|
|
65
|
+
declare function Next({ render, ...props }: NavigationProps): import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>>;
|
|
66
|
+
interface CounterState {
|
|
67
|
+
/** Zero-based index of the active slide. */
|
|
68
|
+
index: number;
|
|
69
|
+
total: number;
|
|
70
|
+
}
|
|
71
|
+
interface CounterProps extends Omit<ComponentProps<"span">, "children"> {
|
|
72
|
+
/** Custom content. Defaults to "3 / 10". */
|
|
73
|
+
children?: (state: CounterState) => ReactNode;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Shows the position, such as "3 / 10". Hidden from assistive technology:
|
|
77
|
+
* slide labels and the live region already convey it. Empty until the slides
|
|
78
|
+
* are known, so also during server rendering.
|
|
79
|
+
*/
|
|
80
|
+
declare function Counter({ children, ...props }: CounterProps): import("react").JSX.Element;
|
|
81
|
+
interface ThumbnailsProps extends ComponentProps<"div"> {
|
|
82
|
+
/** Layout direction; decides which arrow keys move between thumbnails. */
|
|
83
|
+
orientation?: "horizontal" | "vertical";
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* A tab list of thumbnails, one per slide. Arrow keys, Home and End move
|
|
87
|
+
* between thumbnails and select them at once.
|
|
88
|
+
*/
|
|
89
|
+
declare function Thumbnails({ orientation, ...props }: ThumbnailsProps): import("react").JSX.Element;
|
|
90
|
+
interface ThumbnailProps extends ComponentProps<"button"> {
|
|
91
|
+
/** The zero-based index of the slide this thumbnail selects. */
|
|
92
|
+
index: number;
|
|
93
|
+
/** Render as another element, for example `render={<MyButton />}`. */
|
|
94
|
+
render?: RenderProp;
|
|
95
|
+
}
|
|
96
|
+
/** A tab that selects slide `index`. Keeps itself in view when active. */
|
|
97
|
+
declare function Thumbnail({ index, render, ref, ...props }: ThumbnailProps): import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>>;
|
|
98
|
+
declare namespace index_d_exports {
|
|
99
|
+
export { Counter, CounterProps, CounterState, NavigationProps, Next, Prev, Root, RootProps, Slide, SlideProps, Thumbnail, ThumbnailProps, Thumbnails, ThumbnailsProps, Viewport, ViewportProps };
|
|
100
|
+
}
|
|
101
|
+
export { index_d_exports as Carousel, type CarouselLabels, type RenderProp };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { t as __exportAll } from "./rolldown-runtime-D8jQpDG7.js";
|
|
3
|
+
import { cloneElement, createContext, createElement, isValidElement, useCallback, useContext, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
4
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
5
|
+
// src/core/geometry.ts
|
|
6
|
+
/**
|
|
7
|
+
* Scroll geometry helpers. Everything is measured with
|
|
8
|
+
* `getBoundingClientRect` and moved with `scrollBy`, which both use physical
|
|
9
|
+
* coordinates, so the same code works for left-to-right and right-to-left
|
|
10
|
+
* layouts.
|
|
11
|
+
*/
|
|
12
|
+
/** Differences below one pixel are rounding, not a position to move to. */
|
|
13
|
+
const TOLERANCE = 1;
|
|
14
|
+
function horizontalCentre(element) {
|
|
15
|
+
const rect = element.getBoundingClientRect();
|
|
16
|
+
return rect.left + rect.width / 2;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Returns the index of the slide whose centre is nearest the viewport's
|
|
20
|
+
* centre, or -1 when there are no slides.
|
|
21
|
+
*/
|
|
22
|
+
function findNearestSlide(viewport, slides) {
|
|
23
|
+
const centre = horizontalCentre(viewport);
|
|
24
|
+
let nearest = -1;
|
|
25
|
+
let nearestDistance = Infinity;
|
|
26
|
+
slides.forEach((slide, index) => {
|
|
27
|
+
const distance = Math.abs(horizontalCentre(slide) - centre);
|
|
28
|
+
if (distance < nearestDistance) {
|
|
29
|
+
nearest = index;
|
|
30
|
+
nearestDistance = distance;
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
return nearest;
|
|
34
|
+
}
|
|
35
|
+
/** Whether `slide` sits in the centre of `viewport`. */
|
|
36
|
+
function isCentred(viewport, slide) {
|
|
37
|
+
const delta = horizontalCentre(slide) - horizontalCentre(viewport);
|
|
38
|
+
return Math.abs(delta) < TOLERANCE;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Scrolls `viewport` horizontally so `slide` sits in its centre. Returns
|
|
42
|
+
* whether it had to scroll.
|
|
43
|
+
*/
|
|
44
|
+
function scrollSlideIntoCentre(viewport, slide, behavior) {
|
|
45
|
+
const delta = horizontalCentre(slide) - horizontalCentre(viewport);
|
|
46
|
+
if (Math.abs(delta) < TOLERANCE) return false;
|
|
47
|
+
viewport.scrollBy({
|
|
48
|
+
left: delta,
|
|
49
|
+
behavior
|
|
50
|
+
});
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
function overflow(start, end, min, max) {
|
|
54
|
+
if (start < min - TOLERANCE) return start - min;
|
|
55
|
+
if (end > max + TOLERANCE) return end - max;
|
|
56
|
+
return 0;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Scrolls `container` the least amount needed to show `element` completely,
|
|
60
|
+
* like `scrollIntoView({ block: "nearest", inline: "nearest" })`, but without
|
|
61
|
+
* scrolling any ancestor or the page.
|
|
62
|
+
*/
|
|
63
|
+
function scrollIntoViewWithin(container, element, behavior) {
|
|
64
|
+
const outer = container.getBoundingClientRect();
|
|
65
|
+
const inner = element.getBoundingClientRect();
|
|
66
|
+
const left = overflow(inner.left, inner.right, outer.left, outer.right);
|
|
67
|
+
const top = overflow(inner.top, inner.bottom, outer.top, outer.bottom);
|
|
68
|
+
if (left || top) container.scrollBy({
|
|
69
|
+
left,
|
|
70
|
+
top,
|
|
71
|
+
behavior
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
// src/core/scroll-tracker.ts
|
|
75
|
+
/**
|
|
76
|
+
* Tracks which slide of a horizontal scroll-snap viewport is in view.
|
|
77
|
+
*
|
|
78
|
+
* The scroll position is the source of truth: this module only reads it, and
|
|
79
|
+
* moves it through `scrollToIndex`. It reports two indexes:
|
|
80
|
+
*
|
|
81
|
+
* - the **active** index, updated live (once per frame) while scrolling, for
|
|
82
|
+
* UI that should react at once, such as the counter and thumbnails;
|
|
83
|
+
* - the **settled** index, updated when scrolling ends, for consumers that
|
|
84
|
+
* should only see final values, such as `onIndexChange`.
|
|
85
|
+
*
|
|
86
|
+
* Positions come from ./geometry, which works for left-to-right and
|
|
87
|
+
* right-to-left layouts alike.
|
|
88
|
+
*/
|
|
89
|
+
const DEFAULT_SETTLE_DELAY = 100;
|
|
90
|
+
function createScrollTracker(viewport, options) {
|
|
91
|
+
const { getSlides, onActiveChange, onSettle, settle = "auto", settleDelay = DEFAULT_SETTLE_DELAY } = options;
|
|
92
|
+
const useScrollEnd = settle === "scrollend" || settle === "auto" && "onscrollend" in window;
|
|
93
|
+
const initialIndex = Math.max(0, findNearestSlide(viewport, getSlides()));
|
|
94
|
+
let activeIndex = initialIndex;
|
|
95
|
+
let settledIndex = initialIndex;
|
|
96
|
+
let frame = 0;
|
|
97
|
+
let settleTimer;
|
|
98
|
+
function measure() {
|
|
99
|
+
frame = 0;
|
|
100
|
+
const index = findNearestSlide(viewport, getSlides());
|
|
101
|
+
if (index !== -1 && index !== activeIndex) {
|
|
102
|
+
activeIndex = index;
|
|
103
|
+
onActiveChange(index);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function settleNow() {
|
|
107
|
+
cancelAnimationFrame(frame);
|
|
108
|
+
clearTimeout(settleTimer);
|
|
109
|
+
measure();
|
|
110
|
+
if (activeIndex !== settledIndex) {
|
|
111
|
+
settledIndex = activeIndex;
|
|
112
|
+
onSettle(activeIndex);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
let lastScroll = 0;
|
|
116
|
+
function settleWhenAligned() {
|
|
117
|
+
const slides = getSlides();
|
|
118
|
+
const nearest = slides[findNearestSlide(viewport, slides)];
|
|
119
|
+
const quiet = performance.now() - lastScroll;
|
|
120
|
+
if (nearest && !isCentred(viewport, nearest) && quiet < settleDelay * 5) {
|
|
121
|
+
settleTimer = setTimeout(settleWhenAligned, settleDelay);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
settleNow();
|
|
125
|
+
}
|
|
126
|
+
function handleScroll() {
|
|
127
|
+
if (!frame) frame = requestAnimationFrame(measure);
|
|
128
|
+
if (!useScrollEnd) {
|
|
129
|
+
lastScroll = performance.now();
|
|
130
|
+
clearTimeout(settleTimer);
|
|
131
|
+
settleTimer = setTimeout(settleWhenAligned, settleDelay);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
viewport.addEventListener("scroll", handleScroll, { passive: true });
|
|
135
|
+
if (useScrollEnd) viewport.addEventListener("scrollend", settleNow);
|
|
136
|
+
return {
|
|
137
|
+
get activeIndex() {
|
|
138
|
+
return activeIndex;
|
|
139
|
+
},
|
|
140
|
+
get settledIndex() {
|
|
141
|
+
return settledIndex;
|
|
142
|
+
},
|
|
143
|
+
scrollToIndex(index, behavior = "auto") {
|
|
144
|
+
const slides = getSlides();
|
|
145
|
+
const target = slides[Math.min(Math.max(index, 0), slides.length - 1)];
|
|
146
|
+
if (!target) return false;
|
|
147
|
+
return scrollSlideIntoCentre(viewport, target, behavior);
|
|
148
|
+
},
|
|
149
|
+
update: settleNow,
|
|
150
|
+
destroy() {
|
|
151
|
+
cancelAnimationFrame(frame);
|
|
152
|
+
clearTimeout(settleTimer);
|
|
153
|
+
viewport.removeEventListener("scroll", handleScroll);
|
|
154
|
+
viewport.removeEventListener("scrollend", settleNow);
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
// src/components/context.ts
|
|
159
|
+
const CarouselContext = createContext(null);
|
|
160
|
+
function useCarouselContext(part) {
|
|
161
|
+
const context = useContext(CarouselContext);
|
|
162
|
+
if (!context) throw new Error(`Carousel.${part} must be rendered inside Carousel.Root.`);
|
|
163
|
+
return context;
|
|
164
|
+
}
|
|
165
|
+
// src/components/labels.ts
|
|
166
|
+
const defaultLabels = {
|
|
167
|
+
carousel: "carousel",
|
|
168
|
+
slide: "slide",
|
|
169
|
+
slideLabel: (index, total) => `${index + 1} of ${total}`,
|
|
170
|
+
previous: "Previous slide",
|
|
171
|
+
next: "Next slide",
|
|
172
|
+
thumbnails: "Slides",
|
|
173
|
+
thumbnailLabel: (index) => `Slide ${index + 1}`,
|
|
174
|
+
announcement: (index, total) => `Slide ${index + 1} of ${total}`
|
|
175
|
+
};
|
|
176
|
+
// src/components/render.ts
|
|
177
|
+
/** Combines refs into one callback ref. */
|
|
178
|
+
function mergeRefs(...refs) {
|
|
179
|
+
return (value) => {
|
|
180
|
+
const cleanups = refs.map((ref) => {
|
|
181
|
+
if (typeof ref === "function") return ref(value);
|
|
182
|
+
if (ref) ref.current = value;
|
|
183
|
+
});
|
|
184
|
+
return () => {
|
|
185
|
+
refs.forEach((ref, index) => {
|
|
186
|
+
const cleanup = cleanups[index];
|
|
187
|
+
if (typeof cleanup === "function") cleanup();
|
|
188
|
+
else if (typeof ref === "function") ref(null);
|
|
189
|
+
else if (ref) ref.current = null;
|
|
190
|
+
});
|
|
191
|
+
};
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* `mergeRefs` with a stable identity, so React does not detach and reattach
|
|
196
|
+
* the ref on every render.
|
|
197
|
+
*/
|
|
198
|
+
function useMergedRefs(a, b) {
|
|
199
|
+
return useMemo(() => mergeRefs(a, b), [a, b]);
|
|
200
|
+
}
|
|
201
|
+
function isHandler(key, value) {
|
|
202
|
+
return /^on[A-Z]/.test(key) && typeof value === "function";
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Merges `outer` props (from the consumer) over `inner` props (from the
|
|
206
|
+
* part). Class names are joined, styles merged and refs combined. For event
|
|
207
|
+
* handlers, the consumer's runs first; calling `event.preventDefault()` in it
|
|
208
|
+
* skips the part's own handler.
|
|
209
|
+
*/
|
|
210
|
+
function mergeProps(inner, outer) {
|
|
211
|
+
const merged = { ...inner };
|
|
212
|
+
for (const [key, value] of Object.entries(outer)) {
|
|
213
|
+
const base = inner[key];
|
|
214
|
+
if (value === void 0) continue;
|
|
215
|
+
if (key === "className" && base) merged[key] = `${String(base)} ${String(value)}`;
|
|
216
|
+
else if (key === "style" && base) merged[key] = {
|
|
217
|
+
...base,
|
|
218
|
+
...value
|
|
219
|
+
};
|
|
220
|
+
else if (key === "ref" && base) merged[key] = mergeRefs(base, value);
|
|
221
|
+
else if (isHandler(key, value) && isHandler(key, base)) merged[key] = (event) => {
|
|
222
|
+
value(event);
|
|
223
|
+
if (!event?.defaultPrevented) base(event);
|
|
224
|
+
};
|
|
225
|
+
else merged[key] = value;
|
|
226
|
+
}
|
|
227
|
+
return merged;
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Renders a part: `tag` with the merged props, or the `render` element with
|
|
231
|
+
* the merged props applied to it.
|
|
232
|
+
*/
|
|
233
|
+
function renderPart(tag, props, render) {
|
|
234
|
+
if (isValidElement(render)) return cloneElement(render, mergeProps(props, render.props));
|
|
235
|
+
return createElement(tag, props);
|
|
236
|
+
}
|
|
237
|
+
// src/components/use-reduced-motion.ts
|
|
238
|
+
const QUERY = "(prefers-reduced-motion: reduce)";
|
|
239
|
+
function subscribe(onChange) {
|
|
240
|
+
const media = window.matchMedia(QUERY);
|
|
241
|
+
media.addEventListener("change", onChange);
|
|
242
|
+
return () => media.removeEventListener("change", onChange);
|
|
243
|
+
}
|
|
244
|
+
const getSnapshot = () => window.matchMedia(QUERY).matches;
|
|
245
|
+
const getServerSnapshot = () => false;
|
|
246
|
+
/** Whether the user asked the operating system for reduced motion. */
|
|
247
|
+
function usePrefersReducedMotion() {
|
|
248
|
+
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
|
249
|
+
}
|
|
250
|
+
// src/components/root.tsx
|
|
251
|
+
const SLIDE_SELECTOR = ":scope > [data-carousel-slide]";
|
|
252
|
+
const visuallyHidden = {
|
|
253
|
+
position: "absolute",
|
|
254
|
+
width: 1,
|
|
255
|
+
height: 1,
|
|
256
|
+
margin: -1,
|
|
257
|
+
padding: 0,
|
|
258
|
+
overflow: "hidden",
|
|
259
|
+
clip: "rect(0 0 0 0)",
|
|
260
|
+
whiteSpace: "nowrap",
|
|
261
|
+
border: 0
|
|
262
|
+
};
|
|
263
|
+
/** Keeps the latest value in a ref, for use inside long-lived callbacks. */
|
|
264
|
+
function useLatest(value) {
|
|
265
|
+
const ref = useRef(value);
|
|
266
|
+
useLayoutEffect(() => {
|
|
267
|
+
ref.current = value;
|
|
268
|
+
});
|
|
269
|
+
return ref;
|
|
270
|
+
}
|
|
271
|
+
function Root({ index, defaultIndex = 0, onIndexChange, labels: labelOverrides, motion = "auto", children, ...props }) {
|
|
272
|
+
const baseId = useId();
|
|
273
|
+
const [initialIndex] = useState(() => index ?? defaultIndex);
|
|
274
|
+
const [viewport, setViewport] = useState(null);
|
|
275
|
+
const [slides, setSlides] = useState([]);
|
|
276
|
+
const [activeIndex, setActiveIndex] = useState(initialIndex);
|
|
277
|
+
const [hasThumbnails, setHasThumbnails] = useState(false);
|
|
278
|
+
const [announcement, setAnnouncement] = useState("");
|
|
279
|
+
const trackerRef = useRef(null);
|
|
280
|
+
const sourceRef = useRef(null);
|
|
281
|
+
const prefersReducedMotion = usePrefersReducedMotion();
|
|
282
|
+
const behavior = motion === "reduce" || prefersReducedMotion ? "instant" : "smooth";
|
|
283
|
+
const labels = useMemo(() => ({
|
|
284
|
+
...defaultLabels,
|
|
285
|
+
...labelOverrides
|
|
286
|
+
}), [labelOverrides]);
|
|
287
|
+
const labelsRef = useLatest(labels);
|
|
288
|
+
const onIndexChangeRef = useLatest(onIndexChange);
|
|
289
|
+
const behaviorRef = useLatest(behavior);
|
|
290
|
+
useLayoutEffect(() => {
|
|
291
|
+
if (!viewport) return;
|
|
292
|
+
const getSlides = () => [...viewport.querySelectorAll(SLIDE_SELECTOR)];
|
|
293
|
+
const initialSlides = getSlides();
|
|
294
|
+
const initialSlide = initialSlides[Math.min(initialIndex, initialSlides.length - 1)];
|
|
295
|
+
if (initialSlide) scrollSlideIntoCentre(viewport, initialSlide, "instant");
|
|
296
|
+
const tracker = createScrollTracker(viewport, {
|
|
297
|
+
getSlides,
|
|
298
|
+
onActiveChange: setActiveIndex,
|
|
299
|
+
onSettle: (settled) => {
|
|
300
|
+
if (sourceRef.current !== "tab") {
|
|
301
|
+
const total = getSlides().length;
|
|
302
|
+
setAnnouncement(labelsRef.current.announcement(settled, total));
|
|
303
|
+
}
|
|
304
|
+
sourceRef.current = null;
|
|
305
|
+
onIndexChangeRef.current?.(settled);
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
trackerRef.current = tracker;
|
|
309
|
+
setSlides(initialSlides);
|
|
310
|
+
setActiveIndex(tracker.activeIndex);
|
|
311
|
+
const observer = new MutationObserver(() => {
|
|
312
|
+
setSlides(getSlides());
|
|
313
|
+
tracker.update();
|
|
314
|
+
});
|
|
315
|
+
observer.observe(viewport, { childList: true });
|
|
316
|
+
return () => {
|
|
317
|
+
observer.disconnect();
|
|
318
|
+
tracker.destroy();
|
|
319
|
+
trackerRef.current = null;
|
|
320
|
+
};
|
|
321
|
+
}, [
|
|
322
|
+
viewport,
|
|
323
|
+
initialIndex,
|
|
324
|
+
labelsRef,
|
|
325
|
+
onIndexChangeRef
|
|
326
|
+
]);
|
|
327
|
+
useEffect(() => {
|
|
328
|
+
if (index === void 0) return;
|
|
329
|
+
if (trackerRef.current?.scrollToIndex(index, behaviorRef.current)) sourceRef.current = "control";
|
|
330
|
+
}, [index, behaviorRef]);
|
|
331
|
+
const goTo = useCallback((target, source) => {
|
|
332
|
+
if (trackerRef.current?.scrollToIndex(target, behavior)) sourceRef.current = source;
|
|
333
|
+
}, [behavior]);
|
|
334
|
+
const context = useMemo(() => ({
|
|
335
|
+
activeIndex,
|
|
336
|
+
initialIndex,
|
|
337
|
+
slides,
|
|
338
|
+
hasThumbnails,
|
|
339
|
+
setHasThumbnails,
|
|
340
|
+
setViewport,
|
|
341
|
+
goTo,
|
|
342
|
+
behavior,
|
|
343
|
+
labels,
|
|
344
|
+
ids: {
|
|
345
|
+
root: baseId,
|
|
346
|
+
viewport: `${baseId}-viewport`,
|
|
347
|
+
slide: (i) => `${baseId}-slide-${i}`,
|
|
348
|
+
tab: (i) => `${baseId}-tab-${i}`
|
|
349
|
+
}
|
|
350
|
+
}), [
|
|
351
|
+
activeIndex,
|
|
352
|
+
initialIndex,
|
|
353
|
+
slides,
|
|
354
|
+
hasThumbnails,
|
|
355
|
+
goTo,
|
|
356
|
+
behavior,
|
|
357
|
+
labels,
|
|
358
|
+
baseId
|
|
359
|
+
]);
|
|
360
|
+
const rootProps = mergeProps({
|
|
361
|
+
id: baseId,
|
|
362
|
+
role: "region",
|
|
363
|
+
"aria-roledescription": labels.carousel,
|
|
364
|
+
"data-carousel-root": ""
|
|
365
|
+
}, props);
|
|
366
|
+
return /* @__PURE__ */ jsx(CarouselContext, {
|
|
367
|
+
value: context,
|
|
368
|
+
children: /* @__PURE__ */ jsxs("div", {
|
|
369
|
+
...rootProps,
|
|
370
|
+
children: [children, /* @__PURE__ */ jsx("div", {
|
|
371
|
+
"aria-live": "polite",
|
|
372
|
+
"aria-atomic": "true",
|
|
373
|
+
style: visuallyHidden,
|
|
374
|
+
children: announcement
|
|
375
|
+
})]
|
|
376
|
+
})
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
// src/components/parts.tsx
|
|
380
|
+
/** The scroll-snap container. Its direct `Carousel.Slide` children are the slides. */
|
|
381
|
+
function Viewport({ ref, children, ...props }) {
|
|
382
|
+
const { ids, initialIndex, setViewport } = useCarouselContext("Viewport");
|
|
383
|
+
const mergedRef = useMergedRefs(setViewport, ref);
|
|
384
|
+
const viewportProps = mergeProps({
|
|
385
|
+
id: ids.viewport,
|
|
386
|
+
"data-carousel-viewport": ids.root,
|
|
387
|
+
ref: mergedRef
|
|
388
|
+
}, props);
|
|
389
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
390
|
+
...viewportProps,
|
|
391
|
+
children: [initialIndex > 0 && /* @__PURE__ */ jsx("style", {
|
|
392
|
+
href: `carousel-initial-${ids.root}`,
|
|
393
|
+
precedence: "carousel",
|
|
394
|
+
children: `[data-carousel-viewport="${ids.root}"]>[data-carousel-slide]:nth-child(${initialIndex + 1} of [data-carousel-slide]){scroll-initial-target:nearest}`
|
|
395
|
+
}), children]
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
/** One slide. Slides that are not active are `inert`. */
|
|
399
|
+
function Slide({ ref, ...props }) {
|
|
400
|
+
const { slides, activeIndex, hasThumbnails, labels, ids } = useCarouselContext("Slide");
|
|
401
|
+
const [element, setElement] = useState(null);
|
|
402
|
+
const mergedRef = useMergedRefs(setElement, ref);
|
|
403
|
+
const index = element ? slides.indexOf(element) : -1;
|
|
404
|
+
const known = index !== -1;
|
|
405
|
+
const active = known && index === activeIndex;
|
|
406
|
+
const slideProps = mergeProps({
|
|
407
|
+
id: known ? ids.slide(index) : void 0,
|
|
408
|
+
role: hasThumbnails ? "tabpanel" : "group",
|
|
409
|
+
"aria-roledescription": labels.slide,
|
|
410
|
+
"aria-label": known ? labels.slideLabel(index, slides.length) : void 0,
|
|
411
|
+
inert: known && !active,
|
|
412
|
+
"data-carousel-slide": "",
|
|
413
|
+
"data-active": active ? "" : void 0,
|
|
414
|
+
ref: mergedRef
|
|
415
|
+
}, props);
|
|
416
|
+
return /* @__PURE__ */ jsx("div", { ...slideProps });
|
|
417
|
+
}
|
|
418
|
+
function useNavigation(direction, part) {
|
|
419
|
+
const { activeIndex, slides, goTo, labels, ids } = useCarouselContext(part);
|
|
420
|
+
const target = activeIndex + direction;
|
|
421
|
+
const disabled = target < 0 || target >= slides.length;
|
|
422
|
+
return {
|
|
423
|
+
type: "button",
|
|
424
|
+
"aria-label": direction === 1 ? labels.next : labels.previous,
|
|
425
|
+
"aria-controls": ids.viewport,
|
|
426
|
+
"aria-disabled": disabled || void 0,
|
|
427
|
+
"data-disabled": disabled ? "" : void 0,
|
|
428
|
+
onClick: () => {
|
|
429
|
+
if (!disabled) goTo(target, "control");
|
|
430
|
+
}
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
/** Scrolls to the previous slide. `aria-disabled` on the first slide. */
|
|
434
|
+
function Prev({ render, ...props }) {
|
|
435
|
+
return renderPart("button", mergeProps(useNavigation(-1, "Prev"), props), render);
|
|
436
|
+
}
|
|
437
|
+
/** Scrolls to the next slide. `aria-disabled` on the last slide. */
|
|
438
|
+
function Next({ render, ...props }) {
|
|
439
|
+
return renderPart("button", mergeProps(useNavigation(1, "Next"), props), render);
|
|
440
|
+
}
|
|
441
|
+
const defaultCounter = ({ index, total }) => `${index + 1} / ${total}`;
|
|
442
|
+
/**
|
|
443
|
+
* Shows the position, such as "3 / 10". Hidden from assistive technology:
|
|
444
|
+
* slide labels and the live region already convey it. Empty until the slides
|
|
445
|
+
* are known, so also during server rendering.
|
|
446
|
+
*/
|
|
447
|
+
function Counter({ children = defaultCounter, ...props }) {
|
|
448
|
+
const { activeIndex, slides } = useCarouselContext("Counter");
|
|
449
|
+
const total = slides.length;
|
|
450
|
+
const counterProps = mergeProps({
|
|
451
|
+
"aria-hidden": true,
|
|
452
|
+
"data-carousel-counter": ""
|
|
453
|
+
}, props);
|
|
454
|
+
return /* @__PURE__ */ jsx("span", {
|
|
455
|
+
...counterProps,
|
|
456
|
+
children: total > 0 ? children({
|
|
457
|
+
index: activeIndex,
|
|
458
|
+
total
|
|
459
|
+
}) : null
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
const NEXT_KEYS = {
|
|
463
|
+
horizontal: {
|
|
464
|
+
ltr: "ArrowRight",
|
|
465
|
+
rtl: "ArrowLeft"
|
|
466
|
+
},
|
|
467
|
+
vertical: {
|
|
468
|
+
ltr: "ArrowDown",
|
|
469
|
+
rtl: "ArrowDown"
|
|
470
|
+
}
|
|
471
|
+
};
|
|
472
|
+
const PREVIOUS_KEYS = {
|
|
473
|
+
horizontal: {
|
|
474
|
+
ltr: "ArrowLeft",
|
|
475
|
+
rtl: "ArrowRight"
|
|
476
|
+
},
|
|
477
|
+
vertical: {
|
|
478
|
+
ltr: "ArrowUp",
|
|
479
|
+
rtl: "ArrowUp"
|
|
480
|
+
}
|
|
481
|
+
};
|
|
482
|
+
/**
|
|
483
|
+
* A tab list of thumbnails, one per slide. Arrow keys, Home and End move
|
|
484
|
+
* between thumbnails and select them at once.
|
|
485
|
+
*/
|
|
486
|
+
function Thumbnails({ orientation = "horizontal", ...props }) {
|
|
487
|
+
const { labels, setHasThumbnails } = useCarouselContext("Thumbnails");
|
|
488
|
+
useEffect(() => {
|
|
489
|
+
setHasThumbnails(true);
|
|
490
|
+
return () => setHasThumbnails(false);
|
|
491
|
+
}, [setHasThumbnails]);
|
|
492
|
+
function handleKeyDown(event) {
|
|
493
|
+
const list = event.currentTarget;
|
|
494
|
+
const tabs = [...list.querySelectorAll("[data-carousel-thumbnail]")];
|
|
495
|
+
const current = tabs.findIndex((tab) => tab.contains(event.target));
|
|
496
|
+
if (current === -1) return;
|
|
497
|
+
const direction = getComputedStyle(list).direction === "rtl" ? "rtl" : "ltr";
|
|
498
|
+
let next;
|
|
499
|
+
if (event.key === NEXT_KEYS[orientation][direction]) next = (current + 1) % tabs.length;
|
|
500
|
+
else if (event.key === PREVIOUS_KEYS[orientation][direction]) next = (current - 1 + tabs.length) % tabs.length;
|
|
501
|
+
else if (event.key === "Home") next = 0;
|
|
502
|
+
else if (event.key === "End") next = tabs.length - 1;
|
|
503
|
+
else return;
|
|
504
|
+
event.preventDefault();
|
|
505
|
+
const tab = tabs[next];
|
|
506
|
+
tab?.focus();
|
|
507
|
+
tab?.click();
|
|
508
|
+
}
|
|
509
|
+
const listProps = mergeProps({
|
|
510
|
+
role: "tablist",
|
|
511
|
+
"aria-label": labels.thumbnails,
|
|
512
|
+
"aria-orientation": orientation,
|
|
513
|
+
"data-carousel-thumbnails": "",
|
|
514
|
+
"data-orientation": orientation,
|
|
515
|
+
onKeyDown: handleKeyDown
|
|
516
|
+
}, props);
|
|
517
|
+
return /* @__PURE__ */ jsx("div", { ...listProps });
|
|
518
|
+
}
|
|
519
|
+
/** A tab that selects slide `index`. Keeps itself in view when active. */
|
|
520
|
+
function Thumbnail({ index, render, ref, ...props }) {
|
|
521
|
+
const { activeIndex, slides, goTo, labels, ids, behavior } = useCarouselContext("Thumbnail");
|
|
522
|
+
const [element, setElement] = useState(null);
|
|
523
|
+
const mergedRef = useMergedRefs(setElement, ref);
|
|
524
|
+
const selected = index === activeIndex;
|
|
525
|
+
useEffect(() => {
|
|
526
|
+
const list = element?.closest("[data-carousel-thumbnails]");
|
|
527
|
+
if (selected && element && list) scrollIntoViewWithin(list, element, behavior);
|
|
528
|
+
}, [
|
|
529
|
+
selected,
|
|
530
|
+
element,
|
|
531
|
+
behavior
|
|
532
|
+
]);
|
|
533
|
+
return renderPart("button", mergeProps({
|
|
534
|
+
type: "button",
|
|
535
|
+
role: "tab",
|
|
536
|
+
id: ids.tab(index),
|
|
537
|
+
"aria-selected": selected,
|
|
538
|
+
"aria-controls": ids.slide(index),
|
|
539
|
+
"aria-label": labels.thumbnailLabel(index, slides.length),
|
|
540
|
+
tabIndex: selected ? 0 : -1,
|
|
541
|
+
"data-carousel-thumbnail": "",
|
|
542
|
+
"data-active": selected ? "" : void 0,
|
|
543
|
+
ref: mergedRef,
|
|
544
|
+
onClick: () => goTo(index, "tab")
|
|
545
|
+
}, props), render);
|
|
546
|
+
}
|
|
547
|
+
// src/components/index.ts
|
|
548
|
+
var components_exports = /* @__PURE__ */ __exportAll({
|
|
549
|
+
Counter: () => Counter,
|
|
550
|
+
Next: () => Next,
|
|
551
|
+
Prev: () => Prev,
|
|
552
|
+
Root: () => Root,
|
|
553
|
+
Slide: () => Slide,
|
|
554
|
+
Thumbnail: () => Thumbnail,
|
|
555
|
+
Thumbnails: () => Thumbnails,
|
|
556
|
+
Viewport: () => Viewport
|
|
557
|
+
});
|
|
558
|
+
export { components_exports as Carousel, defaultLabels };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// \0rolldown/runtime.js
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __exportAll = (all, no_symbols) => {
|
|
4
|
+
let target = {};
|
|
5
|
+
for (var name in all) __defProp(target, name, {
|
|
6
|
+
get: all[name],
|
|
7
|
+
enumerable: true
|
|
8
|
+
});
|
|
9
|
+
if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
|
|
10
|
+
return target;
|
|
11
|
+
};
|
|
12
|
+
export { __exportAll as t };
|
package/dist/styles.css
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Functional base styles for @mitchellslager/carousel. They live in a cascade
|
|
3
|
+
* layer, so any unlayered style of your own wins without extra specificity.
|
|
4
|
+
* Visual styling (colours, buttons, thumbnails) is up to you.
|
|
5
|
+
*/
|
|
6
|
+
@layer carousel {
|
|
7
|
+
[data-carousel-viewport] {
|
|
8
|
+
display: flex;
|
|
9
|
+
overflow-x: auto;
|
|
10
|
+
overscroll-behavior-x: contain;
|
|
11
|
+
scroll-snap-type: x mandatory;
|
|
12
|
+
scroll-behavior: smooth;
|
|
13
|
+
scrollbar-width: none;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
[data-carousel-viewport]::-webkit-scrollbar {
|
|
17
|
+
display: none;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
[data-carousel-slide] {
|
|
21
|
+
flex: 0 0 100%;
|
|
22
|
+
min-width: 0;
|
|
23
|
+
/*
|
|
24
|
+
* No scroll-snap-stop: always. It would also stop programmatic scrolls at
|
|
25
|
+
* the next slide, so thumbnails could not jump several slides at once.
|
|
26
|
+
*/
|
|
27
|
+
scroll-snap-align: center;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
[data-carousel-thumbnails] {
|
|
31
|
+
display: flex;
|
|
32
|
+
overflow: auto;
|
|
33
|
+
overscroll-behavior: contain;
|
|
34
|
+
scrollbar-width: none;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
[data-carousel-thumbnails]::-webkit-scrollbar {
|
|
38
|
+
display: none;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
[data-carousel-thumbnails][data-orientation="vertical"] {
|
|
42
|
+
flex-direction: column;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
[data-carousel-thumbnail] {
|
|
46
|
+
flex: none;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
@media (prefers-reduced-motion: reduce) {
|
|
50
|
+
[data-carousel-viewport] {
|
|
51
|
+
scroll-behavior: auto;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,80 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mitchellslager/carousel",
|
|
3
|
-
"version": "0.0.0
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"description": "A headless, accessible image carousel for React, driven by native CSS scroll snapping.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Mitchell Slager",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/mitchellslager/carousel.git",
|
|
10
|
+
"directory": "packages/carousel"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"react",
|
|
14
|
+
"carousel",
|
|
15
|
+
"gallery",
|
|
16
|
+
"slider",
|
|
17
|
+
"thumbnails",
|
|
18
|
+
"scroll-snap",
|
|
19
|
+
"accessibility",
|
|
20
|
+
"headless"
|
|
21
|
+
],
|
|
22
|
+
"type": "module",
|
|
23
|
+
"sideEffects": [
|
|
24
|
+
"**/*.css"
|
|
25
|
+
],
|
|
26
|
+
"files": [
|
|
27
|
+
"dist"
|
|
28
|
+
],
|
|
29
|
+
"exports": {
|
|
30
|
+
".": {
|
|
31
|
+
"carousel:source": "./src/index.ts",
|
|
32
|
+
"types": "./dist/index.d.ts",
|
|
33
|
+
"import": "./dist/index.js"
|
|
34
|
+
},
|
|
35
|
+
"./styles.css": {
|
|
36
|
+
"carousel:source": "./src/styles.css",
|
|
37
|
+
"default": "./dist/styles.css"
|
|
38
|
+
},
|
|
39
|
+
"./package.json": "./package.json"
|
|
40
|
+
},
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public",
|
|
43
|
+
"provenance": true
|
|
44
|
+
},
|
|
45
|
+
"scripts": {
|
|
46
|
+
"build": "tsdown",
|
|
47
|
+
"test": "vitest run",
|
|
48
|
+
"test:watch": "vitest",
|
|
49
|
+
"typecheck": "tsc",
|
|
50
|
+
"check:package": "publint --strict --pack npm && attw --pack . --profile esm-only --exclude-entrypoints styles.css",
|
|
51
|
+
"size": "size-limit"
|
|
52
|
+
},
|
|
53
|
+
"peerDependencies": {
|
|
54
|
+
"react": ">=19",
|
|
55
|
+
"react-dom": ">=19"
|
|
56
|
+
},
|
|
57
|
+
"size-limit": [
|
|
58
|
+
{
|
|
59
|
+
"name": "JavaScript (minified, brotli, React excluded)",
|
|
60
|
+
"path": "dist/index.js",
|
|
61
|
+
"import": "{ Carousel }",
|
|
62
|
+
"limit": "4 kB"
|
|
63
|
+
}
|
|
64
|
+
],
|
|
65
|
+
"devDependencies": {
|
|
66
|
+
"@arethetypeswrong/cli": "^0.18.5",
|
|
67
|
+
"@size-limit/preset-small-lib": "^14.3.0",
|
|
68
|
+
"@types/react": "^19.3.0",
|
|
69
|
+
"@types/react-dom": "^19.3.0",
|
|
70
|
+
"@vitest/browser-playwright": "^5.1.0",
|
|
71
|
+
"playwright": "^1.64.1",
|
|
72
|
+
"publint": "^0.3.25",
|
|
73
|
+
"react": "^19.3.1",
|
|
74
|
+
"react-dom": "^19.3.1",
|
|
75
|
+
"size-limit": "^14.3.0",
|
|
76
|
+
"tsdown": "^0.23.5",
|
|
77
|
+
"vitest": "^5.1.0",
|
|
78
|
+
"vitest-browser-react": "^2.3.0"
|
|
79
|
+
}
|
|
80
|
+
}
|
package/README.md
DELETED