@nordwerk/scroll-carousel 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/AGENTS.md ADDED
@@ -0,0 +1,114 @@
1
+ # scroll-carousel: guide for coding agents
2
+
3
+ Two parts: how to build a carousel with this package in a project, and how to work on the package
4
+ itself. Human documentation is in README.md; live examples with code are at
5
+ https://studio-nordwerk.github.io/scroll-carousel/.
6
+
7
+ ## Part 1: using the package
8
+
9
+ ### The model in one paragraph
10
+
11
+ The carousel is a native horizontal scroller. CSS alone lays it out (slides per view, gaps,
12
+ offsets, snapping, centring), so server markup is final and nothing shifts. The script
13
+ (`attach`) only adds arrows, dots, paging, an API, events and announcements. Configure layout in
14
+ CSS custom properties, behaviour in `attach` options. Never compute slide widths or positions in
15
+ JavaScript, never move slides with transforms, never clone slides.
16
+
17
+ ### Rules
18
+
19
+ 1. Markup contract: a root with class `sc`, a track with `data-sc-track`, class `sc-track`,
20
+ `tabindex="0"` and an accessible name (`aria-label` or `aria-labelledby`), one element per
21
+ slide as direct children. Controls are optional: `data-sc-prev`, `data-sc-next`,
22
+ `data-sc-dots` (leave it empty), `data-sc-status` (with `aria-live="polite"`), `data-sc-play`.
23
+ 2. Import the stylesheet once: `@nordwerk/scroll-carousel/carousel.css`.
24
+ 3. Responsive layout goes in CSS: media queries, or container queries on the root
25
+ (`@container sc (min-width: …)`, then set the properties on `.sc-track`). Do not re-attach on
26
+ resize; the script measures on its own.
27
+ 4. Keep the group equal to what is visible: `--sc-group: page`, or the same number as
28
+ `--sc-per-view` per breakpoint.
29
+ 5. Opening on a later slide: `data-sc-initial` on that slide, plus the `PRE_POSITION` inline
30
+ script right after the root when rendering on the server (the adapters do this).
31
+ 6. Drag and autoplay are plugins: `import { drag } from '@nordwerk/scroll-carousel/drag'`,
32
+ `import { autoplay } from '@nordwerk/scroll-carousel/autoplay'`, passed in `plugins`. Autoplay
33
+ needs a visible `[data-sc-play]` button (WCAG 2.2.2).
34
+ 7. An "infinite" or looping carousel is `rewind: true` (fades back to the start). A true loop with
35
+ cloned slides is not supported; do not build one around the package.
36
+ 8. Host-owned arrows or dots: render them yourself and call `carousel.next()`, `prev()`,
37
+ `goToPage(n)`; read `carousel.state` or listen for `sc:change` on the root. In React or
38
+ Preact use `useCarousel()` or the `carouselRef` prop.
39
+ 9. Text: pass `labels` for dots and the live region in the page's language, and give the arrow
40
+ buttons their own `aria-label`s ("Previous products", "Next products").
41
+ 10. Do not add `disabled` to the arrows yourself; the script manages `aria-disabled`.
42
+
43
+ ### Recipes (CSS on the root or track, options in attach)
44
+
45
+ | Want | CSS | attach options |
46
+ | --- | --- | --- |
47
+ | Hero, one per view, dots, autoplay | `--sc-gap: 0px` | `{ rewind: true, plugins: [autoplay({ delay: 5000 })] }` |
48
+ | Product row, 2 / 3 / 4 per view, page by view | `--sc-per-view` per container width, `--sc-group: page` | `{}` or `{ plugins: [drag()] }` |
49
+ | Free mode on phones | `--sc-snap: none` (or `mandatory` for "sticky"), `--sc-controls: none` in a small-width query | `{}` |
50
+ | Peek of the next slide | `--sc-per-view: 2.3`, `--sc-offset-before/after: 1rem` | `{}` |
51
+ | Centred slides | `--sc-align: center; --sc-centered: 1; --sc-per-view: 1.4` | `{}` |
52
+ | Chips or logos as wide as their content | `--sc-slide-size: auto; --sc-snap: proximity; --sc-group: page` | `{}` |
53
+ | Date strip opening on today | `--sc-per-view: 7; --sc-group: page` + `data-sc-initial` | `{}` |
54
+ | Too few slides | class `sc--center-few`, or `--sc-count: <n>` | `{}` |
55
+
56
+ ### Adapters
57
+
58
+ - React / Preact: `<Carousel as="ul" label="…" perView={{ 0: 2, 600: 3 }} gap={12} group="page"
59
+ rewind plugins={[drag()]} initial={3} onChange={…} carouselRef={…}>` with slides as children.
60
+ Props: `arrows`, `dots`, `playButton`, `centerFew`, `slideRoles`, `labels` (`prev`, `next`,
61
+ `page`, `status`), `className`, `style`, `slideClassName`. Options are read once on mount; change
62
+ `key` to re-attach.
63
+ - Astro: `<Carousel as="ul" label="…" perView={…} group="page" gap={12} drag autoplay={5000}
64
+ rewind initial={3} labels={{ page: 'Slide {n} of {count}' }}>` with slide elements in the slot;
65
+ mark the initial slide with `data-sc-initial` yourself.
66
+ - Anything else on the server: `baseStyle(props)`, `responsiveCss(id, props)` and `PRE_POSITION`
67
+ from `@nordwerk/scroll-carousel/markup`.
68
+
69
+ ## Part 2: working on this repository
70
+
71
+ ### Layout
72
+
73
+ - `src/math.ts`: pure positioning maths (snap positions, pages, visible range). Unit tested.
74
+ - `src/index.ts`: the controller, `attach()`. Keep it free of framework code.
75
+ - `src/drag.ts`, `src/autoplay.ts`: plugins against the `PluginContext` in index.ts.
76
+ - `src/markup.ts`: server helpers shared by the adapters.
77
+ - `src/adapter.ts`: the React and Preact adapter, written once against a small `Framework`
78
+ interface; `src/react.ts` and `src/preact.ts` only bind it.
79
+ - `src/astro/Carousel.astro`: shipped as source.
80
+ - `src/carousel.css`: all layout and the default controls.
81
+ - `site/`: the documentation site (`generate.mjs` writes `_site/`), including the wireframe
82
+ patterns in `wireframes.mjs`. Content there is fictional.
83
+ - `test/unit/`: node's test runner on the TypeScript sources.
84
+ - `test/e2e/`: Playwright against `_site/` and the adapter fixtures from `scripts/fixtures.mjs`.
85
+
86
+ ### Commands
87
+
88
+ ```sh
89
+ pnpm install
90
+ pnpm typecheck
91
+ pnpm test # unit tests, no build needed
92
+ pnpm build # dist/: esbuild modules plus tsc declarations
93
+ pnpm size # fails when an entry exceeds its gzip budget
94
+ pnpm site # build, then _site/
95
+ node scripts/fixtures.mjs # React, Preact and Astro fixtures into _site/fixtures/
96
+ pnpm test:e2e # Chromium, WebKit and Firefox
97
+ pnpm check # all of the above in order
98
+ ```
99
+
100
+ Visual baselines exist per platform in `test/e2e/__screenshots__/<platform>`; update them with
101
+ `UPDATE_VISUAL=1 pnpm test:e2e --project=chromium visual --update-snapshots=all`.
102
+
103
+ ### Conventions
104
+
105
+ - No runtime dependencies. Anything a consumer does not use must tree-shake away; new optional
106
+ behaviour becomes a plugin.
107
+ - Size budgets live in `scripts/size.mjs`. Raising one needs a reason in the commit message.
108
+ - Layout stays in CSS. The script reads layout (custom properties, computed styles, geometry)
109
+ and never writes it.
110
+ - Every must-have behaviour has a browser test that passes in all three engines. Fix flaky tests
111
+ instead of retrying them.
112
+ - Descriptive names, English in code, comments and docs, no project code names.
113
+ - Examples, fixtures and docs use fictional content. Never name or describe a client, its code,
114
+ its paths or its numbers in this repository, including commit messages.
package/CHANGELOG.md ADDED
@@ -0,0 +1,15 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (2026-09-21)
4
+
5
+ First version.
6
+
7
+ - Core `attach()`: layout in CSS custom properties with scroll snap; arrows, dots, paging by a
8
+ number of slides or by view, pages counted from the initial slide, keyboard, live-region
9
+ announcements after settled moves, `sc:change` events, rewind by fading or scrolling back,
10
+ slides added or removed after attach, right to left.
11
+ - Plugins: `drag()` for mouse dragging (snapping rows move by one page per drag, free rows keep
12
+ the throw's momentum), `autoplay()` with a pause button and WCAG-conform stops.
13
+ - Adapters: React and Preact (`<Carousel>`, `useCarousel`), Astro (`Carousel.astro`), and server
14
+ helpers in `markup` (responsive container-query CSS, the pre-position snippet).
15
+ - Documentation site with a live example per configuration and storefront patterns as wireframes.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nordwerk
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 ADDED
@@ -0,0 +1,226 @@
1
+ # scroll-carousel
2
+
3
+ A carousel that is a native horizontal scroller first. The layout is plain CSS with scroll snap,
4
+ so the markup your server sends is already the final layout: no layout shift, no hidden slides,
5
+ no device detection on the server. A small script adds arrows, dots, paging by group, an API and
6
+ change events. Drag and autoplay are opt-in plugins. No runtime dependencies.
7
+
8
+ **Live examples:** https://studio-nordwerk.github.io/scroll-carousel/ (every configuration, with
9
+ its code, plus common storefront patterns as wireframes next to the library options they replace).
10
+
11
+ | Part | gzip, minified |
12
+ | --- | --- |
13
+ | Core: attach, arrows, dots, keyboard, announcements | 3.4 kB |
14
+ | Drag plugin | +0.9 kB |
15
+ | Autoplay plugin | +0.8 kB |
16
+ | Stylesheet | 1.4 kB |
17
+
18
+ Adapters for React, Preact and Astro are included; they render the markup and attach the core.
19
+
20
+ ## Install
21
+
22
+ ```sh
23
+ pnpm add @nordwerk/scroll-carousel
24
+ ```
25
+
26
+ React and Preact are optional peer dependencies; the Astro component compiles in your Astro build.
27
+
28
+ ## Quick start
29
+
30
+ ### Plain HTML
31
+
32
+ ```html
33
+ <!-- carousel.css from the package, through your bundler or copied next to the page -->
34
+ <link rel="stylesheet" href="carousel.css" />
35
+
36
+ <div class="sc products">
37
+ <ul class="sc-track" data-sc-track tabindex="0" aria-label="Bestsellers">
38
+ <li>…</li>
39
+ <li>…</li>
40
+ </ul>
41
+ <button class="sc-nav sc-prev" type="button" data-sc-prev aria-label="Previous products">‹</button>
42
+ <button class="sc-nav sc-next" type="button" data-sc-next aria-label="Next products">›</button>
43
+ <div class="sc-dots" data-sc-dots></div>
44
+ <p class="sc-status" data-sc-status aria-live="polite"></p>
45
+ </div>
46
+
47
+ <style>
48
+ .products .sc-track { --sc-per-view: 2; --sc-group: 2; --sc-gap: 12px; }
49
+ @container sc (min-width: 800px) { .products .sc-track { --sc-per-view: 4; --sc-group: 4; } }
50
+ </style>
51
+
52
+ <script type="module">
53
+ import { attach } from '@nordwerk/scroll-carousel';
54
+ attach(document.querySelector('.products'));
55
+ </script>
56
+ ```
57
+
58
+ Without the script the row still scrolls and snaps; arrows and dots stay hidden until it runs.
59
+
60
+ ### React and Preact
61
+
62
+ ```jsx
63
+ import { Carousel } from '@nordwerk/scroll-carousel/react'; // or '/preact'
64
+ import { drag } from '@nordwerk/scroll-carousel/drag';
65
+ import '@nordwerk/scroll-carousel/carousel.css';
66
+
67
+ <Carousel
68
+ as="ul"
69
+ label="Bestsellers"
70
+ perView={{ 0: 2, 600: 3, 900: 4 }}
71
+ group={{ 0: 2, 600: 3, 900: 4 }}
72
+ gap={12}
73
+ plugins={[drag()]}
74
+ onChange={(state) => console.log(state.index)}
75
+ carouselRef={(carousel) => (window.row = carousel)}
76
+ >
77
+ {products.map((product) => <ProductCard key={product.id} {...product} />)}
78
+ </Carousel>;
79
+ ```
80
+
81
+ Each child becomes a slide (`<li>` with `as="ul"`). Responsive values are keyed by the minimum
82
+ width of the carousel itself, not the viewport, and are rendered as container queries in the
83
+ server markup. For your own arrows and dots, render the markup yourself and use the hook:
84
+
85
+ ```jsx
86
+ const { ref, carousel, state } = useCarousel({ rewind: true });
87
+ // <div className="sc" ref={ref}><ul className="sc-track" data-sc-track …>…</ul></div>
88
+ // <MyDots count={state?.pageCount} current={state?.page} onPick={(p) => carousel.goToPage(p)} />
89
+ ```
90
+
91
+ ### Astro
92
+
93
+ ```astro
94
+ ---
95
+ import Carousel from '@nordwerk/scroll-carousel/astro';
96
+ ---
97
+ <Carousel as="ul" label="Bestsellers" perView={{ 0: 2, 600: 3, 900: 4 }} group="page" gap={12} drag>
98
+ {products.map((product) => <li><ProductCard {...product} /></li>)}
99
+ </Carousel>
100
+ ```
101
+
102
+ Slides go in the default slot, one element each. Drag and autoplay are loaded only on pages that
103
+ use them. Labels take templates: `labels={{ page: 'Slide {n} of {count}' }}`.
104
+
105
+ ## Markup
106
+
107
+ | Element | Required | Purpose |
108
+ | --- | --- | --- |
109
+ | `.sc` root | yes | Query container named `sc`; gets the state attributes below |
110
+ | `[data-sc-track]` with class `sc-track` | yes | The scroller. Give it `tabindex="0"` and an accessible name |
111
+ | its children | yes | The slides, one element each |
112
+ | `[data-sc-prev]`, `[data-sc-next]` | no | Any buttons. Class `sc-nav sc-prev` / `sc-next` for the default look |
113
+ | `[data-sc-dots]` | no | Empty container; the script fills it with one button per page |
114
+ | `[data-sc-status]` | no | Live region for "Items 5 to 8 of 12" after a settled move |
115
+ | `[data-sc-play]` | no | Play and pause button for the autoplay plugin |
116
+ | `[data-sc-initial]` on a slide | no | Open on this slide (see below) |
117
+
118
+ State attributes on the root, for styling: `data-sc-ready`, `data-sc-overflow`,
119
+ `data-sc-start`, `data-sc-end`, and with the plugins `data-sc-drag`, `data-sc-playing`,
120
+ `data-sc-paused`.
121
+
122
+ ## Layout: custom properties
123
+
124
+ Set them anywhere above the track, in media queries or in container queries. The root is the
125
+ query container, so inside `@container sc (…)` set them on `.sc-track` (or on `.sc > *`).
126
+
127
+ | Property | Default | |
128
+ | --- | --- | --- |
129
+ | `--sc-per-view` | `1` | Slides per view; fractions show part of the next slide |
130
+ | `--sc-slide-size` | from per-view | A fixed size, or `auto` for the content width |
131
+ | `--sc-gap` | `1rem` | Space between slides |
132
+ | `--sc-offset-before`, `--sc-offset-after` | `0px` | Space before the first and after the last slide; snapped slides line up with it |
133
+ | `--sc-snap` | `mandatory` | `mandatory`, `proximity` or `none` (free mode) |
134
+ | `--sc-align` | `start` | `start` or `center` |
135
+ | `--sc-centered` | `0` | `1` pads both ends so the first and last slide can reach the centre |
136
+ | `--sc-count` | none | The slide count; per-view never exceeds it |
137
+ | `--sc-group` | `1` | Slides per step of next and previous: a number or `page` |
138
+ | `--sc-controls` | shown | `none` hides arrows, dots and the play button |
139
+
140
+ Class `sc--center-few` keeps a row that does not overflow in the middle. Slides stretch to the
141
+ same height unless you set `align-items` on the track.
142
+
143
+ Theme for the default controls: `--sc-control-bg`, `--sc-control-fg`, `--sc-control-border`,
144
+ `--sc-control-size`, `--sc-control-radius`, `--sc-control-shadow`, `--sc-nav-top`,
145
+ `--sc-nav-inset`, `--sc-dot`, `--sc-dot-size`, `--sc-dot-height`, `--sc-dot-active-size`,
146
+ `--sc-dot-radius`, `--sc-dot-idle`, `--sc-focus`. The defaults are neutral system colours.
147
+
148
+ ## API
149
+
150
+ ```ts
151
+ const carousel = attach(root, options);
152
+ ```
153
+
154
+ | Option | |
155
+ | --- | --- |
156
+ | `group` | Overrides `--sc-group` |
157
+ | `initial` | Initial slide; overrides `[data-sc-initial]` |
158
+ | `rewind` | `true` fades back to the start after the end (and the reverse); `'scroll'` scrolls back |
159
+ | `labels` | `{ page(n, count), status(first, last, count, slides) }` for dots and the live region |
160
+ | `onChange` | Called after every settled move that changed the state |
161
+ | `prev`, `next`, `dots`, `status` | Elements elsewhere in the page, instead of the ones inside the root |
162
+ | `plugins` | `[drag(), autoplay({ delay: 5000 })]` |
163
+
164
+ | Member | |
165
+ | --- | --- |
166
+ | `index`, `page`, `pageCount`, `isBeginning`, `isEnd`, `state` | Current state; `state` is a copy of all of it plus `overflow` |
167
+ | `next()`, `prev()` | One page on; with `rewind`, wraps around |
168
+ | `slideTo(index, { instant })` | Show the page that contains the slide |
169
+ | `goToPage(page, { instant })` | |
170
+ | `update()` | Measure again after changing direction or a custom property from script |
171
+ | `destroy()` | Remove everything the script added |
172
+ | `play()`, `pause()` | With the autoplay plugin |
173
+
174
+ The root dispatches `sc:change` with the state as `detail`, once per settled move (on
175
+ `scrollend`, with a timer where that event is missing), and after resizes or content changes that
176
+ alter the state. `getCarousel(root)` returns the carousel attached to a root.
177
+
178
+ Pages are counted from the initial slide, so it always starts a page; the first page may be
179
+ shorter. At the end, the last page shows the last full view. With a group larger than one, only
180
+ page starts are snap points, so a swipe also comes to rest on a page.
181
+
182
+ ## Opening on a later slide without a jump
183
+
184
+ Mark the slide with `data-sc-initial`. If the script runs after the first paint (as module and
185
+ deferred scripts do), render the snippet from `@nordwerk/scroll-carousel/markup` right after the
186
+ root: `<script>${PRE_POSITION}</script>`. It sets the start position while the page is parsed.
187
+ The React, Preact and Astro adapters do this for you when `initial` is set.
188
+
189
+ ## Accessibility
190
+
191
+ - Keyboard: the track is a tab stop; arrow keys move by a page, Home and End go to the ends.
192
+ Links and buttons in slides are reachable with Tab and scroll into view natively.
193
+ - Without the script, or when nothing overflows, arrows and dots are not rendered at all, and a
194
+ row that cannot scroll is no tab stop.
195
+ - Arrows at the ends get `aria-disabled` instead of `disabled`, so focus stays on them.
196
+ - Settled moves are announced in the live region, never on every frame of a swipe, and never
197
+ for autoplay.
198
+ - Autoplay pauses on hover, off-screen and in hidden tabs; keyboard focus entering the carousel
199
+ or any control stops it for good, only the play button restarts it; with reduced motion it
200
+ starts stopped. Moves are instant with reduced motion.
201
+ - A skip link is plain markup: `<a class="sc-skip" href="#after-row">Skip …</a>` before the track.
202
+
203
+ ## Browser support
204
+
205
+ Current Chromium, Firefox and Safari (container queries and container units, `:dir()`,
206
+ `color-mix()`). Where `scrollend` is missing, a short timer stands in for it.
207
+
208
+ ## Replacing a library carousel
209
+
210
+ See [docs/migration.md](docs/migration.md) for option-by-option mapping and what is deliberately
211
+ not supported (true infinite loop, vertical, effects, virtual slides, synced thumbnails).
212
+
213
+ ## Development
214
+
215
+ ```sh
216
+ pnpm install
217
+ pnpm check # typecheck, unit tests, build, size budget, site, fixtures, browser tests
218
+ pnpm serve # the site on http://localhost:4173 after pnpm site
219
+ ```
220
+
221
+ See [AGENTS.md](AGENTS.md) for the repository layout and conventions, and
222
+ [docs/testing.md](docs/testing.md) for what is tested automatically and by hand.
223
+
224
+ ## Licence
225
+
226
+ MIT
@@ -0,0 +1,55 @@
1
+ import { type Carousel, type CarouselOptions, type CarouselState, type Plugin } from './index.js';
2
+ import { type LayoutProps } from './markup.js';
3
+ export interface CarouselProps extends LayoutProps {
4
+ /** Accessible name of the scrolling list. */
5
+ label: string;
6
+ /** Element of the track. With 'ul' every child is wrapped in <li>, otherwise in <div>. */
7
+ as?: 'ul' | 'div';
8
+ children?: unknown;
9
+ className?: string;
10
+ style?: Record<string, string | number>;
11
+ slideClassName?: string;
12
+ /** Mark slides as groups with aria-roledescription="slide" and "n of count" labels. */
13
+ slideRoles?: boolean;
14
+ /** Keep a short row in the middle instead of at the start. */
15
+ centerFew?: boolean;
16
+ /** Default arrows. false renders none, e.g. when the host brings its own. */
17
+ arrows?: boolean;
18
+ /** Default dots. */
19
+ dots?: boolean;
20
+ /** Render a play/pause button for the autoplay plugin. */
21
+ playButton?: boolean;
22
+ labels?: CarouselOptions['labels'] & {
23
+ prev?: string;
24
+ next?: string;
25
+ };
26
+ initial?: number;
27
+ rewind?: CarouselOptions['rewind'];
28
+ group?: LayoutProps['group'];
29
+ plugins?: Plugin[];
30
+ onChange?: (state: CarouselState) => void;
31
+ /** Receives the carousel after attaching, and null after it is destroyed. */
32
+ carouselRef?: (carousel: Carousel | null) => void;
33
+ }
34
+ export interface Framework {
35
+ createElement: (...args: any[]) => any;
36
+ Fragment: any;
37
+ useState: <T>(initial: T) => [T, (value: T) => void];
38
+ useRef: <T>(initial: T) => {
39
+ current: T;
40
+ };
41
+ useEffect: (effect: () => void | (() => void), deps?: unknown[]) => void;
42
+ useLayoutEffect: (effect: () => void | (() => void), deps?: unknown[]) => void;
43
+ useId: () => string;
44
+ toChildArray: (children: unknown) => any[];
45
+ }
46
+ export declare function createAdapter(framework: Framework): {
47
+ Carousel: (props: CarouselProps) => any;
48
+ useCarousel: (options?: CarouselOptions) => {
49
+ ref: {
50
+ current: HTMLElement | null;
51
+ };
52
+ carousel: Carousel | null;
53
+ state: CarouselState | null;
54
+ };
55
+ };
@@ -0,0 +1,14 @@
1
+ import type { Plugin } from './index.js';
2
+ export interface AutoplayOptions {
3
+ /** Milliseconds per page. Default 5000. */
4
+ delay?: number;
5
+ /** Start running on attach. Default true, except with reduced motion. */
6
+ start?: boolean;
7
+ /** Default: [data-sc-play] inside the root. */
8
+ button?: HTMLElement | null;
9
+ labels?: {
10
+ play?: string;
11
+ pause?: string;
12
+ };
13
+ }
14
+ export declare const autoplay: ({ delay, start, button, labels }?: AutoplayOptions) => Plugin;
@@ -0,0 +1,67 @@
1
+ // src/autoplay.ts
2
+ var autoplay = ({ delay = 5e3, start = true, button, labels = {} } = {}) => ({ root, track, listen, layout, step, on }) => {
3
+ const play = button || root.querySelector("[data-sc-play]");
4
+ const holds = /* @__PURE__ */ new Set();
5
+ let playing = start && !matchMedia("(prefers-reduced-motion: reduce)").matches;
6
+ let timer = 0;
7
+ let left = delay;
8
+ let since = 0;
9
+ function sync() {
10
+ const running = playing && !holds.size && layout().pages.length > 1;
11
+ root.toggleAttribute("data-sc-playing", playing);
12
+ root.toggleAttribute("data-sc-paused", playing && !running);
13
+ play?.setAttribute("aria-label", playing ? labels.pause || "Stop automatic scrolling" : labels.play || "Start automatic scrolling");
14
+ if (running && !timer) {
15
+ since = performance.now();
16
+ timer = setTimeout(() => {
17
+ timer = 0;
18
+ left = delay;
19
+ step(1, true, true);
20
+ sync();
21
+ }, left);
22
+ } else if (!running && timer) {
23
+ clearTimeout(timer);
24
+ timer = 0;
25
+ left = Math.max(0, left - (performance.now() - since));
26
+ }
27
+ }
28
+ function set(on2) {
29
+ playing = on2;
30
+ left = delay;
31
+ clearTimeout(timer);
32
+ timer = 0;
33
+ sync();
34
+ }
35
+ const hold = (reason, on2) => {
36
+ holds[on2 ? "add" : "delete"](reason);
37
+ sync();
38
+ };
39
+ if (track.matches(":hover")) holds.add("hover");
40
+ if (document.hidden) holds.add("hidden");
41
+ if (root.contains(document.activeElement) && !play?.contains(document.activeElement)) playing = false;
42
+ root.style.setProperty("--sc-autoplay-delay", `${delay}ms`);
43
+ on("control", () => playing && set(false));
44
+ on("measure", sync);
45
+ listen(play, "click", () => set(!playing));
46
+ listen(track, "pointerenter", (event) => event.pointerType == "mouse" && hold("hover", true));
47
+ listen(track, "pointerleave", (event) => event.pointerType == "mouse" && hold("hover", false));
48
+ listen(root, "focusin", (event) => !play?.contains(event.target) && playing && set(false));
49
+ listen(document, "visibilitychange", () => hold("hidden", document.hidden));
50
+ const visibility = new IntersectionObserver(([entry]) => hold("offscreen", !entry.isIntersecting));
51
+ visibility.observe(root);
52
+ sync();
53
+ return {
54
+ play: () => set(true),
55
+ pause: () => set(false),
56
+ destroy() {
57
+ clearTimeout(timer);
58
+ visibility.disconnect();
59
+ root.removeAttribute("data-sc-playing");
60
+ root.removeAttribute("data-sc-paused");
61
+ root.style.removeProperty("--sc-autoplay-delay");
62
+ }
63
+ };
64
+ };
65
+ export {
66
+ autoplay
67
+ };