srcdev-nuxt-components 9.1.58 → 9.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. package/.claude/settings.json +4 -1
  2. package/.claude/skills/component-aria-landmark.md +42 -8
  3. package/.claude/skills/components/auto-grid.md +12 -6
  4. package/.claude/skills/components/page-hero-highlights.md +3 -1
  5. package/.claude/skills/components/page-row.md +14 -5
  6. package/.claude/skills/components/profile-section.md +1 -1
  7. package/.claude/skills/components/services-section.md +63 -7
  8. package/.claude/skills/components/tab-navigation.md +49 -0
  9. package/.claude/skills/index.md +2 -2
  10. package/.claude/skills/testing-add-unit-test.md +18 -0
  11. package/app/components/01.atoms/grids/data-grid/AutoGrid.vue +1 -2
  12. package/app/components/01.atoms/grids/data-grid/tests/AutoGrid.spec.ts +11 -19
  13. package/app/components/01.atoms/grids/data-grid/tests/__snapshots__/AutoGrid.spec.ts.snap +1 -1
  14. package/app/components/01.atoms/page-row/tests/PageRow.spec.ts +28 -3
  15. package/app/components/02.molecules/profile-section/tests/ProfileSection.spec.ts +2 -2
  16. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesSectionGrid.spec.ts.snap +18 -18
  17. package/app/components/03.organisms/services/services-section/CONSUMER-STYLING.md +149 -0
  18. package/app/components/03.organisms/services/services-section/ServicesSection.vue +85 -60
  19. package/app/components/03.organisms/services/services-section/stories/ServicesSection.stories.ts +186 -32
  20. package/app/components/03.organisms/services/services-section/tests/ServicesSection.spec.ts +83 -0
  21. package/app/components/03.organisms/services/services-section/tests/__snapshots__/ServicesSection.spec.ts.snap +11 -11
  22. package/app/components/04.templates/page-hero-highlights/tests/PageHeroHighlights.spec.ts +2 -2
  23. package/app/composables/tests/useAriaLabelledById.spec.ts +7 -2
  24. package/app/composables/useAriaLabelledById.ts +18 -1
  25. package/package.json +1 -1
@@ -0,0 +1,149 @@
1
+ # ServicesSection — Consumer Styling Guide
2
+
3
+ ## Public token API
4
+
5
+ All `--services-section-*` tokens are the stable override surface. Set them globally in a
6
+ theme file, scoped to a page wrapper, or per-instance via `styleClassPassthrough`.
7
+
8
+ ### Layout & sizing
9
+
10
+ | Token | Default | Controls |
11
+ |---|---|---|
12
+ | `--services-section-grid-gap` | `2rem` | Gap between image and content columns below the 768px breakpoint |
13
+ | `--services-section-grid-gap-desktop` | `3rem` | Gap between columns at 768px and above |
14
+ | `--services-section-image-border-radius` | `0.8rem` | Corner rounding on the service image |
15
+ | `--services-section-price-duration-gap` | `3rem` | Gap between the duration and price rows |
16
+ | `--services-section-price-duration-item-gap` | `0.8rem` | Gap between icon and text within a single row |
17
+ | `--services-section-price-duration-margin-block-end` | `2rem` | Space below the price/duration row |
18
+ | `--services-section-decorator-size` | `2rem` | Width/height of the duration and price icons |
19
+ | `--services-section-faq-margin-block-end` | `2.4rem` | Space below the FAQ accordion |
20
+ | `--services-section-faq-answer-line-height` | `1.6` | Line height of FAQ answer text |
21
+
22
+ ### Colours
23
+
24
+ | Token | Default | Controls |
25
+ |---|---|---|
26
+ | `--services-section-faq-divider-color` | `currentColor` | Border colour between FAQ accordion items |
27
+ | `--services-section-faq-divider-opacity` | `0.7` | Opacity of each FAQ accordion item's border |
28
+
29
+ ---
30
+
31
+ ## Text content — props, not CSS
32
+
33
+ Section headings and the default CTA panel copy are **props**, not hardcoded strings, so
34
+ there is nothing to override in CSS for these:
35
+
36
+ | Prop | Default |
37
+ |---|---|
38
+ | `processHeading` | `"The Process"` |
39
+ | `idealForHeading` | `"Ideal For"` |
40
+ | `maintenanceHeading` | `"Aftercare & Maintenance"` |
41
+ | `faqsHeading` | `"Frequently Asked Questions"` |
42
+ | `ctaHeading` | `"Ready to book your appointment?"` |
43
+ | `ctaBody` | `"Get in touch to book your appointment."` |
44
+
45
+ ```vue
46
+ <ServicesSection
47
+ :service-data="service"
48
+ cta-heading="Ready to book your locs consultation?"
49
+ cta-body="Mobile service across Bath — I come to you."
50
+ />
51
+ ```
52
+
53
+ If the CTA needs a different component entirely (not just different text), replace it with
54
+ the `cta-panel` scoped slot instead of styling around the default `GlassPanel`:
55
+
56
+ ```vue
57
+ <ServicesSection :service-data="service">
58
+ <template #cta-panel="{ serviceData, ctaHeading, ctaBody }">
59
+ <MyCustomPanel :heading="ctaHeading" :body="ctaBody">
60
+ <template #cta><button>Enquire Now</button></template>
61
+ </MyCustomPanel>
62
+ </template>
63
+ </ServicesSection>
64
+ ```
65
+
66
+ ---
67
+
68
+ ## Global theming — recommended approach
69
+
70
+ Create `assets/styles/setup/07.components/services-section.css` in the consuming app and
71
+ set tokens on `:root`. This applies to every `ServicesSection` across the site.
72
+
73
+ ```css
74
+ /* assets/styles/setup/07.components/services-section.css */
75
+ :root {
76
+ --services-section-grid-gap: 2.4rem;
77
+ --services-section-grid-gap-desktop: 4rem;
78
+ --services-section-image-border-radius: 1.2rem;
79
+ --services-section-faq-divider-color: var(--brand-border);
80
+ }
81
+ ```
82
+
83
+ ---
84
+
85
+ ## Page-scoped overrides
86
+
87
+ Override tokens for a specific section by scoping them under the page or layout wrapper.
88
+ No `:deep()` is required (component styles are unscoped).
89
+
90
+ ```css
91
+ /* In the consuming page's unscoped <style> block */
92
+ .our-services-page {
93
+ .services-section {
94
+ --services-section-decorator-size: 2.4rem;
95
+ --services-section-faq-answer-line-height: 1.8;
96
+ }
97
+ }
98
+ ```
99
+
100
+ ---
101
+
102
+ ## Per-instance overrides via styleClassPassthrough
103
+
104
+ Use sparingly — prefer global or page-scoped CSS. When a single instance needs a distinct
105
+ visual style, pass a modifier class:
106
+
107
+ ```vue
108
+ <ServicesSection :style-class-passthrough="['highlight-service']" :service-data="service" />
109
+ ```
110
+
111
+ ```css
112
+ .services-section.highlight-service {
113
+ --services-section-image-border-radius: 2rem;
114
+ --services-section-faq-divider-color: var(--color-accent);
115
+ }
116
+ ```
117
+
118
+ ---
119
+
120
+ ## FAQ accordion styling
121
+
122
+ The `AccordianCore` inside this component receives `services-section__faq` via
123
+ `styleClassPassthrough`. To target the accordion's own internals (summary, icon rotation,
124
+ content padding), style through that class rather than reaching into `AccordianCore`
125
+ directly — see `.services-section__faq` in the component's own `<style>` block for the
126
+ selectors already scoped this way (`.accordian-item.expanding-panel`,
127
+ `.expanding-panel-summary`, `.services-section__faq-answer`).
128
+
129
+ ```css
130
+ .services-section {
131
+ .services-section__faq {
132
+ .expanding-panel-details .expanding-panel-summary {
133
+ padding-block: 1.6rem;
134
+ }
135
+ }
136
+ }
137
+ ```
138
+
139
+ ---
140
+
141
+ ## Notes
142
+
143
+ - `--services-section-grid-gap-desktop` only applies at `768px` and above — the breakpoint
144
+ itself is not a token, since changing it would affect the grid's `minmax()` column sizing
145
+ too, not just spacing.
146
+ - The CTA panel is rendered inside a default `<slot name="cta-panel">` — passing that slot
147
+ bypasses `GlassPanel` and all of its tokens (`--glass-panel-*`) entirely, along with the
148
+ `ctaHeading`/`ctaBody` props, which are handed to the slot for reuse rather than applied
149
+ automatically.
@@ -1,12 +1,19 @@
1
1
  <template>
2
2
  <component :is="tag" class="services-section" :class="[elementClasses]" :aria-labelledby="ariaLabelledby">
3
3
  <div class="services-section__grid" :class="{ 'services-section__grid--reverse': reverse }">
4
- <div class="image-wrapper">
5
- <NuxtImg :src="serviceData.image" :alt="serviceData.title" :loading="imageLoading" :fetchpriority="imageFetchPriority" class="image" />
4
+ <div class="services-section__image-wrapper">
5
+ <NuxtImg
6
+ :src="serviceData.image"
7
+ :alt="serviceData.title"
8
+ :loading="imageLoading"
9
+ :fetchpriority="imageFetchPriority"
10
+ class="services-section__image"
11
+ />
6
12
  </div>
7
- <div class="info-wrapper" :class="infoWrapperClasses">
13
+ <div class="services-section__info-wrapper" :class="infoWrapperClasses">
8
14
  <EyebrowText font-size="large" :text-content="serviceData.subtitle" />
9
15
  <HeroText
16
+ :id="headingId"
10
17
  :tag="headerTag"
11
18
  font-size="title"
12
19
  :text-content="[
@@ -18,13 +25,13 @@
18
25
  :style-class-passthrough="['mb-20']"
19
26
  />
20
27
 
21
- <div class="price-duration">
28
+ <div class="services-section__price-duration">
22
29
  <div class="flex-row">
23
- <Icon :name="durationIcon" class="decorator" />
30
+ <Icon :name="durationIcon" class="services-section__decorator" />
24
31
  <span>{{ serviceData.duration }}</span>
25
32
  </div>
26
33
  <div class="flex-row">
27
- <Icon :name="priceIcon" class="decorator" />
34
+ <Icon :name="priceIcon" class="services-section__decorator" />
28
35
  <span>{{ serviceData.price }}</span>
29
36
  </div>
30
37
  </div>
@@ -50,7 +57,7 @@
50
57
  :tag="headerTag"
51
58
  axis="horizontal"
52
59
  font-size="subheading"
53
- :text-content="[{ text: 'The Process ', styleClass: 'normal' }]"
60
+ :text-content="[{ text: processHeading, styleClass: 'normal' }]"
54
61
  :style-class-passthrough="['mb-20']"
55
62
  />
56
63
 
@@ -72,7 +79,7 @@
72
79
  :tag="headerTag"
73
80
  axis="horizontal"
74
81
  font-size="subheading"
75
- :text-content="[{ text: 'Ideal For', styleClass: 'normal' }]"
82
+ :text-content="[{ text: idealForHeading, styleClass: 'normal' }]"
76
83
  :style-class-passthrough="['mb-20']"
77
84
  />
78
85
 
@@ -90,7 +97,7 @@
90
97
  :tag="headerTag"
91
98
  axis="horizontal"
92
99
  font-size="subheading"
93
- :text-content="[{ text: 'Aftercare &amp; Maintenance', styleClass: 'normal' }]"
100
+ :text-content="[{ text: maintenanceHeading, styleClass: 'normal' }]"
94
101
  :style-class-passthrough="['mb-20']"
95
102
  />
96
103
 
@@ -101,7 +108,7 @@
101
108
  :tag="headerTag"
102
109
  axis="horizontal"
103
110
  font-size="subheading"
104
- :text-content="[{ text: 'Frequently Asked Questions', styleClass: 'normal' }]"
111
+ :text-content="[{ text: faqsHeading, styleClass: 'normal' }]"
105
112
  :style-class-passthrough="['mb-20']"
106
113
  />
107
114
 
@@ -110,7 +117,7 @@
110
117
  id="faq"
111
118
  :item-count="serviceData.faqs.length"
112
119
  :name="`faq-${useId()}`"
113
- :style-class-passthrough="['services-faq']"
120
+ :style-class-passthrough="['services-section__faq']"
114
121
  >
115
122
  <template v-for="(_, key) in serviceData.faqs" :key="`summary-${key}`" #[`accordian-${key}-summary`]>
116
123
  {{ serviceData.faqs[key]?.question }}
@@ -119,21 +126,29 @@
119
126
  <Icon name="mdi:chevron-down" />
120
127
  </template>
121
128
  <template v-for="(_, key) in serviceData.faqs" :key="`content-${key}`" #[`accordian-${key}-content`]>
122
- <p>{{ serviceData.faqs[key]?.answer }}</p>
129
+ <p class="services-section__faq-answer">{{ serviceData.faqs[key]?.answer }}</p>
123
130
  </template>
124
131
  </AccordianCore>
125
132
 
126
- <GlassPanel v-if="!isSummary" :style-class-passthrough="['p-24']">
127
- <HeroText
128
- tag="h2"
129
- axis="horizontal"
130
- font-size="subheading"
131
- :text-content="[{ text: 'Ready to book your highlights appointment?', styleClass: 'normal' }]"
132
- :style-class-passthrough="['mbs-0', 'mbe-20']"
133
- />
134
- <p class="page-body-normal">Mobile service across Bath — I come to you.</p>
135
- <slot name="cta" :service-data="serviceData"></slot>
136
- </GlassPanel>
133
+ <slot
134
+ v-if="!isSummary"
135
+ name="cta-panel"
136
+ :service-data="serviceData"
137
+ :cta-heading="ctaHeading"
138
+ :cta-body="ctaBody"
139
+ >
140
+ <GlassPanel :style-class-passthrough="['services-section__glass-panel', 'p-24']">
141
+ <HeroText
142
+ tag="h2"
143
+ axis="horizontal"
144
+ font-size="subheading"
145
+ :text-content="[{ text: ctaHeading, styleClass: 'normal' }]"
146
+ :style-class-passthrough="['mbs-0', 'mbe-20']"
147
+ />
148
+ <p class="page-body-normal">{{ ctaBody }}</p>
149
+ <slot name="cta" :service-data="serviceData"></slot>
150
+ </GlassPanel>
151
+ </slot>
137
152
  </div>
138
153
  </div>
139
154
  </component>
@@ -152,6 +167,12 @@ interface Props {
152
167
  reverse?: boolean;
153
168
  durationIcon?: string;
154
169
  priceIcon?: string;
170
+ processHeading?: string;
171
+ idealForHeading?: string;
172
+ maintenanceHeading?: string;
173
+ faqsHeading?: string;
174
+ ctaHeading?: string;
175
+ ctaBody?: string;
155
176
  styleClassPassthrough?: string | string[];
156
177
  }
157
178
  const props = withDefaults(defineProps<Props>(), {
@@ -162,17 +183,23 @@ const props = withDefaults(defineProps<Props>(), {
162
183
  summaryAlignment: "center",
163
184
  durationIcon: "mdi:clock-time-four-outline",
164
185
  priceIcon: "mdi:currency-gbp",
186
+ processHeading: "The Process",
187
+ idealForHeading: "Ideal For",
188
+ maintenanceHeading: "Aftercare & Maintenance",
189
+ faqsHeading: "Frequently Asked Questions",
190
+ ctaHeading: "Ready to book your appointment?",
191
+ ctaBody: "Get in touch to book your appointment.",
165
192
  styleClassPassthrough: () => [],
166
193
  });
167
194
 
168
- const { ariaLabelledby } = useAriaLabelledById(() => props.tag);
195
+ const { headingId, ariaLabelledby } = useAriaLabelledById(() => props.tag);
169
196
 
170
197
  const infoWrapperClasses = computed(() => {
171
198
  return {
172
- "is-summary": props.isSummary,
173
- "align-start": props.isSummary && props.summaryAlignment === "start",
174
- "align-center": props.isSummary && props.summaryAlignment === "center",
175
- "align-end": props.isSummary && props.summaryAlignment === "end",
199
+ "services-section__info-wrapper--summary": props.isSummary,
200
+ "services-section__info-wrapper--align-start": props.isSummary && props.summaryAlignment === "start",
201
+ "services-section__info-wrapper--align-center": props.isSummary && props.summaryAlignment === "center",
202
+ "services-section__info-wrapper--align-end": props.isSummary && props.summaryAlignment === "end",
176
203
  };
177
204
  });
178
205
 
@@ -199,44 +226,47 @@ watch(
199
226
  container-name: services-section;
200
227
 
201
228
  .services-section__grid {
229
+ --_grid-gap: var(--services-section-grid-gap, 2rem);
230
+
202
231
  display: grid;
203
232
  grid-template-columns: 1fr;
204
- gap: 2rem;
233
+ gap: var(--_grid-gap);
205
234
 
206
235
  @media (width >= 768px) {
236
+ --_grid-gap: var(--services-section-grid-gap-desktop, 3rem);
237
+
207
238
  grid-template-columns: repeat(auto-fit, minmax(446px, 1fr));
208
- gap: 3rem;
209
239
 
210
240
  &.services-section__grid--reverse {
211
- .image-wrapper {
212
- /* The order: 2 on .image-wrapper pushes it after the content div, since by default both children have order: 0 and source order wins — so the content div naturally sits first. */
241
+ .services-section__image-wrapper {
242
+ /* The order: 2 on .services-section__image-wrapper pushes it after the content div, since by default both children have order: 0 and source order wins — so the content div naturally sits first. */
213
243
  order: 2;
214
244
  }
215
245
  }
216
246
  }
217
247
 
218
- .info-wrapper {
219
- &.is-summary {
248
+ .services-section__info-wrapper {
249
+ &.services-section__info-wrapper--summary {
220
250
  display: grid;
221
251
 
222
- &.align-start {
252
+ &.services-section__info-wrapper--align-start {
223
253
  align-content: start;
224
254
  }
225
- &.align-center {
255
+ &.services-section__info-wrapper--align-center {
226
256
  align-content: center;
227
257
  }
228
- &.align-end {
258
+ &.services-section__info-wrapper--align-end {
229
259
  align-content: end;
230
260
  }
231
261
  }
232
262
  }
233
263
 
234
- .image-wrapper {
264
+ .services-section__image-wrapper {
235
265
  align-self: start;
236
- border-radius: 8px;
266
+ border-radius: var(--services-section-image-border-radius, 0.8rem);
237
267
  overflow: hidden;
238
268
 
239
- .image {
269
+ .services-section__image {
240
270
  display: block;
241
271
  width: 100%;
242
272
  height: 100%;
@@ -244,45 +274,38 @@ watch(
244
274
  }
245
275
  }
246
276
 
247
- .price-duration {
277
+ .services-section__price-duration {
248
278
  display: flex;
249
279
  flex-direction: row;
250
- gap: 3rem;
280
+ gap: var(--services-section-price-duration-gap, 3rem);
251
281
  align-items: center;
252
- margin-block-end: 2rem;
282
+ margin-block-end: var(--services-section-price-duration-margin-block-end, 2rem);
253
283
 
254
284
  > div {
255
285
  display: flex;
256
286
  flex-direction: row;
257
- gap: 0.8rem;
287
+ gap: var(--services-section-price-duration-item-gap, 0.8rem);
258
288
  align-items: center;
259
289
 
260
- .decorator {
261
- width: 2rem;
262
- height: 2rem;
290
+ .services-section__decorator {
291
+ width: var(--services-section-decorator-size, 2rem);
292
+ height: var(--services-section-decorator-size, 2rem);
263
293
  }
264
294
  }
265
295
  }
266
296
 
267
- .glass-card {
268
- background: rgba(255, 255, 255, 0.6);
269
- border-radius: 1rem;
270
- backdrop-filter: blur(10px);
271
- }
272
-
273
- .services-faq {
274
- margin-block-end: 24px;
297
+ .services-section__faq {
298
+ margin-block-end: var(--services-section-faq-margin-block-end, 2.4rem);
275
299
 
276
300
  &.display-accordian {
277
301
  max-width: none;
278
- /* margin: 0; */
279
302
 
280
303
  .accordian-item.expanding-panel {
281
- border-block-end: 1px solid currentColor;
282
- opacity: 0.7;
304
+ border-block-end: 1px solid var(--services-section-faq-divider-color, currentColor);
305
+ opacity: var(--services-section-faq-divider-opacity, 0.7);
283
306
 
284
307
  &:first-child {
285
- border-block-start: 1px solid currentColor;
308
+ border-block-start: 1px solid var(--services-section-faq-divider-color, currentColor);
286
309
  }
287
310
 
288
311
  .expanding-panel-details .expanding-panel-summary {
@@ -290,7 +313,9 @@ watch(
290
313
  }
291
314
 
292
315
  .expanding-panel-content .inner {
293
- /* padding-block-end: 1.2rem; */
316
+ .services-section__faq-answer {
317
+ line-height: var(--services-section-faq-answer-line-height, 1.6);
318
+ }
294
319
  }
295
320
  }
296
321
  }