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.
- package/.claude/settings.json +4 -1
- package/.claude/skills/components/carousel-flip.md +188 -0
- package/.claude/skills/index.md +3 -1
- package/.claude/skills/qa-panel.md +231 -0
- package/app/components/01.atoms/{grid-stack → grids/grid-stack}/stories/GridStack.stories.ts +1 -1
- package/app/components/carousels/CarouselFlip.vue +211 -153
- package/app/components/carousels/stories/CarouselFlip.stories.ts +35 -0
- package/app/components/carousels/tests/CarouselFlip.spec.ts +38 -0
- package/app/pages/ui/carousel-flip.vue +222 -17
- package/package.json +1 -1
- /package/app/components/01.atoms/{scroll-reveal-frame → animations/scroll-reveal-frame}/ScrollRevealFrame.vue +0 -0
- /package/app/components/01.atoms/{scroll-reveal-frame → animations/scroll-reveal-frame}/stories/ScrollRevealFrame.stories.ts +0 -0
- /package/app/components/01.atoms/{scroll-reveal-frame → animations/scroll-reveal-frame}/tests/ScrollRevealFrame.spec.ts +0 -0
- /package/app/components/01.atoms/{scroll-reveal-frame → animations/scroll-reveal-frame}/tests/__snapshots__/ScrollRevealFrame.spec.ts.snap +0 -0
- /package/app/components/01.atoms/{scroll-reveal-image → animations/scroll-reveal-image}/ScrollRevealImage.vue +0 -0
- /package/app/components/01.atoms/{scroll-reveal-image → animations/scroll-reveal-image}/stories/ScrollRevealImage.stories.ts +0 -0
- /package/app/components/01.atoms/{scroll-reveal-image → animations/scroll-reveal-image}/tests/ScrollRevealImage.spec.ts +0 -0
- /package/app/components/01.atoms/{scroll-reveal-image → animations/scroll-reveal-image}/tests/__snapshots__/ScrollRevealImage.spec.ts.snap +0 -0
- /package/app/components/01.atoms/{grid-stack → grids/grid-stack}/GridStack.vue +0 -0
- /package/app/components/01.atoms/{grid-stack → grids/grid-stack}/tests/GridStack.spec.ts +0 -0
package/.claude/settings.json
CHANGED
|
@@ -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.
|
package/.claude/skills/index.md
CHANGED
|
@@ -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
|
-
|
|
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
|
+
```
|
package/app/components/01.atoms/{grid-stack → grids/grid-stack}/stories/GridStack.stories.ts
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Meta, StoryFn } from "@nuxtjs/storybook";
|
|
2
2
|
import GridStackComponent from "../GridStack.vue";
|
|
3
|
-
import BannerVideoComponent from "
|
|
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
|
-
<
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
<
|
|
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
|
-
</
|
|
35
|
-
|
|
36
|
-
<
|
|
37
|
-
<div
|
|
38
|
-
<
|
|
39
|
-
<
|
|
40
|
-
<
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
</
|
|
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
|
-
|
|
67
|
-
carouselDataIds
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
-
|
|
455
|
-
|
|
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
|
-
|
|
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
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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
|
-
|
|
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
|
-
|
|
476
|
-
|
|
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
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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:
|
|
517
|
-
|
|
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
|
-
.
|
|
574
|
+
.buttons-container {
|
|
520
575
|
display: flex;
|
|
521
576
|
align-items: center;
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
577
|
+
gap: 10px;
|
|
578
|
+
grid-area: controls;
|
|
579
|
+
justify-self: end;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
526
582
|
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|