@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 +114 -0
- package/CHANGELOG.md +15 -0
- package/LICENSE +21 -0
- package/README.md +226 -0
- package/dist/adapter.d.ts +55 -0
- package/dist/autoplay.d.ts +14 -0
- package/dist/autoplay.js +67 -0
- package/dist/carousel.css +258 -0
- package/dist/drag.d.ts +6 -0
- package/dist/drag.js +79 -0
- package/dist/index.d.ts +95 -0
- package/dist/index.js +9 -0
- package/dist/markup.d.ts +46 -0
- package/dist/markup.js +14 -0
- package/dist/math.d.ts +45 -0
- package/dist/preact.d.ts +9 -0
- package/dist/preact.js +24 -0
- package/dist/react.d.ts +9 -0
- package/dist/react.js +23 -0
- package/dist/shared/chunk-3THCBXDW.js +78 -0
- package/dist/shared/chunk-5Q4YPVNK.js +109 -0
- package/dist/shared/chunk-HPGJKTV5.js +377 -0
- package/dist/shared/chunk-I4ZMRKZV.js +60 -0
- package/docs/migration.md +84 -0
- package/package.json +106 -0
- package/src/astro/Carousel.astro +88 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# Replacing a library carousel
|
|
2
|
+
|
|
3
|
+
Option and API names on the left are those of Swiper, the most widely used carousel library;
|
|
4
|
+
other libraries name the same things similarly. The main change of mindset: layout moves from
|
|
5
|
+
JavaScript options into CSS, so breakpoints become media or container queries instead of
|
|
6
|
+
options that the script re-applies after load.
|
|
7
|
+
|
|
8
|
+
## Options
|
|
9
|
+
|
|
10
|
+
| Library | Here | Notes |
|
|
11
|
+
| --- | --- | --- |
|
|
12
|
+
| `slidesPerView: 3` | `--sc-per-view: 3` | Fractions work the same way |
|
|
13
|
+
| `slidesPerView: 'auto'` | `--sc-slide-size: auto` | Or any length, e.g. `18rem` |
|
|
14
|
+
| `spaceBetween: 16` | `--sc-gap: 16px` | |
|
|
15
|
+
| `slidesOffsetBefore` / `After` | `--sc-offset-before` / `--sc-offset-after` | Snapped slides line up after the offset |
|
|
16
|
+
| `breakpoints: { … }` | `@media` or `@container sc (min-width: …)` | Container queries follow the carousel's own width |
|
|
17
|
+
| `slidesPerGroup: 3` | `--sc-group: 3` | Only group starts are snap points |
|
|
18
|
+
| `slidesPerGroupAuto` | `--sc-group: page` | As many whole slides as fit |
|
|
19
|
+
| `centeredSlides: true` | `--sc-align: center; --sc-centered: 1` | |
|
|
20
|
+
| `centeredSlidesBounds` | `--sc-align: center` without `--sc-centered` | |
|
|
21
|
+
| `centerInsufficientSlides` | class `sc--center-few` | |
|
|
22
|
+
| clamping slides per view to the slide count | `--sc-count: <n>` | |
|
|
23
|
+
| `initialSlide: 4` | `data-sc-initial` on the slide, or `initial: 4` | Plus the inline `PRE_POSITION` snippet for server rendering |
|
|
24
|
+
| `freeMode: true` | `--sc-snap: none` | |
|
|
25
|
+
| `freeMode: { sticky: true }` | `--sc-snap: mandatory` | Native momentum still carries across several slides |
|
|
26
|
+
| `loop: true` | `rewind: true` | Fades back to the start instead of cloning slides |
|
|
27
|
+
| `rewind: true` | `rewind: 'scroll'` | Scrolls back across all slides |
|
|
28
|
+
| `autoplay: { delay }` | `autoplay({ delay })` plugin | Adds a required pause button; focus stops it |
|
|
29
|
+
| `simulateTouch` (mouse drag) | `drag()` plugin | Opt-in |
|
|
30
|
+
| `navigation` | `[data-sc-prev]`, `[data-sc-next]` on any element | Or call `next()` / `prev()` from your own components |
|
|
31
|
+
| `pagination` (bullets) | `[data-sc-dots]` | Or render your own from `state.page` / `state.pageCount` |
|
|
32
|
+
| `watchOverflow` | automatic | `data-sc-overflow` on the root; controls hide without it |
|
|
33
|
+
| `a11y` module | built in | Live region, `aria-disabled`, keyboard |
|
|
34
|
+
| `lazy` | native `loading="lazy"` on images | |
|
|
35
|
+
| `speed` | not configurable | Native smooth scrolling; instant with reduced motion |
|
|
36
|
+
| `allowTouchMove: false` | `overflow-x: hidden` on the track | Arrows and the API still work |
|
|
37
|
+
|
|
38
|
+
## API
|
|
39
|
+
|
|
40
|
+
| Library | Here |
|
|
41
|
+
| --- | --- |
|
|
42
|
+
| `new Swiper(el, options)` | `attach(root, options)` |
|
|
43
|
+
| `swiper.slideTo(i)` | `carousel.slideTo(i)` (shows the page that contains slide i) |
|
|
44
|
+
| `swiper.slideToLoop(i)` | `carousel.slideTo(i)` |
|
|
45
|
+
| `swiper.slideNext()` / `slidePrev()` | `carousel.next()` / `prev()` |
|
|
46
|
+
| `swiper.activeIndex`, `realIndex` | `carousel.index` |
|
|
47
|
+
| `swiper.snapIndex` | `carousel.page` |
|
|
48
|
+
| `swiper.isBeginning` / `isEnd` | the same names |
|
|
49
|
+
| `swiper.slides` | `carousel.slides` |
|
|
50
|
+
| `swiper.el`, `wrapperEl` | `carousel.root`, `carousel.track` |
|
|
51
|
+
| `swiper.update()` | `carousel.update()`, rarely needed: it measures on resize and content changes |
|
|
52
|
+
| `swiper.destroy()` | `carousel.destroy()` |
|
|
53
|
+
| `on('slideChange')` | `sc:change` event on the root, or `onChange`, once per settled move |
|
|
54
|
+
| `onSwiper(swiper)` | `carousel` returned by `attach`, or `carouselRef` in React and Preact |
|
|
55
|
+
|
|
56
|
+
Behaviour that differs:
|
|
57
|
+
|
|
58
|
+
- No change event on attach. A wrapper that promised one should call its handler once with
|
|
59
|
+
`carousel.state` after attaching.
|
|
60
|
+
- `index` is the slide at the snap position. At the end of a row it is the first slide of the
|
|
61
|
+
last full view, not the last slide.
|
|
62
|
+
- Arrows at the ends are disabled only without `rewind`; with `rewind` they stay enabled.
|
|
63
|
+
- There are no duplicate slides, so code that skips `.swiper-slide-duplicate` or reads
|
|
64
|
+
`data-swiper-slide-index` goes away, as do workarounds that reset a container's scroll
|
|
65
|
+
position after focus moved into a slide.
|
|
66
|
+
|
|
67
|
+
## Deliberately not supported
|
|
68
|
+
|
|
69
|
+
- A true infinite loop (cloned slides and a teleported scroll position). It is fragile on native
|
|
70
|
+
scrolling and confusing for assistive technology; use `rewind`.
|
|
71
|
+
- Vertical carousels, zoom, a draggable scrollbar, slide effects (fade, cube, flip), virtual
|
|
72
|
+
slides, parallax, and syncing thumbnails with a second carousel. Thumbnails can call
|
|
73
|
+
`slideTo()` and listen to `sc:change`.
|
|
74
|
+
- Changing the transition speed or easing of moves.
|
|
75
|
+
|
|
76
|
+
## Migrating step by step
|
|
77
|
+
|
|
78
|
+
1. Keep your component's public props and imperative handle; swap its internals.
|
|
79
|
+
2. Move per-device options into CSS: one media or container query per former breakpoint.
|
|
80
|
+
3. Replace loop with rewind; check with product owners where an endless loop was visible.
|
|
81
|
+
4. Keep your own arrows and dots, bound to `next()`, `prev()`, `goToPage()` and `sc:change`.
|
|
82
|
+
5. Remove layout workarounds made for script-computed layout, such as first-image fixes for
|
|
83
|
+
server rendering.
|
|
84
|
+
6. Behind a flag, migrate one call site at a time and compare with visual tests.
|
package/package.json
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nordwerk/scroll-carousel",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A dependency-free carousel on native scrolling: CSS scroll snap first, arrows, dots, paging and an API as progressive enhancement.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/studio-nordwerk/scroll-carousel.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://studio-nordwerk.github.io/scroll-carousel/",
|
|
12
|
+
"keywords": [
|
|
13
|
+
"carousel",
|
|
14
|
+
"slider",
|
|
15
|
+
"scroll-snap",
|
|
16
|
+
"progressive-enhancement",
|
|
17
|
+
"accessibility",
|
|
18
|
+
"react",
|
|
19
|
+
"preact",
|
|
20
|
+
"astro"
|
|
21
|
+
],
|
|
22
|
+
"sideEffects": [
|
|
23
|
+
"*.css"
|
|
24
|
+
],
|
|
25
|
+
"exports": {
|
|
26
|
+
".": {
|
|
27
|
+
"types": "./dist/index.d.ts",
|
|
28
|
+
"default": "./dist/index.js"
|
|
29
|
+
},
|
|
30
|
+
"./drag": {
|
|
31
|
+
"types": "./dist/drag.d.ts",
|
|
32
|
+
"default": "./dist/drag.js"
|
|
33
|
+
},
|
|
34
|
+
"./autoplay": {
|
|
35
|
+
"types": "./dist/autoplay.d.ts",
|
|
36
|
+
"default": "./dist/autoplay.js"
|
|
37
|
+
},
|
|
38
|
+
"./markup": {
|
|
39
|
+
"types": "./dist/markup.d.ts",
|
|
40
|
+
"default": "./dist/markup.js"
|
|
41
|
+
},
|
|
42
|
+
"./react": {
|
|
43
|
+
"types": "./dist/react.d.ts",
|
|
44
|
+
"default": "./dist/react.js"
|
|
45
|
+
},
|
|
46
|
+
"./preact": {
|
|
47
|
+
"types": "./dist/preact.d.ts",
|
|
48
|
+
"default": "./dist/preact.js"
|
|
49
|
+
},
|
|
50
|
+
"./astro": "./src/astro/Carousel.astro",
|
|
51
|
+
"./carousel.css": "./dist/carousel.css",
|
|
52
|
+
"./package.json": "./package.json"
|
|
53
|
+
},
|
|
54
|
+
"files": [
|
|
55
|
+
"dist",
|
|
56
|
+
"src/astro",
|
|
57
|
+
"AGENTS.md",
|
|
58
|
+
"CHANGELOG.md",
|
|
59
|
+
"docs/migration.md"
|
|
60
|
+
],
|
|
61
|
+
"publishConfig": {
|
|
62
|
+
"access": "public"
|
|
63
|
+
},
|
|
64
|
+
"scripts": {
|
|
65
|
+
"build": "node scripts/build.mjs",
|
|
66
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
67
|
+
"test": "node --test \"test/unit/*.test.ts\"",
|
|
68
|
+
"size": "node scripts/size.mjs",
|
|
69
|
+
"site": "node scripts/build.mjs && node site/generate.mjs",
|
|
70
|
+
"fixtures": "node scripts/fixtures.mjs",
|
|
71
|
+
"test:e2e": "playwright test",
|
|
72
|
+
"serve": "node scripts/serve.mjs",
|
|
73
|
+
"check": "pnpm typecheck && pnpm test && pnpm site && pnpm size && pnpm fixtures && pnpm test:e2e",
|
|
74
|
+
"prepublishOnly": "node scripts/build.mjs"
|
|
75
|
+
},
|
|
76
|
+
"peerDependencies": {
|
|
77
|
+
"preact": ">=10",
|
|
78
|
+
"react": ">=18"
|
|
79
|
+
},
|
|
80
|
+
"peerDependenciesMeta": {
|
|
81
|
+
"preact": {
|
|
82
|
+
"optional": true
|
|
83
|
+
},
|
|
84
|
+
"react": {
|
|
85
|
+
"optional": true
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
"devDependencies": {
|
|
89
|
+
"@axe-core/playwright": "4.13.0",
|
|
90
|
+
"@playwright/test": "1.61.1",
|
|
91
|
+
"@types/node": "^24.0.0",
|
|
92
|
+
"@types/react": "^19.3.0",
|
|
93
|
+
"@types/react-dom": "^19.3.0",
|
|
94
|
+
"astro": "^7.3.3",
|
|
95
|
+
"esbuild": "0.28.2",
|
|
96
|
+
"preact": "^10.29.8",
|
|
97
|
+
"preact-render-to-string": "6.7.0",
|
|
98
|
+
"react": "^19.3.0",
|
|
99
|
+
"react-dom": "^19.3.0",
|
|
100
|
+
"typescript": "^7.0.2"
|
|
101
|
+
},
|
|
102
|
+
"engines": {
|
|
103
|
+
"node": ">=22"
|
|
104
|
+
},
|
|
105
|
+
"packageManager": "pnpm@12.5.1"
|
|
106
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
---
|
|
2
|
+
/*
|
|
3
|
+
* Astro adapter. Renders the carousel markup around the slides you pass in the default slot
|
|
4
|
+
* (one element per slide: <li> when as="ul", otherwise any element) and attaches the core in the
|
|
5
|
+
* browser. Drag and autoplay are loaded only when a carousel on the page asks for them.
|
|
6
|
+
*/
|
|
7
|
+
import '@nordwerk/scroll-carousel/carousel.css';
|
|
8
|
+
import { PRE_POSITION, baseVars, responsiveCss, type LayoutProps } from '@nordwerk/scroll-carousel/markup';
|
|
9
|
+
|
|
10
|
+
interface Props extends LayoutProps {
|
|
11
|
+
/** Accessible name of the scrolling list. */
|
|
12
|
+
label: string;
|
|
13
|
+
as?: 'ul' | 'div';
|
|
14
|
+
class?: string;
|
|
15
|
+
style?: string;
|
|
16
|
+
centerFew?: boolean;
|
|
17
|
+
arrows?: boolean;
|
|
18
|
+
dots?: boolean;
|
|
19
|
+
initial?: number;
|
|
20
|
+
rewind?: boolean | 'fade' | 'scroll';
|
|
21
|
+
drag?: boolean;
|
|
22
|
+
/** Milliseconds per page; renders the play button. */
|
|
23
|
+
autoplay?: number;
|
|
24
|
+
/** Label templates: page 'Slide {n} of {count}', status 'Items {first} to {last} of {count}', prev, next. */
|
|
25
|
+
labels?: { page?: string; status?: string; prev?: string; next?: string; play?: string; pause?: string };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const props = Astro.props;
|
|
29
|
+
const { as: Track = 'div', label, class: className, style = '', centerFew, arrows = true, dots = true } = props;
|
|
30
|
+
const { initial = 0, rewind, drag, autoplay, labels = {} } = props;
|
|
31
|
+
const id = `sc${Math.random().toString(36).slice(2, 10)}`;
|
|
32
|
+
const css = responsiveCss(id, props);
|
|
33
|
+
const vars = Object.entries(baseVars(props)).map(([name, value]) => `${name}:${value}`).join(';');
|
|
34
|
+
const options = JSON.stringify({ initial, rewind, drag, autoplay, labels, group: typeof props.group == 'object' ? undefined : props.group });
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
<div class:list={['sc', { 'sc--center-few': centerFew }, className]} data-sc-id={id} data-sc-astro={options} style={[vars, style].filter(Boolean).join(';')}>
|
|
38
|
+
{css && <style set:html={css} />}
|
|
39
|
+
<Track class="sc-track" data-sc-track tabindex="0" aria-label={label}><slot /></Track>
|
|
40
|
+
{arrows && (
|
|
41
|
+
<>
|
|
42
|
+
<button type="button" class="sc-nav sc-prev" data-sc-prev aria-label={labels.prev ?? 'Previous'}>
|
|
43
|
+
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m15 18-6-6 6-6" /></svg>
|
|
44
|
+
</button>
|
|
45
|
+
<button type="button" class="sc-nav sc-next" data-sc-next aria-label={labels.next ?? 'Next'}>
|
|
46
|
+
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m9 6 6 6-6 6" /></svg>
|
|
47
|
+
</button>
|
|
48
|
+
</>
|
|
49
|
+
)}
|
|
50
|
+
{autoplay && (
|
|
51
|
+
<button type="button" class="sc-play" data-sc-play aria-label={labels.pause ?? 'Stop automatic scrolling'}>
|
|
52
|
+
<svg class="sc-icon-play" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M8 5v14l11-7z" /></svg>
|
|
53
|
+
<svg class="sc-icon-pause" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M7 5h3.5v14H7zM13.5 5H17v14h-3.5z" /></svg>
|
|
54
|
+
</button>
|
|
55
|
+
)}
|
|
56
|
+
{dots && <div class="sc-dots" data-sc-dots></div>}
|
|
57
|
+
<p class="sc-status" data-sc-status aria-live="polite"></p>
|
|
58
|
+
</div>
|
|
59
|
+
{initial > 0 && <script is:inline set:html={PRE_POSITION} />}
|
|
60
|
+
|
|
61
|
+
<script>
|
|
62
|
+
import { attach, getCarousel, type Plugin } from '@nordwerk/scroll-carousel';
|
|
63
|
+
import { templateLabels } from '@nordwerk/scroll-carousel/markup';
|
|
64
|
+
|
|
65
|
+
async function attachAll() {
|
|
66
|
+
for (const root of document.querySelectorAll<HTMLElement>('[data-sc-astro]')) {
|
|
67
|
+
if (getCarousel(root)) continue;
|
|
68
|
+
const options = JSON.parse(root.dataset.scAstro || '{}');
|
|
69
|
+
const plugins: Plugin[] = [];
|
|
70
|
+
if (options.drag) plugins.push((await import('@nordwerk/scroll-carousel/drag')).drag());
|
|
71
|
+
if (options.autoplay) {
|
|
72
|
+
const { autoplay } = await import('@nordwerk/scroll-carousel/autoplay');
|
|
73
|
+
plugins.push(autoplay({ delay: options.autoplay, labels: options.labels }));
|
|
74
|
+
}
|
|
75
|
+
// The page may have changed while the plugins loaded.
|
|
76
|
+
if (root.isConnected) attach(root, { ...options, labels: templateLabels(options.labels), plugins });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
attachAll();
|
|
81
|
+
document.addEventListener('astro:page-load', attachAll);
|
|
82
|
+
// With view transitions, carousels that leave the page must let go of their listeners.
|
|
83
|
+
document.addEventListener('astro:before-swap', () => {
|
|
84
|
+
for (const root of document.querySelectorAll<HTMLElement>('[data-sc-astro]')) {
|
|
85
|
+
if (!root.closest('[data-astro-transition-persist]')) getCarousel(root)?.destroy();
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
</script>
|