srcdev-nuxt-components 9.1.19 → 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.
@@ -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 consuming component, visual customisation can be applied
6
- locally using `styleClassPassthrough` combined with a scoped style block in the consuming file.
7
- This avoids adding one-off props to the component and keeps customisation co-located with the usage.
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
- ### 1. Pass a modifier class via styleClassPassthrough
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
- | Colours | backgrounds, borders, text | CSS custom properties if the component exposes them, otherwise direct values |
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, not a style modifier.
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 `--_` prefixed private custom properties internally. These can be targeted via
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 defines: */
73
- .my-component {
74
- --_background-color: white;
75
- background-color: var(--_background-color);
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
- /* Consumer overrides via modifier: */
79
- .my-component {
140
+ /* Override via --_ private token (fragile — may break on component update): */
141
+ .social-icons-list {
80
142
  &.my-modifier {
81
- --_background-color: var(--brand-surface); /* CSS token */
82
- /* or */
83
- --_background-color: #f5f0eb; /* direct value */
143
+ --_icon-size: 3.2rem;
84
144
  }
85
145
  }
86
146
  ```
87
147
 
88
- Note: `--_` properties are component-internal. If the component is updated and renames them,
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
- <SocialIconsList :style-class-passthrough="['my-social-icons']" :items="items" />
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
- .social-icons-list {
105
- &.my-social-icons {
109
+ .my-page-or-section {
110
+ .social-icons-list {
106
111
  /* --theme-social-icon-size: 3.2rem; */
107
- /* --theme-social-icon-gap: 2rem; */
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" },
@@ -1,10 +1,5 @@
1
1
  <template>
2
- <component
3
- :is="tag"
4
- class="profile-section"
5
- :class="[elementClasses]"
6
- :aria-labelledby="ariaLabelledby"
7
- >
2
+ <component :is="tag" class="profile-section" :class="[elementClasses]" :aria-labelledby="ariaLabelledby">
8
3
  <header class="profile-section-header">
9
4
  <slot v-if="hasEyebrowTextSlot" name="eyebrowText"></slot>
10
5
  <slot v-if="hasHeroTextSlot" name="heroText" :heading-id="headingId"></slot>
@@ -16,9 +11,9 @@
16
11
  </div>
17
12
  <div class="profile-info">
18
13
  <div class="profile-info-content">
19
- <div v-for="index in props.profileInfoCount" :key="index" class="profile-info-block">
20
- <slot :name="'profile-info-' + index">
21
- <p>Profile info content {{ index }}</p>
14
+ <div v-for="slotName in profileInfoSlots" :key="slotName" class="profile-info-block">
15
+ <slot :name="slotName">
16
+ <p>Profile info content {{ slotName }}</p>
22
17
  </slot>
23
18
  </div>
24
19
  </div>
@@ -58,6 +53,15 @@ const hasEyebrowTextSlot = computed(() => Boolean(slots.eyebrowText));
58
53
  const hasHeroTextSlot = computed(() => Boolean(slots.heroText));
59
54
  const hasProfileLinksSlot = computed(() => Boolean(slots.profileLinks));
60
55
 
56
+ const profileInfoSlots = computed(() => {
57
+ const provided = Object.keys(slots)
58
+ .filter((key) => /^profile-info-\d+$/.test(key))
59
+ .sort((a, b) => parseInt(a.split("-")[2] ?? "0") - parseInt(b.split("-")[2] ?? "0"));
60
+ return provided.length > 0
61
+ ? provided
62
+ : Array.from({ length: props.profileInfoCount }, (_, i) => `profile-info-${i + 1}`);
63
+ });
64
+
61
65
  const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
62
66
 
63
67
  watch(
@@ -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="3000"
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`]>
@@ -12,7 +12,6 @@
12
12
  src: '/images/services/service-balayage.jpg',
13
13
  alt: 'Profile picture of Natasha, the mobile hairdresser in Bath',
14
14
  }"
15
- :profile-info-count="3"
16
15
  >
17
16
  <template #eyebrowText>
18
17
  <EyebrowText tag="p" font-size="large" text-content="About Natasha" :style-class-passthrough="['mb-0']" />
@@ -66,6 +65,35 @@
66
65
  </p>
67
66
  </template>
68
67
 
68
+ <template #profile-info-4>
69
+ <HeroText
70
+ tag="h3"
71
+ axis="horizontal"
72
+ font-size="heading"
73
+ :text-content="[
74
+ { text: 'Follow me on', styleClass: 'normal' },
75
+ { text: 'social media', styleClass: 'accent' },
76
+ ]"
77
+ :style-class-passthrough="['mb-20']"
78
+ />
79
+ <SocialIconsList
80
+ :items="[
81
+ {
82
+ networkName: 'Facebook',
83
+ iconName: 'logos:facebook',
84
+ baseHref: 'https://www.facebook.com/',
85
+ profileId: 'luxurylocsbynatasha',
86
+ },
87
+ {
88
+ networkName: 'Instagram',
89
+ iconName: 'skill-icons:instagram',
90
+ baseHref: 'https://www.instagram.com/',
91
+ profileId: 'luxurylocsbynatasha',
92
+ },
93
+ ]"
94
+ />
95
+ </template>
96
+
69
97
  <template #profileLinks>
70
98
  <InputButtonCore
71
99
  variant="secondary"
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "srcdev-nuxt-components",
3
3
  "type": "module",
4
- "version": "9.1.19",
4
+ "version": "9.1.21",
5
5
  "main": "nuxt.config.ts",
6
6
  "types": "types.d.ts",
7
7
  "license": "MIT",