srcdev-nuxt-components 9.1.20 → 9.1.21
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 +2 -1
- package/.claude/skills/component-dynamic-slots.md +66 -0
- package/.claude/skills/component-local-style-override.md +89 -40
- package/.claude/skills/components/social-icons-list.md +20 -5
- package/app/components/02.molecules/expandable/expanding-panel/ExpandingPanel.vue +5 -1
- package/app/components/02.molecules/expandable/expanding-panel/tests/ExpandingPanel.spec.ts +33 -0
- package/app/components/02.molecules/profile-section/tests/__snapshots__/ProfileSection.spec.ts.snap +3 -3
- package/app/pages/ui/accordian.vue +1 -1
- package/package.json +1 -1
package/.claude/settings.json
CHANGED
|
@@ -26,7 +26,8 @@
|
|
|
26
26
|
"Bash(git -C /Users/simoncornforth/websites/nuxt-components show 5597a15 --stat)",
|
|
27
27
|
"Bash(git -C /Users/simoncornforth/websites/nuxt-components show 5597a15 -- \"*.vue\" \"*.css\")",
|
|
28
28
|
"Bash(npm install:*)",
|
|
29
|
-
"Bash(npm run:*)"
|
|
29
|
+
"Bash(npm run:*)",
|
|
30
|
+
"Bash(git add:*)"
|
|
30
31
|
],
|
|
31
32
|
"additionalDirectories": [
|
|
32
33
|
"/Users/simoncornforth/websites/nuxt-components/app/components/01.atoms/content-wrappers/content-width",
|
|
@@ -138,6 +138,72 @@ Some components pair multiple slot types per index (e.g. TabsCore):
|
|
|
138
138
|
|
|
139
139
|
---
|
|
140
140
|
|
|
141
|
+
## Pattern 3 — Prefixed Slot Inference
|
|
142
|
+
|
|
143
|
+
The component iterates over a **named subset** of provided slots, filtered by a prefix pattern. Consumer adds `profile-info-1`, `profile-info-2`, etc. and the component renders exactly those — no count prop required.
|
|
144
|
+
|
|
145
|
+
Use this when:
|
|
146
|
+
|
|
147
|
+
- Slot names follow a predictable prefix+index convention (`prefix-N`)
|
|
148
|
+
- The component should render however many the consumer passes, without needing a count prop kept in sync
|
|
149
|
+
- A fallback count is still useful for demos/Storybook when no matching slots are provided
|
|
150
|
+
|
|
151
|
+
### Implementation
|
|
152
|
+
|
|
153
|
+
```vue
|
|
154
|
+
<template>
|
|
155
|
+
<div v-for="slotName in prefixedSlots" :key="slotName" class="item-block">
|
|
156
|
+
<slot :name="slotName">
|
|
157
|
+
<p>Fallback content for {{ slotName }}</p>
|
|
158
|
+
</slot>
|
|
159
|
+
</div>
|
|
160
|
+
</template>
|
|
161
|
+
|
|
162
|
+
<script setup lang="ts">
|
|
163
|
+
interface Props {
|
|
164
|
+
itemCount?: number; // fallback only — used when no matching slots are provided
|
|
165
|
+
}
|
|
166
|
+
const props = withDefaults(defineProps<Props>(), { itemCount: 3 });
|
|
167
|
+
|
|
168
|
+
const slots = useSlots();
|
|
169
|
+
|
|
170
|
+
const prefixedSlots = computed(() => {
|
|
171
|
+
const provided = Object.keys(slots)
|
|
172
|
+
.filter((key) => /^my-prefix-\d+$/.test(key))
|
|
173
|
+
.sort((a, b) => parseInt(a.split("-")[2] ?? "0") - parseInt(b.split("-")[2] ?? "0"));
|
|
174
|
+
return provided.length > 0
|
|
175
|
+
? provided
|
|
176
|
+
: Array.from({ length: props.itemCount }, (_, i) => `my-prefix-${i + 1}`);
|
|
177
|
+
});
|
|
178
|
+
</script>
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
### Key details
|
|
182
|
+
|
|
183
|
+
- `useSlots()` is required (not `$slots`) because the filtering logic lives in `<script setup>`
|
|
184
|
+
- Sort numerically, not lexicographically — `"10"` must come after `"9"`
|
|
185
|
+
- The `itemCount` fallback keeps demos and Storybook working without needing real slot content
|
|
186
|
+
- Do **not** pass `:heading-id` or other scoped props via these slots unless the consumer genuinely needs them — it creates duplicate-id risk when the same value is forwarded to multiple slots
|
|
187
|
+
|
|
188
|
+
### Consumer usage
|
|
189
|
+
|
|
190
|
+
No count prop needed — just add slots:
|
|
191
|
+
|
|
192
|
+
```vue
|
|
193
|
+
<ProfileSection>
|
|
194
|
+
<template #profile-info-1>...</template>
|
|
195
|
+
<template #profile-info-2>...</template>
|
|
196
|
+
<template #profile-info-3>...</template>
|
|
197
|
+
<template #profile-info-4>...</template> <!-- automatically picked up -->
|
|
198
|
+
</ProfileSection>
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
### Examples (prefixed slot inference)
|
|
202
|
+
|
|
203
|
+
- `app/components/02.molecules/profile-section/ProfileSection.vue`
|
|
204
|
+
|
|
205
|
+
---
|
|
206
|
+
|
|
141
207
|
## Comparison
|
|
142
208
|
|
|
143
209
|
| | Named dynamic slots | Indexed dynamic slots |
|
|
@@ -2,17 +2,75 @@
|
|
|
2
2
|
|
|
3
3
|
## Overview
|
|
4
4
|
|
|
5
|
-
When including a component in a page or
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
When including a component in a consuming page or component, visual customisation (theming and
|
|
6
|
+
geometry) can be applied locally without modifying the component. Two patterns exist depending
|
|
7
|
+
on context.
|
|
8
8
|
|
|
9
|
-
No changes to the component are required.
|
|
9
|
+
No changes to the layer component are required for either pattern.
|
|
10
10
|
|
|
11
11
|
---
|
|
12
12
|
|
|
13
|
-
## Pattern
|
|
13
|
+
## Pattern 1 — Page-level scoping (preferred for single-use or section-scoped instances)
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
The consuming page has a unique wrapper or body class. The `<style>` block is **unscoped** — no
|
|
16
|
+
`scoped` attribute — so component class names are targeted directly by nesting within the page
|
|
17
|
+
scope. No `:deep()` is needed.
|
|
18
|
+
|
|
19
|
+
```vue
|
|
20
|
+
<!-- In page template or parent component -->
|
|
21
|
+
<template>
|
|
22
|
+
<div class="contact-page">
|
|
23
|
+
<div class="hero-section">
|
|
24
|
+
<SocialIconsList :items="socialItems" />
|
|
25
|
+
</div>
|
|
26
|
+
</div>
|
|
27
|
+
</template>
|
|
28
|
+
|
|
29
|
+
<!-- Unscoped style block — no `scoped` attribute -->
|
|
30
|
+
<style lang="css">
|
|
31
|
+
.contact-page {
|
|
32
|
+
.hero-section {
|
|
33
|
+
.social-icons-list {
|
|
34
|
+
/* Theming */
|
|
35
|
+
--theme-social-icon-size: 3.2rem;
|
|
36
|
+
--theme-social-icon-gap: 2rem;
|
|
37
|
+
color: var(--colour-brand-primary); /* drives currentColor on icons */
|
|
38
|
+
|
|
39
|
+
/* Geometry */
|
|
40
|
+
margin-block-start: 1.6rem;
|
|
41
|
+
|
|
42
|
+
.social-icon-link {
|
|
43
|
+
border-radius: 0.4rem;
|
|
44
|
+
padding: 0.4rem;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
</style>
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
**Body class pattern**: Pages often set a unique class via `bodyAttrs.class` in `useHead()`, then
|
|
53
|
+
use that as the root scope for all page-specific overrides:
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
useHead({ bodyAttrs: { class: "contact-page" } })
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
```css
|
|
60
|
+
/* All overrides for the page nested under the body class */
|
|
61
|
+
.contact-page {
|
|
62
|
+
.social-icons-list { ... }
|
|
63
|
+
.hero-text { ... }
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## Pattern 2 — Per-instance modifier via styleClassPassthrough
|
|
70
|
+
|
|
71
|
+
Use when the same component appears multiple times on a page and you need to target a specific
|
|
72
|
+
instance, or when the consuming file uses `<style scoped>` and needs an anchor class that survives
|
|
73
|
+
scoping.
|
|
16
74
|
|
|
17
75
|
```vue
|
|
18
76
|
<CardCore :style-class-passthrough="['featured-card']">
|
|
@@ -20,11 +78,6 @@ No changes to the component are required.
|
|
|
20
78
|
</CardCore>
|
|
21
79
|
```
|
|
22
80
|
|
|
23
|
-
### 2. Add a style block in the consuming file
|
|
24
|
-
|
|
25
|
-
Scaffold this block when adding the component. Include a comment so future developers know it
|
|
26
|
-
is safe to delete if no overrides are needed.
|
|
27
|
-
|
|
28
81
|
```vue
|
|
29
82
|
<style>
|
|
30
83
|
/* ─── CardCore local overrides ─────────────────────────────────────
|
|
@@ -40,54 +93,59 @@ is safe to delete if no overrides are needed.
|
|
|
40
93
|
|
|
41
94
|
/* Geometry */
|
|
42
95
|
/* border-radius: 1.6rem; */
|
|
43
|
-
|
|
44
|
-
/* Border / outline */
|
|
45
|
-
/* --_border-width: 0.3rem; */
|
|
46
96
|
}
|
|
47
97
|
}
|
|
48
98
|
</style>
|
|
49
99
|
```
|
|
50
100
|
|
|
101
|
+
The modifier class lands on the component's root element — nested element overrides use the full
|
|
102
|
+
path: `.card-core.featured-card .card-row-header { ... }`.
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## When to offer a scaffold
|
|
107
|
+
|
|
108
|
+
After placing a component in a consuming page or component, offer a CSS override scaffold. Use the
|
|
109
|
+
component's own class name and any `--theme-*` tokens it exposes as commented stubs. Cover theming
|
|
110
|
+
(colours, tokens) and geometry (sizes, spacing, borders) — not behaviour (`display`, `pointer-events`,
|
|
111
|
+
`z-index`, animations).
|
|
112
|
+
|
|
51
113
|
---
|
|
52
114
|
|
|
53
115
|
## What to override
|
|
54
116
|
|
|
55
117
|
| Category | Examples | Approach |
|
|
56
118
|
|---|---|---|
|
|
57
|
-
|
|
|
58
|
-
| Geometry | border-radius, padding, gap | Direct property or `--_` private variable |
|
|
119
|
+
| Theming | icon colour, background, border colour | `--theme-*` tokens where exposed; otherwise direct values |
|
|
120
|
+
| Geometry | border-radius, padding, gap, size | Direct property or `--_` private variable |
|
|
59
121
|
| Border / outline | width, style, colour | Direct property or `--_` private variable |
|
|
60
122
|
|
|
61
123
|
**Do not override behaviour** — `display`, `visibility`, `pointer-events`, `z-index`, animations.
|
|
62
|
-
Those belong in the component or a structural parent
|
|
124
|
+
Those belong in the component or a structural parent.
|
|
63
125
|
|
|
64
126
|
---
|
|
65
127
|
|
|
66
128
|
## CSS custom property targeting
|
|
67
129
|
|
|
68
|
-
Components use `--_`
|
|
69
|
-
a modifier class at higher specificity:
|
|
130
|
+
Components expose `--theme-*` public tokens and use `--_` private tokens internally:
|
|
70
131
|
|
|
71
132
|
```css
|
|
72
|
-
/* Component internally
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
133
|
+
/* Component internally: --_icon-size: var(--theme-social-icon-size, 2.4rem) */
|
|
134
|
+
|
|
135
|
+
/* Override via --theme-* (stable, recommended): */
|
|
136
|
+
.social-icons-list {
|
|
137
|
+
--theme-social-icon-size: 3.2rem;
|
|
76
138
|
}
|
|
77
139
|
|
|
78
|
-
/*
|
|
79
|
-
.
|
|
140
|
+
/* Override via --_ private token (fragile — may break on component update): */
|
|
141
|
+
.social-icons-list {
|
|
80
142
|
&.my-modifier {
|
|
81
|
-
--
|
|
82
|
-
/* or */
|
|
83
|
-
--_background-color: #f5f0eb; /* direct value */
|
|
143
|
+
--_icon-size: 3.2rem;
|
|
84
144
|
}
|
|
85
145
|
}
|
|
86
146
|
```
|
|
87
147
|
|
|
88
|
-
|
|
89
|
-
the override will silently stop working. For shared/themeable overrides, prefer components that
|
|
90
|
-
expose `--theme-*` public variables instead.
|
|
148
|
+
Prefer `--theme-*` tokens. Only target `--_` private variables when no `--theme-*` equivalent exists.
|
|
91
149
|
|
|
92
150
|
---
|
|
93
151
|
|
|
@@ -115,12 +173,3 @@ expose `--theme-*` public variables instead.
|
|
|
115
173
|
- Anything where visual inconsistency between instances would be a bug
|
|
116
174
|
|
|
117
175
|
The test: *should all instances of this component look the same?* If yes → theme. If instances are expected to look different → local override.
|
|
118
|
-
|
|
119
|
-
---
|
|
120
|
-
|
|
121
|
-
## Notes
|
|
122
|
-
|
|
123
|
-
- `styleClassPassthrough` accepts a string or array — pass an array when combining multiple modifiers.
|
|
124
|
-
- The modifier class lands on the component's root element, so nested element overrides need the
|
|
125
|
-
full selector path: `.card-core.featured-card .card-row-header { ... }`.
|
|
126
|
-
- Keep the style block close to the component usage in the template — don't put it in a global stylesheet.
|
|
@@ -93,23 +93,38 @@ import type { ISocialIcon } from "srcdev-nuxt-components";
|
|
|
93
93
|
|
|
94
94
|
## Local style override scaffold
|
|
95
95
|
|
|
96
|
+
Offer this scaffold when placing the component in a consuming page or section. The style block is
|
|
97
|
+
**unscoped** — no `:deep()` needed. Scope by the page or section's existing wrapper class.
|
|
98
|
+
|
|
96
99
|
```vue
|
|
97
|
-
<
|
|
100
|
+
<template>
|
|
101
|
+
<SocialIconsList :items="items" />
|
|
102
|
+
</template>
|
|
98
103
|
|
|
99
|
-
<style>
|
|
104
|
+
<style lang="css">
|
|
100
105
|
/* ─── SocialIconsList local overrides ──────────────────────────────
|
|
101
106
|
Geometry and size only — brand colours are baked into logos: SVGs.
|
|
102
107
|
Delete this block if no overrides are needed.
|
|
103
108
|
─────────────────────────────────────────────────────────────────── */
|
|
104
|
-
.
|
|
105
|
-
|
|
109
|
+
.my-page-or-section {
|
|
110
|
+
.social-icons-list {
|
|
106
111
|
/* --theme-social-icon-size: 3.2rem; */
|
|
107
|
-
/* --theme-social-icon-gap:
|
|
112
|
+
/* --theme-social-icon-gap: 1.6rem; */
|
|
113
|
+
/* margin-block-start: 1.6rem; */
|
|
114
|
+
|
|
115
|
+
.social-icon-link {
|
|
116
|
+
/* border-radius: 0.4rem; */
|
|
117
|
+
/* padding: 0.4rem; */
|
|
118
|
+
/* outline: 1px solid transparent; */
|
|
119
|
+
}
|
|
108
120
|
}
|
|
109
121
|
}
|
|
110
122
|
</style>
|
|
111
123
|
```
|
|
112
124
|
|
|
125
|
+
Use `styleClassPassthrough` only if the same component appears multiple times on the page and you
|
|
126
|
+
need to target a specific instance. See `component-local-style-override.md` for full pattern guidance.
|
|
127
|
+
|
|
113
128
|
---
|
|
114
129
|
|
|
115
130
|
## Notes
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<div class="expanding-panel" :class="[elementClasses]">
|
|
3
|
-
<details class="expanding-panel-details" :name :open>
|
|
3
|
+
<details class="expanding-panel-details" :name :open @toggle="onDetailsToggle">
|
|
4
4
|
<summary
|
|
5
5
|
:id="`id-${name}-trigger`"
|
|
6
6
|
class="expanding-panel-summary"
|
|
@@ -59,6 +59,10 @@ const handleToggle = (event: Event) => {
|
|
|
59
59
|
}
|
|
60
60
|
isPanelOpen.value = !isPanelOpen.value;
|
|
61
61
|
};
|
|
62
|
+
|
|
63
|
+
const onDetailsToggle = (event: Event) => {
|
|
64
|
+
isPanelOpen.value = (event.target as HTMLDetailsElement).open;
|
|
65
|
+
};
|
|
62
66
|
</script>
|
|
63
67
|
|
|
64
68
|
<style lang="css">
|
|
@@ -225,6 +225,39 @@ describe("ExpandingPanel", () => {
|
|
|
225
225
|
expect(wrapper.emitted("update:modelValue")?.[0]).toEqual([true]);
|
|
226
226
|
});
|
|
227
227
|
|
|
228
|
+
it("syncs isPanelOpen to false when <details> is closed externally via toggle event", async () => {
|
|
229
|
+
const wrapper = await mountSuspended(ExpandingPanel, {
|
|
230
|
+
props: { name: "external-close" },
|
|
231
|
+
attrs: { modelValue: true },
|
|
232
|
+
});
|
|
233
|
+
const vm = wrapper.vm as unknown as ExpandingPanelInstance;
|
|
234
|
+
expect(vm.isPanelOpen).toBe(true);
|
|
235
|
+
|
|
236
|
+
// Simulate browser closing <details> externally (e.g. exclusive accordion name group)
|
|
237
|
+
const details = wrapper.find("details").element as HTMLDetailsElement;
|
|
238
|
+
details.open = false;
|
|
239
|
+
await wrapper.find("details").trigger("toggle");
|
|
240
|
+
await nextTick();
|
|
241
|
+
|
|
242
|
+
expect(vm.isPanelOpen).toBe(false);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("syncs isPanelOpen to true when <details> is opened externally via toggle event", async () => {
|
|
246
|
+
const wrapper = await mountSuspended(ExpandingPanel, {
|
|
247
|
+
props: { name: "external-open" },
|
|
248
|
+
});
|
|
249
|
+
const vm = wrapper.vm as unknown as ExpandingPanelInstance;
|
|
250
|
+
expect(vm.isPanelOpen).toBe(false);
|
|
251
|
+
|
|
252
|
+
// Simulate browser opening <details> externally
|
|
253
|
+
const details = wrapper.find("details").element as HTMLDetailsElement;
|
|
254
|
+
details.open = true;
|
|
255
|
+
await wrapper.find("details").trigger("toggle");
|
|
256
|
+
await nextTick();
|
|
257
|
+
|
|
258
|
+
expect(vm.isPanelOpen).toBe(true);
|
|
259
|
+
});
|
|
260
|
+
|
|
228
261
|
it("updates aria-expanded after toggle", async () => {
|
|
229
262
|
const wrapper = await mountSuspended(ExpandingPanel, {
|
|
230
263
|
props: { name: "aria-toggle" },
|
package/app/components/02.molecules/profile-section/tests/__snapshots__/ProfileSection.spec.ts.snap
CHANGED
|
@@ -11,13 +11,13 @@ exports[`ProfileSection > renders correct HTML structure 1`] = `
|
|
|
11
11
|
<div class="profile-info">
|
|
12
12
|
<div class="profile-info-content">
|
|
13
13
|
<div class="profile-info-block">
|
|
14
|
-
<p>Profile info content 1</p>
|
|
14
|
+
<p>Profile info content profile-info-1</p>
|
|
15
15
|
</div>
|
|
16
16
|
<div class="profile-info-block">
|
|
17
|
-
<p>Profile info content 2</p>
|
|
17
|
+
<p>Profile info content profile-info-2</p>
|
|
18
18
|
</div>
|
|
19
19
|
<div class="profile-info-block">
|
|
20
|
-
<p>Profile info content 3</p>
|
|
20
|
+
<p>Profile info content profile-info-3</p>
|
|
21
21
|
</div>
|
|
22
22
|
</div>
|
|
23
23
|
<!--v-if-->
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
<AccordianCore
|
|
10
10
|
:item-count="data.length ?? 0"
|
|
11
|
-
:animation-duration="
|
|
11
|
+
:animation-duration="1000"
|
|
12
12
|
:style-class-passthrough="['class-modifier-narrow']"
|
|
13
13
|
>
|
|
14
14
|
<template v-for="(item, key) in data" :key="`summary-${key}`" #[`accordian-${key}-summary`]>
|