@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 +21 -0
- package/README.md +120 -2
- 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 +77 -3
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
|
-
#
|
|
1
|
+
# @mitchellslager/carousel
|
|
2
2
|
|
|
3
|
-
|
|
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
|
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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
}
|