@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.
@@ -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"
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
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
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.