srcdev-nuxt-components 9.1.38 → 9.1.39

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.
Files changed (20) hide show
  1. package/.claude/settings.json +4 -1
  2. package/.claude/skills/components/carousel-flip.md +188 -0
  3. package/.claude/skills/index.md +3 -1
  4. package/.claude/skills/qa-panel.md +231 -0
  5. package/app/components/01.atoms/{grid-stack → grids/grid-stack}/stories/GridStack.stories.ts +1 -1
  6. package/app/components/carousels/CarouselFlip.vue +211 -153
  7. package/app/components/carousels/stories/CarouselFlip.stories.ts +35 -0
  8. package/app/components/carousels/tests/CarouselFlip.spec.ts +38 -0
  9. package/app/pages/ui/carousel-flip.vue +222 -17
  10. package/package.json +1 -1
  11. /package/app/components/01.atoms/{scroll-reveal-frame → animations/scroll-reveal-frame}/ScrollRevealFrame.vue +0 -0
  12. /package/app/components/01.atoms/{scroll-reveal-frame → animations/scroll-reveal-frame}/stories/ScrollRevealFrame.stories.ts +0 -0
  13. /package/app/components/01.atoms/{scroll-reveal-frame → animations/scroll-reveal-frame}/tests/ScrollRevealFrame.spec.ts +0 -0
  14. /package/app/components/01.atoms/{scroll-reveal-frame → animations/scroll-reveal-frame}/tests/__snapshots__/ScrollRevealFrame.spec.ts.snap +0 -0
  15. /package/app/components/01.atoms/{scroll-reveal-image → animations/scroll-reveal-image}/ScrollRevealImage.vue +0 -0
  16. /package/app/components/01.atoms/{scroll-reveal-image → animations/scroll-reveal-image}/stories/ScrollRevealImage.stories.ts +0 -0
  17. /package/app/components/01.atoms/{scroll-reveal-image → animations/scroll-reveal-image}/tests/ScrollRevealImage.spec.ts +0 -0
  18. /package/app/components/01.atoms/{scroll-reveal-image → animations/scroll-reveal-image}/tests/__snapshots__/ScrollRevealImage.spec.ts.snap +0 -0
  19. /package/app/components/01.atoms/{grid-stack → grids/grid-stack}/GridStack.vue +0 -0
  20. /package/app/components/01.atoms/{grid-stack → grids/grid-stack}/tests/GridStack.spec.ts +0 -0
@@ -23,7 +23,10 @@
23
23
  "Bash(npm run:*)",
24
24
  "Bash(git add:*)",
25
25
  "Bash(gh release:*)",
26
- "Bash(node -e ':*)"
26
+ "Bash(node -e ':*)",
27
+ "Bash(git ls-tree *)",
28
+ "Bash(node -p \"require\\('./package.json'\\).version\")",
29
+ "Bash(git status *)"
27
30
  ],
28
31
  "additionalDirectories": []
29
32
  }
@@ -0,0 +1,188 @@
1
+ # CarouselFlip
2
+
3
+ ## Overview
4
+
5
+ A FLIP-animated carousel that reorders items in the DOM using CSS `order` and animates transitions with the FLIP technique (First, Last, Invert, Play). Supports swipe, keyboard navigation, and marker dots. The prev/next buttons and controls bar can be placed in several layouts via a single prop.
6
+
7
+ ---
8
+
9
+ ## Implementation guide
10
+
11
+ When a dev asks to implement CarouselFlip, work through the following questions before writing any code. Each answer maps directly to a prop or CSS decision. You do not need to ask all questions at once — use context clues where the answer is obvious.
12
+
13
+ ### Step 1 — Data source
14
+
15
+ Ask: **"Where is the carousel data coming from — a static array, an API, or a Nuxt `useFetch`?"**
16
+
17
+ - **Static array**: Define `carouselDataIds` directly as a `const`.
18
+ - **API / useFetch**: Derive `carouselDataIds` as a `computed` from the response. Gate the component with `v-if="status === 'success'"` to avoid a flash of empty slots.
19
+
20
+ ```vue
21
+ <!-- API pattern -->
22
+ const { data, status } = await useFetch<MyType>("/api/items");
23
+ const carouselDataIds = computed(() => data.value?.items.map(i => i.id) ?? []);
24
+
25
+ <CarouselFlip v-if="status === 'success'" :carousel-data-ids="carouselDataIds">
26
+ <template v-for="item in data.items" :key="item.id" #[item.id]>
27
+ <!-- slot content -->
28
+ </template>
29
+ </CarouselFlip>
30
+ ```
31
+
32
+ ### Step 2 — Button layout
33
+
34
+ Ask: **"Where should the prev/next buttons sit?"** Show these options:
35
+
36
+ | Value | Visual description |
37
+ |---|---|
38
+ | `"sides"` | Buttons float on the left and right edges of the carousel frame, centred vertically — the classic look |
39
+ | `"controls-flanking"` | Buttons move down into the controls row: prev · markers · next |
40
+ | `"controls-grouped-right"` | Markers stretch left, both buttons grouped together at the far right of the controls row |
41
+ | `"overlay"` | Buttons stay on the sides; the markers bar overlays the bottom edge of the carousel frame |
42
+
43
+ Set via `:button-layout="..."`. Default is `"sides"`.
44
+
45
+ ### Step 3 — Show or hide the markers bar
46
+
47
+ Ask: **"Do you want the dot/marker navigation bar visible?"**
48
+
49
+ - **Yes** (default): omit the prop or pass `:show-controls="true"`.
50
+ - **No**: pass `:show-controls="false"`. The element is fully removed from the DOM — no layout space, no keyboard listener.
51
+
52
+ > If `showControls` is false and `buttonLayout` is `"controls-flanking"` or `"controls-grouped-right"`, the controls row collapses entirely — only the buttons remain. Recommend switching `buttonLayout` to `"sides"` or `"overlay"` in that case.
53
+
54
+ ### Step 4 — Edge peek (overflow)
55
+
56
+ Ask: **"Should adjacent carousel items peek in from the sides?"**
57
+
58
+ - **No peek** (default): `:allow-carousel-overflow="false"` — clean, contained look.
59
+ - **Peek visible**: `:allow-carousel-overflow="true"` — items bleed slightly out of the container. Requires CSS custom properties to control how much:
60
+
61
+ ```css
62
+ .my-carousel.carousel-flip {
63
+ --_carousel-item-track-gap: 16px;
64
+ --_carousel-item-edge-preview-width: 32px; /* keep at 2× track-gap */
65
+ --_carousel-container-max-inline-size: 900px;
66
+ }
67
+ ```
68
+
69
+ ### Step 5 — Animation style
70
+
71
+ Ask: **"What animation feel are you going for?"**
72
+
73
+ | Prop | Effect |
74
+ |---|---|
75
+ | `:use-flip-animation="false"` (default) | Slides — items translate horizontally |
76
+ | `:use-flip-animation="true"` | FLIP reorder — items swap position with a physics-aware delta animation |
77
+ | `:use-spring-effect="true"` | Adds spring easing to FLIP transitions. Requires `var(--spring-easing)` in the theme |
78
+ | `:transition-speed="400"` | Duration in ms. Default `200`. Recommended range: `200`–`1200` |
79
+
80
+ ### Step 6 — Minimal working implementation
81
+
82
+ Once the above decisions are made, assemble the component:
83
+
84
+ ```vue
85
+ <CarouselFlip
86
+ :carousel-data-ids="carouselDataIds"
87
+ :allow-carousel-overflow="true"
88
+ :transition-speed="600"
89
+ :use-flip-animation="true"
90
+ :use-spring-effect="false"
91
+ button-layout="sides"
92
+ :show-controls="true"
93
+ :style-class-passthrough="['my-carousel']"
94
+ >
95
+ <template v-for="item in items" :key="item.id" #[item.id]>
96
+ <div class="my-carousel__item">
97
+ <!-- item content -->
98
+ </div>
99
+ </template>
100
+ </CarouselFlip>
101
+ ```
102
+
103
+ ### Step 7 — Style the component
104
+
105
+ Always scope overrides using `styleClassPassthrough` + a page/section wrapper class. Required tokens for a usable carousel:
106
+
107
+ ```css
108
+ .my-carousel.carousel-flip {
109
+ --_carousel-item-track-gap: 16px;
110
+ --_carousel-container-max-inline-size: 900px;
111
+ --_carousel-item-edge-preview-width: 32px; /* 2× track-gap when overflow is on */
112
+
113
+ /* Items */
114
+ .item {
115
+ border-radius: 1.2rem;
116
+ overflow: hidden;
117
+ }
118
+
119
+ /* Markers */
120
+ .btn-marker {
121
+ width: 10px;
122
+ height: 10px;
123
+ border-radius: 100vw;
124
+ background: oklch(70% 0 0);
125
+
126
+ &.active { background: white; }
127
+ }
128
+
129
+ /* Prev/next buttons */
130
+ .btn-action {
131
+ padding: 10px;
132
+ background: oklch(0% 0 0 / 0.4);
133
+ border: none;
134
+ border-radius: 100vw;
135
+ color: white;
136
+ }
137
+ }
138
+ ```
139
+
140
+ ---
141
+
142
+ ## Props reference
143
+
144
+ | Prop | Type | Default | Description |
145
+ |---|---|---|---|
146
+ | `carouselDataIds` | `string[]` | `[]` | Ordered list of unique IDs — each becomes a named slot |
147
+ | `transitionSpeed` | `number` | `200` | Animation duration in ms |
148
+ | `allowCarouselOverflow` | `boolean` | `false` | Allows peeking items outside the container bounds |
149
+ | `useFlipAnimation` | `boolean` | `false` | Enables FLIP reorder animation on prev/next |
150
+ | `useSpringEffect` | `boolean` | `false` | Uses spring easing (`var(--spring-easing)`) instead of `ease` |
151
+ | `buttonLayout` | `"sides" \| "controls-flanking" \| "controls-grouped-right" \| "overlay"` | `"sides"` | Controls placement of prev/next buttons relative to the carousel frame and controls bar |
152
+ | `showControls` | `boolean` | `true` | Show or hide the markers/controls bar. When `false` the element is removed from the DOM; `controlsContainerRef` becomes null and its keyboard listener detaches automatically |
153
+ | `styleClassPassthrough` | `string \| string[]` | `[]` | Classes applied to the root element |
154
+
155
+ ## CSS custom properties
156
+
157
+ | Property | Purpose |
158
+ |---|---|
159
+ | `--_carousel-item-track-gap` | Gap between carousel items (default `10px`) |
160
+ | `--_carousel-container-max-inline-size` | Max width of the visible carousel window |
161
+ | `--_carousel-item-edge-preview-width` | How much of adjacent items to reveal (edge peek). Keep at `2× --_carousel-item-track-gap` |
162
+ | `--_carousel-display-max-width` | Max width of the whole component inc. controls |
163
+
164
+ ## HTML structure
165
+
166
+ ```text
167
+ section.carousel-flip ← grid root, data-button-layout="..."
168
+ div.item-container ← grid-area: carousel — flex row of items
169
+ div.item[data-id] ← one per carouselDataIds entry
170
+ div.controls-container ← grid-area: controls — markers bar (v-if="showControls")
171
+ div.markers-container
172
+ ul.markers-list
173
+ li.markers-item
174
+ button.btn-marker
175
+ div.buttons-container ← display: contents by default (transparent to grid)
176
+ button.btn-action.btn-prev ← grid-area: prev (row 1, col 1)
177
+ button.btn-action.btn-next ← grid-area: next (row 1, col 3)
178
+ ```
179
+
180
+ `buttons-container` uses `display: contents` so `.btn-prev`/`.btn-next` participate directly in the parent grid. In the `controls-grouped-right` variant the component sets `display: flex` on it, making it the grid child instead.
181
+
182
+ ## Notes
183
+
184
+ - **Opacity fade-in**: The root starts at `opacity: 0` and gets `.mounted` (opacity 1) after `initialSetup()` completes. This prevents a flash of unstyled layout on mount.
185
+ - **z-index**: `.btn-prev`/`.btn-next` have `z-index: 1` to sit above `.item-container` which uses `isolation: isolate` (carousel items are translated and can overlap the button columns).
186
+ - **ResizeObserver**: `initialSetup()` re-runs on resize to recalculate item widths. CSS `translate` on `.item` is driven by the measured `itemWidth` via `v-bind`.
187
+ - **Spring easing**: `useSpringEffect` switches to `var(--spring-easing)`. Make sure this custom property is defined in your theme or global CSS when enabling it.
188
+ - **buttonLayout + showControls combo**: `"controls-flanking"` and `"controls-grouped-right"` place buttons in the controls row. If `showControls` is false that row collapses — use `"sides"` or `"overlay"` instead.
@@ -41,6 +41,7 @@ Each skill is a single markdown file named `<area>-<task>.md`.
41
41
  ├── icon-sets.md — icon set packages required by layer components, FOUC prevention, component→package map
42
42
  ├── robots-env-aware.md — @nuxtjs/robots: allow crawling on prod domain only, block on preview/staging via env var
43
43
  ├── new-app-scaffold.md — scaffold a new Nuxt consumer app extending this layer (package.json, nuxt.config, app structure, CLAUDE.md)
44
+ ├── qa-panel.md — collapsible dev-only panel for toggling component props live on a page (demo pages and consuming apps)
44
45
  ├── release-notes.md — produce release notes as a fenced markdown block from git log
45
46
  ├── composable-canonical-url.md — useCanonicalUrl: set <link rel="canonical"> from runtimeConfig.public.canonicalHost; layout setup, node types
46
47
  ├── composable-whatsapp.md — useWhatsApp: open pre-filled wa.me link from form payload; runtime config, security, usage
@@ -75,7 +76,8 @@ Each skill is a single markdown file named `<area>-<task>.md`.
75
76
  ├── display-qr-code.md — DisplayQrCode: QR code SVG from a string value, colour/size/variant/radius props, currentColor default
76
77
  ├── capture-qr-code.md — CaptureQrCode: live camera scanner, error state, visibility/route/KeepAlive lifecycle, media stream cleanup
77
78
  ├── decode-qr-code.md — DecodeQrCode: file picker + drag-and-drop image decoder, shared results list, CSS override points
78
- └── data-grid.md — DataGrid: auto-fit responsive grid, $slots iteration, --data-grid-columns/gap tokens, semantic tag + aria
79
+ ├── data-grid.md — DataGrid: auto-fit responsive grid, $slots iteration, --data-grid-columns/gap tokens, semantic tag + aria
80
+ └── carousel-flip.md — CarouselFlip: FLIP-animated carousel, carouselDataIds slot API, buttonLayout variants (sides/controls-flanking/controls-grouped-right/overlay), CSS tokens
79
81
  ```
80
82
 
81
83
  ## Skill file template
@@ -0,0 +1,231 @@
1
+ # QA Panel
2
+
3
+ ## Overview
4
+
5
+ A collapsible dev-only panel that lets you toggle component props live on a page — without touching the component or breaking the visual layout. Hidden in production via `import.meta.dev`. Uses a native `<details>`/`<summary>` so it takes up no space when collapsed. Useful on both demo pages in this library and on pages in consuming apps.
6
+
7
+ ## Structure
8
+
9
+ ```
10
+ <details> ← collapses the whole panel
11
+ <summary> ← always visible: title + live status line
12
+ <body> ← groups of chip buttons (and optional free-text inputs)
13
+ ```
14
+
15
+ Each group controls one prop. The status `<code>` in the summary mirrors the current state so you can see it at a glance without opening the panel.
16
+
17
+ ## Steps
18
+
19
+ ### 1. Add the reactive state to `<script setup>`
20
+
21
+ ```ts
22
+ // ── QA controls (dev only) ────────────────────────────────────────
23
+ const isDev = import.meta.dev;
24
+
25
+ // One ref per controllable prop
26
+ const qaMyBoolean = ref(true);
27
+ const qaMyNumber = ref(400);
28
+ const qaMyString = ref<"a" | "b" | "c">("a");
29
+
30
+ // Preset arrays for numeric/string chip groups
31
+ const myNumberPresets = [100, 200, 400, 800, 1600];
32
+ const myStringPresets = ["a", "b", "c"] as const;
33
+ ```
34
+
35
+ ### 2. Wire the refs to the component
36
+
37
+ ```vue
38
+ <MyComponent
39
+ :my-boolean="qaMyBoolean"
40
+ :my-number="qaMyNumber"
41
+ :my-string="qaMyString"
42
+ />
43
+ ```
44
+
45
+ ### 3. Add the panel markup
46
+
47
+ Place directly above (or below) the component being QA'd, outside any layout wrapper that clips content:
48
+
49
+ ```vue
50
+ <!-- ── QA Panel (dev only) ───────────────────────────────── -->
51
+ <div v-if="isDev" class="qa-panel">
52
+ <details class="qa-panel__details">
53
+ <summary class="qa-panel__summary">
54
+ <span class="qa-panel__title">QA — MyComponent</span>
55
+ <code class="qa-panel__status">
56
+ bool:{{ qaMyBoolean ? "on" : "off" }} · {{ qaMyNumber }}ms · {{ qaMyString }}
57
+ </code>
58
+ </summary>
59
+ <div class="qa-panel__body">
60
+
61
+ <!-- Boolean group -->
62
+ <div class="qa-panel__group">
63
+ <span class="qa-panel__label">My Boolean</span>
64
+ <div class="qa-panel__chips">
65
+ <button
66
+ v-for="opt in [true, false]"
67
+ :key="String(opt)"
68
+ class="qa-panel__chip"
69
+ :class="{ 'is-active': qaMyBoolean === opt }"
70
+ @click="qaMyBoolean = opt"
71
+ >{{ opt ? "on" : "off" }}</button>
72
+ </div>
73
+ </div>
74
+
75
+ <!-- Number group (preset chips) -->
76
+ <div class="qa-panel__group">
77
+ <span class="qa-panel__label">My Number</span>
78
+ <div class="qa-panel__chips">
79
+ <button
80
+ v-for="preset in myNumberPresets"
81
+ :key="preset"
82
+ class="qa-panel__chip"
83
+ :class="{ 'is-active': qaMyNumber === preset }"
84
+ @click="qaMyNumber = preset"
85
+ >{{ preset }}</button>
86
+ </div>
87
+ </div>
88
+
89
+ <!-- String union group -->
90
+ <div class="qa-panel__group">
91
+ <span class="qa-panel__label">My String</span>
92
+ <div class="qa-panel__chips">
93
+ <button
94
+ v-for="opt in myStringPresets"
95
+ :key="opt"
96
+ class="qa-panel__chip"
97
+ :class="{ 'is-active': qaMyString === opt }"
98
+ @click="qaMyString = opt"
99
+ >{{ opt }}</button>
100
+ </div>
101
+ </div>
102
+
103
+ <!-- Free-text input (for strings where presets aren't enough) -->
104
+ <div class="qa-panel__group">
105
+ <span class="qa-panel__label">Custom Value</span>
106
+ <input v-model="qaMyString" placeholder="e.g. 4/3" class="qa-panel__input" />
107
+ </div>
108
+
109
+ </div>
110
+ </details>
111
+ </div>
112
+ ```
113
+
114
+ ### 4. Add the CSS
115
+
116
+ Scope inside your page body class (e.g. `.my-page`) so styles don't bleed. The panel is always dark regardless of colour scheme — it's a dev tool, not a UI element.
117
+
118
+ ```css
119
+ .my-page {
120
+ /* ── QA Panel ──────────────────────────────────────────────────── */
121
+
122
+ .qa-panel {
123
+ background: oklch(15% 0 0);
124
+ color: white;
125
+ font-size: 1.3rem;
126
+ }
127
+
128
+ .qa-panel__details {
129
+ padding: 1rem 2rem;
130
+ }
131
+
132
+ .qa-panel__summary {
133
+ cursor: pointer;
134
+ display: flex;
135
+ align-items: center;
136
+ gap: 1.6rem;
137
+ list-style: none;
138
+ user-select: none;
139
+
140
+ &::-webkit-details-marker { display: none; }
141
+ }
142
+
143
+ .qa-panel__title {
144
+ font-weight: 600;
145
+ font-size: 1.1rem;
146
+ text-transform: uppercase;
147
+ letter-spacing: 0.08em;
148
+ }
149
+
150
+ .qa-panel__status {
151
+ font-family: monospace;
152
+ font-size: 1.2rem;
153
+ background: oklch(0% 0 0 / 0.3);
154
+ padding: 0.2rem 0.8rem;
155
+ border-radius: 0.4rem;
156
+ user-select: text;
157
+ cursor: text;
158
+ }
159
+
160
+ .qa-panel__body {
161
+ display: flex;
162
+ flex-wrap: wrap;
163
+ gap: 2.4rem;
164
+ padding-block: 1.2rem 0.4rem;
165
+ }
166
+
167
+ .qa-panel__group {
168
+ display: flex;
169
+ flex-direction: column;
170
+ gap: 0.6rem;
171
+ }
172
+
173
+ .qa-panel__label {
174
+ font-size: 1.1rem;
175
+ text-transform: uppercase;
176
+ letter-spacing: 0.08em;
177
+ opacity: 0.55;
178
+ }
179
+
180
+ .qa-panel__chips {
181
+ display: flex;
182
+ flex-wrap: wrap;
183
+ gap: 0.4rem;
184
+ }
185
+
186
+ .qa-panel__chip {
187
+ font-family: monospace;
188
+ font-size: 1.2rem;
189
+ color: white;
190
+ background: oklch(0% 0 0 / 0.25);
191
+ border: 1px solid oklch(100% 0 0 / 0.18);
192
+ padding: 0.3rem 1rem;
193
+ border-radius: 0.4rem;
194
+ cursor: pointer;
195
+ transition: background 0.15s;
196
+
197
+ &:hover { background: oklch(0% 0 0 / 0.4); }
198
+
199
+ &.is-active {
200
+ background: oklch(55% 0.18 240);
201
+ border-color: oklch(55% 0.18 240);
202
+ }
203
+ }
204
+
205
+ .qa-panel__input {
206
+ font-family: monospace;
207
+ font-size: 1.2rem;
208
+ color: white;
209
+ background: oklch(0% 0 0 / 0.25);
210
+ border: 1px solid oklch(100% 0 0 / 0.18);
211
+ padding: 0.3rem 1rem;
212
+ border-radius: 0.4rem;
213
+ width: 18rem;
214
+
215
+ &::placeholder { opacity: 0.45; }
216
+ }
217
+ }
218
+ ```
219
+
220
+ ## Notes
221
+
222
+ - **Production safety**: `import.meta.dev` is `false` in production builds — the entire `v-if="isDev"` block is tree-shaken. No runtime cost.
223
+ - **Consuming apps**: The active chip color (`oklch(55% 0.18 240)`) is a neutral blue. Replace with a brand accent token if preferred: `background: var(--color-brand-accent)`.
224
+ - **Panel placement**: Outside any `overflow: hidden` or clipping container, otherwise the panel may be clipped or push layout unexpectedly. Placing it as a direct sibling of the component row works well.
225
+ - **Computed CSS vars**: When a prop controls a CSS custom property (e.g. max-height tiers), use a `computed` that returns a style object and bind it with `:style` on the component wrapper:
226
+
227
+ ```ts
228
+ const qaStyleOverrides = computed(() => ({
229
+ "--theme-component-max-height": qaMaxHeight.value || undefined,
230
+ }));
231
+ ```
@@ -1,6 +1,6 @@
1
1
  import type { Meta, StoryFn } from "@nuxtjs/storybook";
2
2
  import GridStackComponent from "../GridStack.vue";
3
- import BannerVideoComponent from "../../banner-video/BannerVideo.vue";
3
+ import BannerVideoComponent from "../../../banner-video/BannerVideo.vue";
4
4
 
5
5
  interface GridStackArgs {
6
6
  tag: "div" | "section" | "article" | "main";
@@ -3,6 +3,7 @@
3
3
  ref="carouselWrapperRef"
4
4
  class="carousel-flip"
5
5
  :class="[elementClasses]"
6
+ :data-button-layout="buttonLayout"
6
7
  role="region"
7
8
  aria-label="Image carousel"
8
9
  >
@@ -10,84 +11,81 @@
10
11
  Item {{ currentActiveIndex + 1 }} of {{ itemCount }}
11
12
  </div>
12
13
 
13
- <LayoutRow tag="div" variant="full-width" :style-class-passthrough="['mbe-20']">
14
+ <div
15
+ ref="carouselContainerRef"
16
+ tabindex="0"
17
+ class="item-container"
18
+ :class="{ 'allow-overflow': allowCarouselOverflow }"
19
+ role="group"
20
+ aria-label="Carousel items"
21
+ >
14
22
  <div
15
- ref="carouselContainerRef"
16
- tabindex="0"
17
- class="item-container"
18
- :class="{ 'allow-overflow': allowCarouselOverflow }"
19
- role="group"
20
- aria-label="Carousel items"
23
+ v-for="(item, index) in carouselDataIds"
24
+ :key="index"
25
+ ref="carouselItems"
26
+ class="item"
27
+ :class="{ loaded: carouselInitComplete && userHasInteracted }"
28
+ :data-id="item"
29
+ :aria-current="currentActiveIndex === index ? 'true' : 'false'"
21
30
  >
22
- <div
23
- v-for="(item, index) in carouselDataIds"
24
- :key="index"
25
- ref="carouselItems"
26
- class="item"
27
- :class="{ loaded: carouselInitComplete && userHasInteracted }"
28
- :data-id="item"
29
- :aria-current="currentActiveIndex === index ? 'true' : 'false'"
30
- >
31
- <slot :name="item"></slot>
32
- </div>
31
+ <slot :name="item"></slot>
33
32
  </div>
34
- </LayoutRow>
35
-
36
- <LayoutRow tag="div" variant="full-width" :style-class-passthrough="['mbe-20']">
37
- <div ref="controlsContainerRef" tabindex="0" class="controls-container">
38
- <div class="markers-container">
39
- <ul class="markers-list">
40
- <li v-for="index in itemCount" :key="index" class="markers-item">
41
- <button
42
- class="btn-marker"
43
- :class="[{ active: displayActiveIndex === index - 1 }]"
44
- :aria-label="`Jump to item ${index}`"
45
- @click.prevent="jumpToFrame(index - 1)"
46
- ></button>
47
- </li>
48
- </ul>
49
- </div>
50
- <div class="buttons-container">
51
- <button type="button" class="btn-action" aria-label="Go to previous item" @click.prevent="actionPrevious()">
52
- <Icon name="ic:outline-keyboard-arrow-left" class="arrows-icon" />
53
- </button>
54
- <button type="button" class="btn-action" aria-label="Go to next item" @click.prevent="actionNext()">
55
- <Icon name="ic:outline-keyboard-arrow-right" class="arrows-icon" />
56
- </button>
57
- </div>
33
+ </div>
34
+
35
+ <div v-if="showControls" ref="controlsContainerRef" tabindex="0" class="controls-container">
36
+ <div class="markers-container">
37
+ <ul class="markers-list">
38
+ <li v-for="index in itemCount" :key="index" class="markers-item">
39
+ <button
40
+ class="btn-marker"
41
+ :class="[{ active: displayActiveIndex === index - 1 }]"
42
+ :aria-label="`Jump to item ${index}`"
43
+ @click.prevent="jumpToFrame(index - 1)"
44
+ ></button>
45
+ </li>
46
+ </ul>
58
47
  </div>
59
- </LayoutRow>
48
+ </div>
49
+
50
+ <div class="buttons-container">
51
+ <button
52
+ type="button"
53
+ class="btn-action btn-prev"
54
+ aria-label="Go to previous item"
55
+ @click.prevent="actionPrevious()"
56
+ >
57
+ <Icon name="ic:outline-keyboard-arrow-left" class="arrows-icon" />
58
+ </button>
59
+ <button type="button" class="btn-action btn-next" aria-label="Go to next item" @click.prevent="actionNext()">
60
+ <Icon name="ic:outline-keyboard-arrow-right" class="arrows-icon" />
61
+ </button>
62
+ </div>
60
63
  </section>
61
64
  </template>
62
65
 
63
66
  <script setup lang="ts">
64
67
  import { useEventListener, useResizeObserver, useSwipe } from "@vueuse/core";
65
68
 
66
- const props = defineProps({
67
- carouselDataIds: {
68
- type: Array as PropType<string[]>,
69
- default: () => [],
70
- },
71
- styleClassPassthrough: {
72
- type: [String, Array] as PropType<string | string[]>,
73
- default: () => [],
74
- },
75
- transitionSpeed: {
76
- type: Number,
77
- default: 200,
78
- },
79
- allowCarouselOverflow: {
80
- type: Boolean,
81
- default: false,
82
- },
83
- useFlipAnimation: {
84
- type: Boolean,
85
- default: false,
86
- },
87
- useSpringEffect: {
88
- type: Boolean,
89
- default: false,
90
- },
69
+ interface Props {
70
+ carouselDataIds?: string[];
71
+ styleClassPassthrough?: string | string[];
72
+ transitionSpeed?: number;
73
+ allowCarouselOverflow?: boolean;
74
+ useFlipAnimation?: boolean;
75
+ useSpringEffect?: boolean;
76
+ buttonLayout?: "sides" | "controls-flanking" | "controls-grouped-right" | "overlay";
77
+ showControls?: boolean;
78
+ }
79
+
80
+ const props = withDefaults(defineProps<Props>(), {
81
+ carouselDataIds: () => [],
82
+ styleClassPassthrough: () => [],
83
+ transitionSpeed: 200,
84
+ allowCarouselOverflow: false,
85
+ useFlipAnimation: false,
86
+ useSpringEffect: false,
87
+ buttonLayout: "sides",
88
+ showControls: true,
91
89
  });
92
90
 
93
91
  const { elementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
@@ -417,93 +415,100 @@ onMounted(() => {
417
415
 
418
416
  <style lang="css">
419
417
  @layer components {
420
- .carousel-flip {
421
- --_carousel-item-track-gap: 10px;
422
-
423
- display: grid;
424
- grid-template-columns: 1fr;
425
- gap: 10px;
426
- opacity: 0;
427
-
428
- &.mounted {
429
- opacity: 1;
430
- }
431
-
432
- .sr-only {
433
- position: absolute;
434
- width: 1px;
435
- height: 1px;
436
- padding: 0;
437
- margin: -1px;
438
- overflow: hidden;
439
- clip: rect(0, 0, 0, 0);
440
- white-space: nowrap;
441
- border: 0;
442
- }
443
-
444
- .item-container {
445
- display: flex;
446
- gap: var(--_carousel-item-track-gap);
447
- overflow-x: hidden;
448
- position: relative;
449
- isolation: isolate;
450
-
451
- max-inline-size: var(--_carousel-display-max-width);
452
- margin-inline: auto;
418
+ .carousel-flip {
419
+ --_carousel-item-track-gap: 10px;
420
+
421
+ display: grid;
422
+ grid-template-columns: auto 1fr auto;
423
+ grid-template-rows: 1fr auto;
424
+ grid-template-areas:
425
+ "prev carousel next"
426
+ ". controls . ";
427
+ gap: 10px;
428
+ opacity: 0;
429
+
430
+ &.mounted {
431
+ opacity: 1;
432
+ }
453
433
 
454
- &.allow-overflow {
455
- overflow-x: initial;
434
+ .sr-only {
435
+ position: absolute;
436
+ width: 1px;
437
+ height: 1px;
438
+ padding: 0;
439
+ margin: -1px;
440
+ overflow: hidden;
441
+ clip: rect(0, 0, 0, 0);
442
+ white-space: nowrap;
443
+ border: 0;
456
444
  }
457
445
 
458
- .item {
446
+ .item-container {
447
+ grid-area: carousel;
459
448
  display: flex;
460
- flex: 0 0 100%;
449
+ gap: var(--_carousel-item-track-gap);
450
+ overflow-x: hidden;
461
451
  position: relative;
452
+ isolation: isolate;
462
453
 
454
+ max-inline-size: var(--_carousel-display-max-width);
463
455
  margin-inline: auto;
464
456
 
465
- max-inline-size: calc(
466
- var(--_carousel-container-max-inline-size) + var(--_carousel-item-track-gap) -
467
- (2 * var(--_carousel-item-edge-preview-width))
468
- );
457
+ &.allow-overflow {
458
+ overflow-x: initial;
459
+ }
460
+
461
+ .item {
462
+ display: flex;
463
+ flex: 0 0 100%;
464
+ position: relative;
469
465
 
470
- translate: calc(
471
- v-bind(itemWidthOffsetStr) - var(--_carousel-item-track-gap) + var(--_carousel-item-edge-preview-width)
472
- )
473
- 0;
466
+ margin-inline: auto;
474
467
 
475
- &.loaded {
476
- transition: transform v-bind(transitionSpeedStr) ease;
468
+ max-inline-size: calc(
469
+ var(--_carousel-container-max-inline-size) + var(--_carousel-item-track-gap) -
470
+ (2 * var(--_carousel-item-edge-preview-width))
471
+ );
472
+
473
+ translate: calc(
474
+ v-bind(itemWidthOffsetStr) - var(--_carousel-item-track-gap) + var(--_carousel-item-edge-preview-width)
475
+ )
476
+ 0;
477
+
478
+ &.loaded {
479
+ transition: transform v-bind(transitionSpeedStr) ease;
480
+ }
477
481
  }
478
482
  }
479
- }
480
483
 
481
- .controls-container {
482
- display: flex;
483
- align-items: center;
484
- justify-content: flex-end;
485
- max-inline-size: var(--_carousel-display-max-width);
486
- margin-inline: auto;
484
+ .controls-container {
485
+ grid-area: controls;
486
+ display: flex;
487
+ align-items: center;
488
+ justify-content: center;
489
+ max-inline-size: var(--_carousel-display-max-width);
490
+ margin-inline: auto;
487
491
 
488
- .markers-container {
489
- .markers-list {
490
- display: flex;
491
- flex-direction: row;
492
- gap: 10px;
493
- list-style-type: none;
494
- margin: unset;
495
- padding: unset;
496
-
497
- .markers-item {
498
- .btn-marker {
499
- border: 1px solid transparent;
500
- outline: 1px solid transparent;
501
- box-shadow: none;
502
- cursor: pointer;
503
- transition: background-color v-bind(transitionSpeedStr) linear;
504
-
505
- &.active {
506
- background-color: light-dark(var(--slate-10), var(--slate-00));
492
+ .markers-container {
493
+ .markers-list {
494
+ display: flex;
495
+ flex-direction: row;
496
+ gap: 10px;
497
+ list-style-type: none;
498
+ margin: unset;
499
+ padding: unset;
500
+
501
+ .markers-item {
502
+ .btn-marker {
503
+ border: 1px solid transparent;
504
+ outline: 1px solid transparent;
505
+ box-shadow: none;
506
+ cursor: pointer;
507
+ transition: background-color v-bind(transitionSpeedStr) linear;
508
+
509
+ &.active {
510
+ background-color: light-dark(var(--slate-10), var(--slate-00));
511
+ }
507
512
  }
508
513
  }
509
514
  }
@@ -511,26 +516,79 @@ onMounted(() => {
511
516
  }
512
517
 
513
518
  .buttons-container {
519
+ display: contents;
520
+ }
521
+
522
+ .btn-prev {
523
+ grid-area: prev;
524
+ align-self: center;
525
+ z-index: 1;
526
+ }
527
+
528
+ .btn-next {
529
+ grid-area: next;
530
+ align-self: center;
531
+ z-index: 1;
532
+ }
533
+
534
+ .btn-action {
514
535
  display: flex;
515
536
  align-items: center;
516
- justify-content: end;
517
- gap: 20px;
537
+ justify-content: center;
538
+ cursor: pointer;
539
+ height: fit-content;
540
+
541
+ background-color: white;
542
+ border: 1px solid light-dark(hsl(0, 29%, 3%), hsl(0, 0%, 92%));
543
+ border-radius: 100vw;
544
+ padding: 8px;
545
+
546
+ .arrows-icon {
547
+ width: 24px;
548
+ height: 24px;
549
+ }
550
+ }
551
+
552
+ /* ── controls-flanking: buttons flank the controls row ─────── */
553
+ &[data-button-layout="controls-flanking"] {
554
+ .btn-prev {
555
+ grid-area: unset;
556
+ grid-column: 1;
557
+ grid-row: 2;
558
+ }
559
+
560
+ .btn-next {
561
+ grid-area: unset;
562
+ grid-column: 3;
563
+ grid-row: 2;
564
+ }
565
+ }
566
+
567
+ /* ── controls-grouped-right: markers centred, buttons end ────── */
568
+ &[data-button-layout="controls-grouped-right"] {
569
+ grid-template-columns: 1fr;
570
+ grid-template-areas:
571
+ "carousel"
572
+ "controls";
518
573
 
519
- .btn-action {
574
+ .buttons-container {
520
575
  display: flex;
521
576
  align-items: center;
522
- justify-content: center;
523
-
524
- cursor: pointer;
525
- height: fit-content;
577
+ gap: 10px;
578
+ grid-area: controls;
579
+ justify-self: end;
580
+ }
581
+ }
526
582
 
527
- .arrows-icon {
528
- width: 24px;
529
- height: 24px;
530
- }
583
+ /* ── overlay: controls float at the bottom edge of the frame ── */
584
+ &[data-button-layout="overlay"] {
585
+ .controls-container {
586
+ grid-area: carousel;
587
+ align-self: end;
588
+ z-index: 1;
589
+ padding-block-end: 1rem;
531
590
  }
532
591
  }
533
592
  }
534
593
  }
535
- }
536
594
  </style>
@@ -16,6 +16,8 @@ interface CarouselFlipStoryArgs {
16
16
  transitionSpeed: number;
17
17
  useFlipAnimation: boolean;
18
18
  useSpringEffect: boolean;
19
+ buttonLayout: "sides" | "controls-flanking" | "controls-grouped-right" | "overlay";
20
+ showControls: boolean;
19
21
  styleClassPassthrough: string[];
20
22
  // Mock data for stories
21
23
  carouselData: CarouselItem[];
@@ -105,6 +107,21 @@ export default {
105
107
  category: "Animation",
106
108
  },
107
109
  },
110
+ buttonLayout: {
111
+ control: { type: "select" },
112
+ options: ["sides", "controls-flanking", "controls-grouped-right", "overlay"],
113
+ description: "Placement of prev/next buttons relative to the carousel and controls bar",
114
+ table: {
115
+ category: "Layout",
116
+ },
117
+ },
118
+ showControls: {
119
+ control: { type: "boolean" },
120
+ description: "Show or hide the markers/controls bar",
121
+ table: {
122
+ category: "Layout",
123
+ },
124
+ },
108
125
  styleClassPassthrough: {
109
126
  control: { type: "object" },
110
127
  description: "Additional CSS classes to pass through",
@@ -127,6 +144,8 @@ export default {
127
144
  transitionSpeed: 1000,
128
145
  useFlipAnimation: true,
129
146
  useSpringEffect: false,
147
+ buttonLayout: "sides",
148
+ showControls: true,
130
149
  styleClassPassthrough: ["carousel-flip-demo"],
131
150
  carouselData: sampleCarouselItems,
132
151
  },
@@ -289,6 +308,8 @@ const Template: StoryFn<CarouselFlipStoryArgs> = (args) => ({
289
308
  :transition-speed="args.transitionSpeed"
290
309
  :use-flip-animation="args.useFlipAnimation"
291
310
  :use-spring-effect="args.useSpringEffect"
311
+ :button-layout="args.buttonLayout"
312
+ :show-controls="args.showControls"
292
313
  :style-class-passthrough="args.styleClassPassthrough"
293
314
  >
294
315
  <template v-for="(item, index) in filteredCarouselData" #[item.id] :key="index">
@@ -350,6 +371,16 @@ NoOverflow.args = {
350
371
  styleClassPassthrough: ["carousel-flip-demo", "no-overflow"],
351
372
  };
352
373
 
374
+ export const NoControls = Template.bind({});
375
+ NoControls.args = {
376
+ carouselDataIds: [1, 2, 3, 4, 5],
377
+ transitionSpeed: 1000,
378
+ useFlipAnimation: true,
379
+ useSpringEffect: false,
380
+ allowCarouselOverflow: true,
381
+ showControls: false,
382
+ };
383
+
353
384
  export const ManyItems = Template.bind({});
354
385
  ManyItems.args = {
355
386
  carouselDataIds: [1, 2, 3, 4, 5, 6, 7, 8],
@@ -452,6 +483,8 @@ const RichContentTemplate: StoryFn<CarouselFlipStoryArgs> = (args) => ({
452
483
  :transition-speed="args.transitionSpeed"
453
484
  :use-flip-animation="args.useFlipAnimation"
454
485
  :use-spring-effect="args.useSpringEffect"
486
+ :button-layout="args.buttonLayout"
487
+ :show-controls="args.showControls"
455
488
  :style-class-passthrough="args.styleClassPassthrough"
456
489
  >
457
490
  <template v-for="(item, index) in filteredCarouselData" #[item.id] :key="index">
@@ -592,6 +625,8 @@ const AnimationShowcaseTemplate: StoryFn<CarouselFlipStoryArgs> = (args) => ({
592
625
  :transition-speed="args.transitionSpeed"
593
626
  :use-flip-animation="args.useFlipAnimation"
594
627
  :use-spring-effect="args.useSpringEffect"
628
+ :button-layout="args.buttonLayout"
629
+ :show-controls="args.showControls"
595
630
  :style-class-passthrough="args.styleClassPassthrough"
596
631
  >
597
632
  <template v-for="(item, index) in filteredCarouselData" #[item.id] :key="index">
@@ -232,6 +232,44 @@ describe("CarouselFlip", () => {
232
232
  expect(component).toBeDefined();
233
233
  // Spring effect is applied during transitions
234
234
  });
235
+
236
+ it("shows controls container by default", async () => {
237
+ await createWrapper();
238
+
239
+ expect(wrapper.find(".controls-container").exists()).toBe(true);
240
+ expect(wrapper.findAll(".btn-marker").length).toBe(6);
241
+ });
242
+
243
+ it("hides controls container when showControls is false", async () => {
244
+ await createWrapper({ showControls: false });
245
+
246
+ expect(wrapper.find(".controls-container").exists()).toBe(false);
247
+ expect(wrapper.findAll(".btn-marker").length).toBe(0);
248
+ });
249
+
250
+ it("still renders navigation buttons when showControls is false", async () => {
251
+ await createWrapper({ showControls: false });
252
+
253
+ expect(wrapper.find('[aria-label="Go to previous item"]').exists()).toBe(true);
254
+ expect(wrapper.find('[aria-label="Go to next item"]').exists()).toBe(true);
255
+ });
256
+
257
+ it("navigation still works when showControls is false", async () => {
258
+ await createWrapper({ showControls: false });
259
+ await nextTick();
260
+
261
+ const nextButton = wrapper.find('[aria-label="Go to next item"]');
262
+ await nextButton.trigger("click");
263
+ await nextTick();
264
+
265
+ expect(component.currentActiveIndex).toBe(1);
266
+ });
267
+
268
+ it("sets data-button-layout attribute from buttonLayout prop", async () => {
269
+ await createWrapper({ buttonLayout: "overlay" });
270
+
271
+ expect(wrapper.attributes("data-button-layout")).toBe("overlay");
272
+ });
235
273
  });
236
274
 
237
275
  describe("Initial Setup", () => {
@@ -6,24 +6,127 @@
6
6
  <h1 class="page-heading-2">Carousel</h1>
7
7
  </LayoutRow>
8
8
 
9
- <LayoutRow tag="div" variant="popout" :style-class-passthrough="['mbe-20']">
10
- <CarouselFlip
11
- v-if="carouselStatus === 'success'"
12
- :carousel-data-ids
13
- :allow-carousel-overflow="true"
14
- :transition-speed="1000"
15
- :use-flip-animation="true"
16
- :use-spring-effect="false"
17
- :style-class-passthrough="['carousel-flip-demo', 'mbe-20']"
18
- >
19
- <template v-for="(item, index) in carouselData?.items" :key="index" #[item.id]>
20
- <div class="custom-carousel-item">
21
- <h3>{{ index + 1 }}</h3>
22
- <p>{{ item.alt }}</p>
9
+ <!-- ── QA Panel (dev only) ───────────────────────────────── -->
10
+ <div v-if="isDev" class="qa-panel">
11
+ <details class="qa-panel__details">
12
+ <summary class="qa-panel__summary">
13
+ <span class="qa-panel__title">QA — CarouselFlip</span>
14
+ <code class="qa-panel__status">
15
+ overflow:{{ qaAllowOverflow ? "on" : "off" }} · speed:{{ qaTransitionSpeed }}ms · flip:{{
16
+ qaUseFlipAnimation ? "on" : "off"
17
+ }}
18
+ · spring:{{ qaUseSpringEffect ? "on" : "off" }} · {{ qaButtonLayout }} · controls:{{
19
+ qaShowControls ? "on" : "off"
20
+ }}
21
+ </code>
22
+ </summary>
23
+ <div class="qa-panel__body">
24
+ <div class="qa-panel__group">
25
+ <span class="qa-panel__label">Allow Overflow</span>
26
+ <div class="qa-panel__chips">
27
+ <button
28
+ v-for="opt in [true, false]"
29
+ :key="String(opt)"
30
+ class="qa-panel__chip"
31
+ :class="{ 'is-active': qaAllowOverflow === opt }"
32
+ @click="qaAllowOverflow = opt"
33
+ >
34
+ {{ opt ? "on" : "off" }}
35
+ </button>
36
+ </div>
23
37
  </div>
24
- </template>
25
- </CarouselFlip>
26
- </LayoutRow>
38
+ <div class="qa-panel__group">
39
+ <span class="qa-panel__label">Transition Speed</span>
40
+ <div class="qa-panel__chips">
41
+ <button
42
+ v-for="preset in transitionSpeedPresets"
43
+ :key="preset"
44
+ class="qa-panel__chip"
45
+ :class="{ 'is-active': qaTransitionSpeed === preset }"
46
+ @click="qaTransitionSpeed = preset"
47
+ >
48
+ {{ preset }}ms
49
+ </button>
50
+ </div>
51
+ </div>
52
+ <div class="qa-panel__group">
53
+ <span class="qa-panel__label">Flip Animation</span>
54
+ <div class="qa-panel__chips">
55
+ <button
56
+ v-for="opt in [true, false]"
57
+ :key="String(opt)"
58
+ class="qa-panel__chip"
59
+ :class="{ 'is-active': qaUseFlipAnimation === opt }"
60
+ @click="qaUseFlipAnimation = opt"
61
+ >
62
+ {{ opt ? "on" : "off" }}
63
+ </button>
64
+ </div>
65
+ </div>
66
+ <div class="qa-panel__group">
67
+ <span class="qa-panel__label">Spring Effect</span>
68
+ <div class="qa-panel__chips">
69
+ <button
70
+ v-for="opt in [true, false]"
71
+ :key="String(opt)"
72
+ class="qa-panel__chip"
73
+ :class="{ 'is-active': qaUseSpringEffect === opt }"
74
+ @click="qaUseSpringEffect = opt"
75
+ >
76
+ {{ opt ? "on" : "off" }}
77
+ </button>
78
+ </div>
79
+ </div>
80
+ <div class="qa-panel__group">
81
+ <span class="qa-panel__label">Button Layout</span>
82
+ <div class="qa-panel__chips">
83
+ <button
84
+ v-for="layout in buttonLayoutPresets"
85
+ :key="layout"
86
+ class="qa-panel__chip"
87
+ :class="{ 'is-active': qaButtonLayout === layout }"
88
+ @click="qaButtonLayout = layout"
89
+ >
90
+ {{ layout }}
91
+ </button>
92
+ </div>
93
+ </div>
94
+ <div class="qa-panel__group">
95
+ <span class="qa-panel__label">Show Controls</span>
96
+ <div class="qa-panel__chips">
97
+ <button
98
+ v-for="opt in [true, false]"
99
+ :key="String(opt)"
100
+ class="qa-panel__chip"
101
+ :class="{ 'is-active': qaShowControls === opt }"
102
+ @click="qaShowControls = opt"
103
+ >
104
+ {{ opt ? "on" : "off" }}
105
+ </button>
106
+ </div>
107
+ </div>
108
+ </div>
109
+ </details>
110
+ </div>
111
+
112
+ <CarouselFlip
113
+ v-if="carouselStatus === 'success'"
114
+ :carousel-data-ids
115
+ :allow-carousel-overflow="qaAllowOverflow"
116
+ :transition-speed="qaTransitionSpeed"
117
+ :use-flip-animation="qaUseFlipAnimation"
118
+ :use-spring-effect="qaUseSpringEffect"
119
+ :button-layout="qaButtonLayout"
120
+ :show-controls="qaShowControls"
121
+ :style-class-passthrough="['carousel-flip-demo', 'mbe-20']"
122
+ >
123
+ <template v-for="(item, index) in carouselData?.items" :key="index" #[item.id]>
124
+ <div class="custom-carousel-item">
125
+ <h3>{{ index + 1 }}</h3>
126
+ <p>{{ item.alt }}</p>
127
+ </div>
128
+ </template>
129
+ </CarouselFlip>
27
130
  </template>
28
131
  </NuxtLayout>
29
132
  </div>
@@ -32,6 +135,17 @@
32
135
  <script setup lang="ts">
33
136
  import type { ICarouselBasic } from "~/types/components";
34
137
 
138
+ // ── QA controls (dev only) ────────────────────────────────────────
139
+ const isDev = import.meta.dev;
140
+ const qaAllowOverflow = ref(true);
141
+ const qaTransitionSpeed = ref(1000);
142
+ const qaUseFlipAnimation = ref(true);
143
+ const qaUseSpringEffect = ref(false);
144
+ const qaButtonLayout = ref<"sides" | "controls-flanking" | "controls-grouped-right" | "overlay">("sides");
145
+ const qaShowControls = ref(true);
146
+ const transitionSpeedPresets = [100, 200, 400, 600, 1000, 2000];
147
+ const buttonLayoutPresets = ["sides", "controls-flanking", "controls-grouped-right", "overlay"] as const;
148
+
35
149
  definePageMeta({
36
150
  layout: false,
37
151
  });
@@ -54,6 +168,97 @@ const carouselDataIds = computed(() => {
54
168
  </script>
55
169
 
56
170
  <style lang="css">
171
+ .carousel-flip-page {
172
+ /* ── QA Panel ──────────────────────────────────────────────────── */
173
+
174
+ .qa-panel {
175
+ background: oklch(15% 0 0);
176
+ color: white;
177
+ font-size: 1.3rem;
178
+ }
179
+
180
+ .qa-panel__details {
181
+ padding: 1rem 2rem;
182
+ }
183
+
184
+ .qa-panel__summary {
185
+ cursor: pointer;
186
+ display: flex;
187
+ align-items: center;
188
+ gap: 1.6rem;
189
+ list-style: none;
190
+ user-select: none;
191
+
192
+ &::-webkit-details-marker {
193
+ display: none;
194
+ }
195
+ }
196
+
197
+ .qa-panel__title {
198
+ font-weight: 600;
199
+ font-size: 1.1rem;
200
+ text-transform: uppercase;
201
+ letter-spacing: 0.08em;
202
+ }
203
+
204
+ .qa-panel__status {
205
+ font-family: monospace;
206
+ font-size: 1.2rem;
207
+ background: oklch(0% 0 0 / 0.3);
208
+ padding: 0.2rem 0.8rem;
209
+ border-radius: 0.4rem;
210
+ user-select: text;
211
+ cursor: text;
212
+ }
213
+
214
+ .qa-panel__body {
215
+ display: flex;
216
+ flex-wrap: wrap;
217
+ gap: 2.4rem;
218
+ padding-block: 1.2rem 0.4rem;
219
+ }
220
+
221
+ .qa-panel__group {
222
+ display: flex;
223
+ flex-direction: column;
224
+ gap: 0.6rem;
225
+ }
226
+
227
+ .qa-panel__label {
228
+ font-size: 1.1rem;
229
+ text-transform: uppercase;
230
+ letter-spacing: 0.08em;
231
+ opacity: 0.55;
232
+ }
233
+
234
+ .qa-panel__chips {
235
+ display: flex;
236
+ flex-wrap: wrap;
237
+ gap: 0.4rem;
238
+ }
239
+
240
+ .qa-panel__chip {
241
+ font-family: monospace;
242
+ font-size: 1.2rem;
243
+ color: white;
244
+ background: oklch(0% 0 0 / 0.25);
245
+ border: 1px solid oklch(100% 0 0 / 0.18);
246
+ padding: 0.3rem 1rem;
247
+ border-radius: 0.4rem;
248
+ cursor: pointer;
249
+ transition: background 0.15s;
250
+
251
+ &:hover {
252
+ background: oklch(0% 0 0 / 0.4);
253
+ }
254
+
255
+ &.is-active {
256
+ background: oklch(55% 0.18 240);
257
+ border-color: oklch(55% 0.18 240);
258
+ }
259
+ }
260
+ }
261
+
57
262
  @property --glow-deg {
58
263
  syntax: "<angle>";
59
264
  inherits: true;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "srcdev-nuxt-components",
3
3
  "type": "module",
4
- "version": "9.1.38",
4
+ "version": "9.1.39",
5
5
  "main": "nuxt.config.ts",
6
6
  "types": "types.d.ts",
7
7
  "license": "MIT",