srcdev-nuxt-components 9.1.38 → 9.1.40
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/settings.local.json +6 -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/.claude/skills/testing-add-unit-test.md +32 -0
- package/app/components/01.atoms/{grid-stack → grids/grid-stack}/stories/GridStack.stories.ts +1 -1
- package/app/components/02.molecules/samaritan-prompt/SamaritanPrompt.vue +230 -0
- package/app/components/02.molecules/samaritan-prompt/SamaritanPromptMixed.vue +234 -0
- package/app/components/02.molecules/samaritan-prompt/tests/SamaritanPrompt.spec.ts +368 -0
- package/app/components/02.molecules/samaritan-prompt/tests/SamaritanPromptMixed.spec.ts +306 -0
- 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/composables/tests/useCancellableTimer.spec.ts +135 -0
- package/app/composables/useCancellableTimer.ts +42 -0
- package/app/pages/samaritan.vue +332 -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
|
}
|
|
@@ -24,7 +24,12 @@
|
|
|
24
24
|
"Bash(git -C /Users/simoncornforth/websites/nuxt-components show 0f99d7a --stat)",
|
|
25
25
|
"Bash(git -C /Users/simoncornforth/websites/nuxt-components diff app/components/parallax/SectionParallax.vue)",
|
|
26
26
|
"Bash(git -C /Users/simoncornforth/websites/nuxt-components diff HEAD app/components/parallax/SectionParallax.vue)",
|
|
27
|
-
"Bash(git -C /Users/simoncornforth/websites/nuxt-components diff --cached)"
|
|
27
|
+
"Bash(git -C /Users/simoncornforth/websites/nuxt-components diff --cached)",
|
|
28
|
+
"Bash(SRCDEV_STANDALONE=true npx vitest --run app/components/02.molecules/samaritan-prompt)",
|
|
29
|
+
"Bash(SRCDEV_STANDALONE=true npx vitest --run app/composables/tests/useCancellableTimer app/components/02.molecules/samaritan-prompt/tests/SamaritanPromptMixed)",
|
|
30
|
+
"Bash(SRCDEV_STANDALONE=true npx vitest --run app/components/02.molecules/samaritan-prompt/tests/SamaritanPromptMixed)",
|
|
31
|
+
"Bash(SRCDEV_STANDALONE=true npx vitest --run app/composables/tests/useCancellableTimer)",
|
|
32
|
+
"Bash(SRCDEV_STANDALONE=true npm run test:run -- --reporter=verbose)"
|
|
28
33
|
],
|
|
29
34
|
"additionalDirectories": [
|
|
30
35
|
"/Users/simoncornforth/websites/nuxt-components/app/components/01.atoms/content-wrappers/content-width",
|
|
@@ -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
|
+
```
|
|
@@ -182,6 +182,38 @@ await nextTick();
|
|
|
182
182
|
vi.runAllTimers();
|
|
183
183
|
```
|
|
184
184
|
|
|
185
|
+
## `mountSuspended` flushes component async setup — initial state may be past the first `await`
|
|
186
|
+
|
|
187
|
+
`mountSuspended` calls `flushPromises()` internally, draining all pending microtasks including `await nextTick()` calls inside the component's async functions. If a component's async lifecycle starts with `await nextTick()` (e.g. to let a CSS `v-bind` update before beginning a transition), **the component will have already executed past it by the time `mountSuspended` returns**.
|
|
188
|
+
|
|
189
|
+
Assert the post-flush state, not the state before the first await:
|
|
190
|
+
|
|
191
|
+
```ts
|
|
192
|
+
// Component's runEffect() starts with: await nextTick(); opacity.value = 0;
|
|
193
|
+
// ❌ — by the time mountSuspended returns, opacity is already 0
|
|
194
|
+
const wrapper = await mountSuspended(MyComponent, { props });
|
|
195
|
+
expect(wrapper.find(".content").attributes("style")).toContain("opacity: 1");
|
|
196
|
+
|
|
197
|
+
// ✅ — assert the state that exists after mountSuspended's flush
|
|
198
|
+
const wrapper = await mountSuspended(MyComponent, { props });
|
|
199
|
+
expect(wrapper.find(".content").attributes("style")).toContain("opacity: 0");
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
## `.then().catch()` requires two microtask hops; use `.then(onFulfilled, onRejected)` for one
|
|
203
|
+
|
|
204
|
+
When asserting Promise settlement with a single `await Promise.resolve()`, put both handlers in the two-argument form of `.then()`. Chaining `.then().catch()` creates an intermediate Promise that adds a second hop, so `settled` won't be `true` after only one drain:
|
|
205
|
+
|
|
206
|
+
```ts
|
|
207
|
+
// ❌ needs two await Promise.resolve() to settle
|
|
208
|
+
promise.then(() => { settled = true; }).catch(() => { settled = true; });
|
|
209
|
+
|
|
210
|
+
// ✅ settles after one await Promise.resolve()
|
|
211
|
+
promise.then(
|
|
212
|
+
() => { settled = true; },
|
|
213
|
+
() => { settled = true; },
|
|
214
|
+
);
|
|
215
|
+
```
|
|
216
|
+
|
|
185
217
|
## Hyphenated prop attributes in tests
|
|
186
218
|
|
|
187
219
|
When a component uses a hyphenated Vue prop like `:tab-index` or `:aria-label`, Vue renders it as the literal hyphenated DOM attribute. Assert with the hyphenated form — not the camelCase equivalent:
|
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";
|