Skip to content

Commit 1a34e41

Browse files
committed
chore: auto hide arrows with single carousel item
1 parent 1d80c00 commit 1a34e41

7 files changed

Lines changed: 310 additions & 57 deletions

File tree

README.md

Lines changed: 102 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ don't have to earn them the hard way.
2626
- 🧱 Layouts with range — stacked or magazine-style aside (left *or* right), arrows wherever you want them
2727
- 🎰 Slots for everything — custom headings, custom arrow icons, your markup wins
2828
- 🎨 ~40 CSS variables to theme with, zero `!important` battles
29+
- 📐 SSR-safe responsive slide counts — `--weburz-carousel-slides` in a media query, no hydration snap
2930
- 🧩 Auto-imported composables: `useCarousel`, `useYouTubePlayer`, `useInstagramEmbed`, `useTikTokEmbed`, `useEmbedMetadata`
3031
- 🟦 First-class TypeScript types
3132

@@ -132,9 +133,9 @@ Shared by all carousels:
132133
| ---- | ---- | ------- | ----------- |
133134
| `options` | `EmblaOptionsType` | `{}` | Embla carousel options |
134135
| `plugins` | `EmblaPluginType[]` | `[]` | Embla plugins (autoplay, etc.) |
135-
| `slidesPerView` | `number` | `1` | Slides visible at once |
136-
| `showArrows` | `boolean` | `true` | Render prev/next arrows |
137-
| `showDots` | `boolean` | `true` | Render dot pagination |
136+
| `slidesPerView` | `number` | `1` | Slides visible at once. For viewport-dependent counts prefer the `--weburz-carousel-slides` CSS variable — see [Responsive slide counts](#responsive-slide-counts-ssr-safe) |
137+
| `showArrows` | `boolean` | `true` | Render prev/next arrows — auto-hidden when there's only one scroll position |
138+
| `showDots` | `boolean` | `true` | Render dot pagination — hidden when there's only one scroll position |
138139
| `arrowPosition` | `'sides' \| 'below'` | `'below'` | Arrows flanking the dots (default) or beside the stage |
139140
| `layout` | `'stacked' \| 'aside'` | `'stacked'` | `'aside'` puts title, description, and nav in a side column next to the carousel (≥768px; stacks below that) |
140141
| `asidePosition` | `'left' \| 'right'` | `'left'` | Which side the aside column sits on (only applies with `layout="aside"`) |
@@ -248,6 +249,8 @@ styles:
248249
--weburz-carousel-accent: #00dc82;
249250
--weburz-carousel-gap: 1rem; /* stack gap: heading / stage / nav */
250251
--weburz-carousel-slide-gap: 1rem; /* gap between slides */
252+
/* --weburz-carousel-slides: 3; slides per view — wins over the slidesPerView
253+
prop; see Responsive slide counts below */
251254
/* Arrows are plain chevrons by default — add bg/border to get buttons back */
252255
--weburz-carousel-arrow-size: 2rem;
253256
--weburz-carousel-arrow-font-size: 1.5rem;
@@ -325,6 +328,41 @@ html.dark {
325328
watcher. Arrows and dots default to `currentColor`, so even with zero mapping
326329
they already match the surrounding text color.
327330

331+
## Responsive slide counts (SSR-safe)
332+
333+
`slidesPerView` is a plain number, so driving it from JavaScript viewport
334+
detection (`useMediaQuery` and friends) has an SSR problem: media queries
335+
can't run on the server, so the server guesses one count, the browser
336+
hydrates to another, and the first slide visibly snaps to its real width.
337+
338+
Set the count in CSS instead. The `--weburz-carousel-slides` variable takes
339+
precedence over the prop and lives in plain CSS, media queries included — the
340+
server-rendered HTML is correct for every viewport, no JavaScript involved:
341+
342+
```vue
343+
<BaseCarousel class="cards" :options="{ align: 'start' }">
344+
<BaseSlide v-for="card in cards" :key="card.id">…</BaseSlide>
345+
</BaseCarousel>
346+
```
347+
348+
```css
349+
.cards {
350+
--weburz-carousel-slides: 1;
351+
}
352+
353+
@media (min-width: 48rem) {
354+
.cards {
355+
--weburz-carousel-slides: 3;
356+
}
357+
}
358+
```
359+
360+
This works on the platform carousels too — their slides live in the same
361+
cascade, so a class + media query on `<TikTokCarousel>` behaves identically.
362+
363+
Keep the prop for counts that are genuinely static (`:slides-per-view="2"`)
364+
or genuinely dynamic at runtime — the variable, when set, always wins.
365+
328366
## Module options
329367

330368
| Option | Type | Default | Description |
@@ -351,59 +389,82 @@ export default defineNuxtConfig({
351389
- `useTikTokEmbed()``load` / `reload` for TikTok's `embed.js` with the cache-bust trick needed after SPA navigation.
352390
- `useEmbedMetadata()``forYouTube(videoId)` / `forTikTok(url)` fetch `{ title, authorName, thumbnailUrl }` from the platforms' public oEmbed endpoints, cached per URL.
353391

354-
## Contribution
392+
## Contributing
355393

356-
<details>
357-
<summary>Local development</summary>
394+
Contributions are welcome — bug reports, fixes, new platform quirk
395+
workarounds, docs. Day-to-day commands are wrapped in a
396+
[Taskfile](https://taskfile.dev), so you don't have to memorize the pnpm
397+
script names.
358398

359-
```bash
360-
pnpm install
361-
pnpm run dev:prepare # generate Nuxt type stubs
362-
pnpm run dev # run the playground
363-
pnpm run test
364-
pnpm run lint
365-
```
399+
### Prerequisites
366400

367-
</details>
401+
- [Node.js](https://nodejs.org/) ≥ 20
402+
- [pnpm](https://pnpm.io/) 11 (`corepack enable` or `npm i -g pnpm`)
403+
- [Task](https://taskfile.dev/installation/) (`brew install go-task` /
404+
`scoop install task` / see the install docs)
405+
- [pre-commit](https://pre-commit.com/) — optional but recommended
406+
(`brew install pre-commit` or `pipx install pre-commit`)
368407

369-
<details>
370-
<summary>Pre-commit hooks</summary>
408+
### Getting started
371409

372-
This repo uses [pre-commit](https://pre-commit.com/) to enforce formatting,
373-
lint staged files, and validate commit messages (conventional-commits style,
374-
required by `changelogen` for the auto-generated [CHANGELOG](./CHANGELOG.md)).
410+
```bash
411+
git clone https://github.com/Weburz/carousel.git
412+
cd carousel
413+
task setup # pnpm install + generate Nuxt type stubs
414+
task setup:hooks # activate the pre-commit hooks (optional)
415+
task dev # run the playground at http://localhost:3000
416+
```
375417

376-
Install once per clone:
418+
The playground (`playground/`) is a real Nuxt app with the module stubbed in —
419+
edit anything under `src/` and it hot-reloads.
377420

378-
```bash
379-
# If you don't have pre-commit yet:
380-
brew install pre-commit # macOS / Linuxbrew
381-
# or: pipx install pre-commit
382-
# or: pip install --user pre-commit
421+
### Everyday tasks
383422

384-
# Activate the hooks in this repo:
385-
pnpm setup:hooks
386-
```
423+
Run `task` (no arguments) to see the full list. The ones you'll use most:
387424

388-
Skip hooks for a single commit with `git commit --no-verify` (use sparingly).
425+
| Command | What it does |
426+
| ------- | ------------ |
427+
| `task setup` | Install dependencies and generate Nuxt type stubs (run once per clone) |
428+
| `task dev` | Run the playground with hot reload |
429+
| `task test` | Run the test suite once (`task test:watch` for watch mode) |
430+
| `task test:types` | Type-check the module and the playground |
431+
| `task lint` | Lint everything (`task lint:fix` to auto-fix) |
432+
| `task check` | Everything CI runs: lint, type-check, tests, build |
433+
| `task build` | Build the distributable module into `dist/` |
434+
| `task clean` | Remove build artifacts (`task clean:all` also nukes `node_modules`) |
389435

390-
</details>
436+
Before opening a PR, run `task check` — it mirrors the `Code QA Checks`
437+
GitHub workflow, so if it passes locally, CI should too.
391438

392-
<details>
393-
<summary>Release flow</summary>
439+
### Project layout
394440

395-
Local one-liner that lints, tests, builds the module, bumps the version +
396-
CHANGELOG via `changelogen`, publishes to npm, and pushes the tag:
441+
```
442+
src/module.ts # Nuxt module entry — registers components & composables
443+
src/runtime/components/ # BaseCarousel, YouTubeCarousel, InstagramCarousel, TikTokCarousel, …
444+
src/runtime/composables/ # useCarousel, useYouTubePlayer, useEmbedMetadata, …
445+
src/runtime/types.ts # public TypeScript types
446+
playground/ # demo Nuxt app for local dev (deployed to GitHub Pages)
447+
test/ # Vitest + @nuxt/test-utils suite
448+
```
449+
450+
### Commit messages & hooks
451+
452+
Commits must follow
453+
[Conventional Commits](https://www.conventionalcommits.org/) (`feat: …`,
454+
`fix: …`, `chore: …`) — the CHANGELOG is auto-generated from them by
455+
`changelogen`. The pre-commit hooks (installed via `task setup:hooks`) lint
456+
staged files and validate the commit message for you. Skip them for a single
457+
commit with `git commit --no-verify` (use sparingly).
397458

398-
```bash
399-
pnpm release
400-
```
459+
### Releases (maintainers)
401460

402-
The `Deploy Playground` workflow publishes `playground/` to GitHub Pages on
403-
every push to `main`. Enable Pages in the repository settings and set
404-
**Source** to "GitHub Actions" to activate it.
461+
```bash
462+
task release
463+
```
405464

406-
</details>
465+
Lints, tests, builds the module, bumps the version + CHANGELOG via
466+
`changelogen`, publishes to npm, and pushes the tag. The `Deploy Playground`
467+
workflow publishes `playground/` to GitHub Pages on every push to `main`.
407468

408469
## License
409470

Taskfile.yml

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
# https://taskfile.dev
2+
version: "3"
3+
4+
tasks:
5+
default:
6+
desc: "List all available tasks"
7+
silent: true
8+
cmds:
9+
- task --list
10+
11+
setup:
12+
desc: "Install dependencies and generate Nuxt type stubs (run once per clone)"
13+
cmds:
14+
- pnpm install
15+
- pnpm run dev:prepare
16+
17+
setup:hooks:
18+
desc: "Install the pre-commit hooks (formatting, lint, commit-message checks)"
19+
cmds:
20+
- pnpm run setup:hooks
21+
22+
dev:
23+
desc: "Run the playground with the module stubbed in (hot reload)"
24+
cmds:
25+
- pnpm run dev
26+
27+
build:
28+
desc: "Build the distributable module into dist/"
29+
cmds:
30+
- pnpm run prepack
31+
32+
build:playground:
33+
desc: "Build the playground app"
34+
cmds:
35+
- pnpm run dev:build
36+
37+
generate:
38+
desc: "Generate the static playground site (what GitHub Pages deploys)"
39+
cmds:
40+
- pnpm exec nuxi generate playground
41+
42+
test:
43+
desc: "Run the test suite once"
44+
cmds:
45+
- pnpm run test
46+
47+
test:watch:
48+
desc: "Run the test suite in watch mode"
49+
cmds:
50+
- pnpm run test:watch
51+
52+
test:types:
53+
desc: "Type-check the module and the playground"
54+
cmds:
55+
- pnpm run test:types
56+
57+
lint:
58+
desc: "Lint all files with ESLint"
59+
cmds:
60+
- pnpm run lint
61+
62+
lint:fix:
63+
desc: "Lint and auto-fix all files with ESLint"
64+
cmds:
65+
- pnpm run lint:fix
66+
67+
check:
68+
desc: "Run everything CI runs: lint, type-check, tests, build"
69+
cmds:
70+
- task: lint
71+
- task: test:types
72+
- task: test
73+
- task: build
74+
75+
clean:
76+
desc: "Remove build artifacts and generated dirs (dist, .nuxt, .output, coverage)"
77+
cmds:
78+
- rm -rf dist .nuxt .output coverage
79+
- rm -rf playground/.nuxt playground/.output playground/dist
80+
- rm -rf test/fixtures/*/.nuxt test/fixtures/*/.output
81+
82+
clean:all:
83+
desc: "Clean everything including node_modules (follow with `task setup`)"
84+
cmds:
85+
- task: clean
86+
- rm -rf node_modules playground/node_modules
87+
88+
release:
89+
desc: "Lint, test, build, bump version + CHANGELOG, publish to npm, push tags"
90+
cmds:
91+
- pnpm run release

playground/app.vue

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,34 @@
22
<div class="stage">
33
<h1>@weburz/carousel playground</h1>
44

5+
<section class="demo">
6+
<h2>Responsive slide count — the --weburz-carousel-slides CSS var (SSR-safe)</h2>
7+
<p class="demo-note">
8+
The slide count lives in CSS media queries (1 below 48rem, 3 above),
9+
so the server-rendered HTML is already viewport-correct — no hydration
10+
width snap, unlike a slidesPerView prop driven by JS viewport
11+
detection. The var wins over the prop, which stays as the static
12+
fallback.
13+
</p>
14+
<BaseCarousel
15+
class="responsive-slides"
16+
:options="{ align: 'start' }"
17+
aria-label="CSS-var responsive carousel"
18+
>
19+
<BaseSlide
20+
v-for="slide in slides"
21+
:key="slide.id"
22+
>
23+
<div
24+
class="slide-card slide-card--tall"
25+
:style="{ background: slide.color }"
26+
>
27+
{{ slide.label }}
28+
</div>
29+
</BaseSlide>
30+
</BaseCarousel>
31+
</section>
32+
533
<section class="demo">
634
<h2>YouTubeCarousel — captions: explicit on slide 1, auto-fetched on 2 &amp; 3</h2>
735
<YouTubeCarousel
@@ -268,6 +296,25 @@
268296
</BaseCarousel>
269297
</section>
270298

299+
<section class="demo">
300+
<h2>Single slide — arrows and dots auto-hide</h2>
301+
<p class="demo-note">
302+
One slide means one scroll position: dots never render, and the arrows
303+
are dropped once Embla confirms the count. (SSR keeps arrows while the
304+
count is unknown so multi-slide carousels don't get a nav pop-in.)
305+
</p>
306+
<BaseCarousel aria-label="Single slide carousel">
307+
<BaseSlide>
308+
<div
309+
class="slide-card slide-card--short"
310+
style="background: #555"
311+
>
312+
The only slide — no chrome below
313+
</div>
314+
</BaseSlide>
315+
</BaseCarousel>
316+
</section>
317+
271318
<section class="demo">
272319
<h2>Dots only / arrows only</h2>
273320
<div class="demo-grid">
@@ -451,6 +498,22 @@ body,
451498
font-weight: 600;
452499
}
453500
501+
.slide-card--tall {
502+
height: 320px;
503+
}
504+
505+
/* SSR-correct responsive slide count: plain CSS, so the server output already
506+
matches the viewport — no hydration snap. */
507+
.responsive-slides {
508+
--weburz-carousel-slides: 1;
509+
}
510+
511+
@media (min-width: 48rem) {
512+
.responsive-slides {
513+
--weburz-carousel-slides: 3;
514+
}
515+
}
516+
454517
.slide-card--short {
455518
height: 120px;
456519
font-size: 1rem;

0 commit comments

Comments
 (0)