@mitchellslager/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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mitchell Slager
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,121 @@
1
- # Temporary Holding Version
1
+ # @mitchellslager/carousel
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A headless, accessible image carousel for React 19, driven by native CSS scroll snapping.
4
+
5
+ - Thumbnail navigation, synchronised with the carousel
6
+ - A "3 / 10" counter
7
+ - The WAI-ARIA carousel pattern, with translatable labels
8
+ - Respects `prefers-reduced-motion`
9
+
10
+ [Demo](https://mitchellslager.github.io/carousel/) · [Design decisions](https://github.com/mitchellslager/carousel/blob/main/DESIGN.md)
11
+
12
+ ## Usage
13
+
14
+ ```bash
15
+ pnpm add @mitchellslager/carousel
16
+ ```
17
+
18
+ ```tsx
19
+ import { Carousel } from "@mitchellslager/carousel"
20
+ import "@mitchellslager/carousel/styles.css"
21
+
22
+ export function ProductGallery({
23
+ images,
24
+ }: {
25
+ images: { src: string; alt: string }[]
26
+ }) {
27
+ return (
28
+ <Carousel.Root aria-label="Product images">
29
+ <Carousel.Viewport>
30
+ {images.map((image) => (
31
+ <Carousel.Slide key={image.src}>
32
+ <img src={image.src} alt={image.alt} />
33
+ </Carousel.Slide>
34
+ ))}
35
+ </Carousel.Viewport>
36
+ <Carousel.Prev>←</Carousel.Prev>
37
+ <Carousel.Counter />
38
+ <Carousel.Next>→</Carousel.Next>
39
+ <Carousel.Thumbnails>
40
+ {images.map((image, index) => (
41
+ <Carousel.Thumbnail key={image.src} index={index}>
42
+ <img src={image.src} alt="" />
43
+ </Carousel.Thumbnail>
44
+ ))}
45
+ </Carousel.Thumbnails>
46
+ </Carousel.Root>
47
+ )
48
+ }
49
+ ```
50
+
51
+ Give `Root` an accessible name with `aria-label` or `aria-labelledby`. Every part is optional and can be placed anywhere inside `Root`.
52
+
53
+ ### Parts
54
+
55
+ | Part | Renders | Notes |
56
+ | -------------- | -------- | ---------------------------------------------------------------- |
57
+ | `Root` | `div` | `index` / `defaultIndex` / `onIndexChange`, `labels`, `motion`. |
58
+ | `Viewport` | `div` | The scroll-snap container. Its `Slide` children are the slides. |
59
+ | `Slide` | `div` | `inert` unless active. `data-active` when active. |
60
+ | `Prev`, `Next` | `button` | `aria-disabled` and `data-disabled` at the edges. `render` prop. |
61
+ | `Counter` | `span` | "3 / 10" by default; pass a function child to change it. |
62
+ | `Thumbnails` | `div` | Tab list; `orientation` is `"horizontal"` or `"vertical"`. |
63
+ | `Thumbnail` | `button` | Tab for slide `index`. `data-active` when active. `render` prop. |
64
+
65
+ ### State
66
+
67
+ `onIndexChange` fires once scrolling settles on a new slide. Pass `index` to request a slide from outside: changing it scrolls there, but a swipe always wins and reports itself through `onIndexChange`. To reset the carousel, for example when a product variant changes its images, give `Root` a new `key`.
68
+
69
+ ### Labels
70
+
71
+ All accessible text is English by default. Translate it with `labels`:
72
+
73
+ ```tsx
74
+ <Carousel.Root
75
+ aria-label="Productafbeeldingen"
76
+ labels={{
77
+ carousel: "carrousel",
78
+ slideLabel: (index, total) => `${index + 1} van ${total}`,
79
+ previous: "Vorige afbeelding",
80
+ next: "Volgende afbeelding",
81
+ thumbnails: "Afbeeldingen",
82
+ thumbnailLabel: (index) => `Afbeelding ${index + 1}`,
83
+ announcement: (index, total) => `Afbeelding ${index + 1} van ${total}`,
84
+ }}
85
+ >
86
+ ```
87
+
88
+ ### Motion
89
+
90
+ The carousel scrolls smoothly, and jumps instead when the user prefers reduced motion. Pass `motion="reduce"` to always jump.
91
+
92
+ ### Custom elements
93
+
94
+ `Prev`, `Next` and `Thumbnail` take a `render` prop to render as your own component. Props are merged: class names are joined, and your event handlers run first (call `event.preventDefault()` to skip the carousel's own).
95
+
96
+ ```tsx
97
+ <Carousel.Next render={<Button variant="ghost" />}>Next</Carousel.Next>
98
+ ```
99
+
100
+ ## Styling
101
+
102
+ Import the base styles once:
103
+
104
+ ```ts
105
+ import "@mitchellslager/carousel/styles.css"
106
+ ```
107
+
108
+ They only handle layout, snapping and reduced motion, inside `@layer carousel`. Unlayered CSS of your own always wins. If you use Tailwind or other cascade layers, declare the carousel layer first so your utilities override it:
109
+
110
+ ```css
111
+ @layer carousel;
112
+ @import "tailwindcss";
113
+ ```
114
+
115
+ ## Contributing
116
+
117
+ Development happens in the [GitHub repository](https://github.com/mitchellslager/carousel).
118
+
119
+ ## License
120
+
121
+ MIT
@@ -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 };
@@ -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-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.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
+ "peerDependencies": {
46
+ "react": ">=19",
47
+ "react-dom": ">=19"
48
+ },
49
+ "size-limit": [
50
+ {
51
+ "name": "JavaScript (minified, brotli, React excluded)",
52
+ "path": "dist/index.js",
53
+ "import": "{ Carousel }",
54
+ "limit": "4 kB"
55
+ }
56
+ ],
57
+ "devDependencies": {
58
+ "@arethetypeswrong/cli": "^0.18.5",
59
+ "@size-limit/preset-small-lib": "^14.3.0",
60
+ "@types/react": "^19.3.0",
61
+ "@types/react-dom": "^19.3.0",
62
+ "@vitest/browser-playwright": "^5.1.0",
63
+ "playwright": "^1.64.1",
64
+ "publint": "^0.3.25",
65
+ "react": "^19.3.1",
66
+ "react-dom": "^19.3.1",
67
+ "size-limit": "^14.3.0",
68
+ "tsdown": "^0.23.5",
69
+ "vitest": "^5.1.0",
70
+ "vitest-browser-react": "^2.3.0"
71
+ },
72
+ "scripts": {
73
+ "build": "tsdown",
74
+ "test": "vitest run",
75
+ "test:watch": "vitest",
76
+ "typecheck": "tsc",
77
+ "check:package": "publint --strict --pack npm && attw --pack . --profile esm-only --exclude-entrypoints styles.css",
78
+ "size": "size-limit"
79
+ }
6
80
  }