srcdev-nuxt-components 9.3.11 → 9.3.13

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 (77) hide show
  1. package/.claude/skills/component-aria-landmark.md +3 -3
  2. package/.claude/skills/components/auto-grid.md +1 -1
  3. package/.claude/skills/components/page-hero-highlights.md +1 -1
  4. package/.claude/skills/components/service-detail.md +13 -14
  5. package/.claude/skills/components/service-summary-grid.md +141 -0
  6. package/.claude/skills/components/service-summary.md +165 -0
  7. package/.claude/skills/icon-sets.md +1 -1
  8. package/.claude/skills/index.md +3 -2
  9. package/.claude/skills/theming-component-token-pattern.md +126 -0
  10. package/.vscode/srcdev-component-service-summary.code-snippets +67 -0
  11. package/app/assets/styles/setup/04.elements/forms/06.button-geometry.css +10 -1
  12. package/app/components/02.molecules/input-copy/CONSUMER-STYLING.md +3 -3
  13. package/app/components/02.molecules/input-copy/InputCopy.vue +9 -3
  14. package/app/components/02.molecules/pricing-card/CONSUMER-STYLING.md +3 -3
  15. package/app/components/02.molecules/pricing-card/PricingCard.vue +6 -3
  16. package/app/components/03.organisms/services/service-detail/CONSUMER-STYLING.md +7 -8
  17. package/app/components/03.organisms/services/service-detail/ServiceDetail.vue +0 -1
  18. package/app/components/03.organisms/services/service-detail/stories/ServiceDetail.stories.ts +2 -2
  19. package/app/components/03.organisms/services/service-summary/CONSUMER-STYLING.md +116 -0
  20. package/app/components/03.organisms/services/service-summary/ServiceSummary.vue +183 -0
  21. package/app/components/03.organisms/services/{services-section/stories/ServicesSection.stories.ts → service-summary/stories/ServiceSummary.stories.ts} +78 -148
  22. package/app/components/03.organisms/services/service-summary/tests/ServiceSummary.spec.ts +210 -0
  23. package/app/components/03.organisms/services/service-summary/tests/__snapshots__/ServiceSummary.spec.ts.snap +17 -0
  24. package/app/components/03.organisms/services/services-grids/{ServicesSectionGrid.vue → ServiceSummaryGrid.vue} +8 -10
  25. package/app/components/03.organisms/services/services-grids/stories/{ServicesSectionGrid.stories.ts → ServiceSummaryGrid.stories.ts} +21 -21
  26. package/app/components/03.organisms/services/services-grids/tests/{ServicesSectionGrid.spec.ts → ServiceSummaryGrid.spec.ts} +28 -28
  27. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServiceSummaryGrid.spec.ts.snap +45 -0
  28. package/app/components/05.forms/input-button/CONSUMER-STYLING.md +117 -0
  29. package/app/components/05.forms/input-button/InputButtonCore.vue +83 -37
  30. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/combo-icons-secondary-chromium-darwin.png +0 -0
  31. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/combo-icons-tertiary-chromium-darwin.png +0 -0
  32. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/combo-primary-pending-chromium-darwin.png +0 -0
  33. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/combo-secondary-pill-chromium-darwin.png +0 -0
  34. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/combo-tertiary-readonly-chromium-darwin.png +0 -0
  35. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/default-chromium-darwin.png +0 -0
  36. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/default-theme-default-chromium-darwin.png +0 -0
  37. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/default-theme-error-chromium-darwin.png +0 -0
  38. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/default-theme-success-chromium-darwin.png +0 -0
  39. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/default-theme-warning-chromium-darwin.png +0 -0
  40. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/default-variant-primary-chromium-darwin.png +0 -0
  41. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/default-variant-secondary-chromium-darwin.png +0 -0
  42. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/default-variant-tertiary-chromium-darwin.png +0 -0
  43. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/nuxt-icon-only-component-chromium-darwin.png +0 -0
  44. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/state-pending-chromium-darwin.png +0 -0
  45. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/state-pill-chromium-darwin.png +0 -0
  46. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/state-readonly-chromium-darwin.png +0 -0
  47. package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/with-both-nuxt-icon-components-chromium-darwin.png +0 -0
  48. package/app/components/05.forms/input-button/stories/InputButtonCore.stories.ts +35 -0
  49. package/app/components/05.forms/input-checkbox-radio/CONSUMER-STYLING.md +68 -0
  50. package/app/components/05.forms/input-checkbox-radio/InputCheckboxRadioButton.vue +19 -9
  51. package/app/components/05.forms/input-checkbox-radio/InputCheckboxRadioCore.vue +8 -5
  52. package/app/components/05.forms/input-checkbox-radio/stories/InputCheckboxRadioButton.stories.ts +93 -0
  53. package/app/components/05.forms/input-checkbox-radio/stories/InputCheckboxRadioCore.stories.ts +75 -0
  54. package/app/components/05.forms/input-number/CONSUMER-STYLING.md +41 -0
  55. package/app/components/05.forms/input-number/InputNumberCore.vue +12 -6
  56. package/app/components/05.forms/input-select/CONSUMER-STYLING.md +35 -29
  57. package/app/components/05.forms/input-select/InputSelectCore.vue +32 -20
  58. package/app/components/05.forms/input-select/stories/InputSelectCore.stories.ts +69 -0
  59. package/app/components/05.forms/input-text/CONSUMER-STYLING.md +38 -48
  60. package/app/components/05.forms/input-text/InputTextCore.vue +35 -18
  61. package/app/components/05.forms/patterns/stories/ContactForm.stories.ts +184 -0
  62. package/app/components/05.forms/patterns/stories/LoginForm.stories.ts +125 -0
  63. package/app/components/05.forms/toggle-switch/CONSUMER-STYLING.md +49 -0
  64. package/app/components/05.forms/toggle-switch/ToggleSwitchCore.vue +23 -16
  65. package/app/components/05.forms/triple-toggle-switch/CONSUMER-STYLING.md +72 -0
  66. package/app/components/05.forms/triple-toggle-switch/TripleToggleSwitchCore.vue +42 -20
  67. package/app/components/05.forms/triple-toggle-switch/stories/TripleToggleSwitchCore.stories.ts +63 -0
  68. package/app/components/display-theme-switch/DisplayThemeSwitch.vue +13 -6
  69. package/package.json +1 -1
  70. package/.claude/skills/components/services-section-grid.md +0 -130
  71. package/.claude/skills/components/services-section.md +0 -211
  72. package/.vscode/srcdev-component-services-section.code-snippets +0 -84
  73. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesSectionGrid.spec.ts.snap +0 -87
  74. package/app/components/03.organisms/services/services-section/CONSUMER-STYLING.md +0 -149
  75. package/app/components/03.organisms/services/services-section/ServicesSection.vue +0 -345
  76. package/app/components/03.organisms/services/services-section/tests/ServicesSection.spec.ts +0 -297
  77. package/app/components/03.organisms/services/services-section/tests/__snapshots__/ServicesSection.spec.ts.snap +0 -87
@@ -1,15 +1,14 @@
1
- import ServicesSection from "../ServicesSection.vue";
1
+ import ServiceSummary from "../ServiceSummary.vue";
2
2
  import PageRow from "../../../../01.atoms/page-row/PageRow.vue";
3
3
  import TextBlock from "../../../../01.atoms/text-block/TextBlock.vue";
4
4
  import EyebrowText from "../../../../01.atoms/text-blocks/eyebrow-text/EyebrowText.vue";
5
5
  import HeroText from "../../../../01.atoms/text-blocks/hero-text/HeroText.vue";
6
- import InputButtonCore from "../../../../05.forms/input-button/InputButtonCore.vue";
7
6
  import type { Meta, StoryObj } from "@nuxtjs/storybook";
8
7
  import type { Service } from "~/types/types.services";
9
8
 
10
- const meta: Meta<typeof ServicesSection> = {
11
- title: "Organisms/Services/Services Section",
12
- component: ServicesSection,
9
+ const meta: Meta<typeof ServiceSummary> = {
10
+ title: "Organisms/Services/Service Summary",
11
+ component: ServiceSummary,
13
12
  argTypes: {
14
13
  tag: {
15
14
  control: { type: "select" },
@@ -21,51 +20,15 @@ const meta: Meta<typeof ServicesSection> = {
21
20
  options: ["h1", "h2", "h3"],
22
21
  description: "Heading element used for the service title",
23
22
  },
24
- isSummary: {
25
- control: { type: "boolean" },
26
- description: "Summary mode — compact view with summary-link slot",
27
- },
28
- summaryAlignment: {
23
+ alignment: {
29
24
  control: { type: "select" },
30
25
  options: ["start", "center", "end"],
31
- description: "Vertical alignment of the info column in summary mode",
26
+ description: "Vertical alignment of the info column relative to the image",
32
27
  },
33
28
  reverse: {
34
29
  control: { type: "boolean" },
35
30
  description: "Swap image and content columns",
36
31
  },
37
- durationIcon: {
38
- control: { type: "text" },
39
- description: "Icon name for the duration row (any Iconify icon)",
40
- },
41
- priceIcon: {
42
- control: { type: "text" },
43
- description: "Icon name for the price row (any Iconify icon)",
44
- },
45
- processHeading: {
46
- control: { type: "text" },
47
- description: "Heading text for the process section",
48
- },
49
- idealForHeading: {
50
- control: { type: "text" },
51
- description: "Heading text for the ideal-for section",
52
- },
53
- maintenanceHeading: {
54
- control: { type: "text" },
55
- description: "Heading text for the maintenance/aftercare section",
56
- },
57
- faqsHeading: {
58
- control: { type: "text" },
59
- description: "Heading text for the FAQs section",
60
- },
61
- ctaHeading: {
62
- control: { type: "text" },
63
- description: "Heading text inside the default CTA glass panel — ignored if the cta-panel slot is used",
64
- },
65
- ctaBody: {
66
- control: { type: "text" },
67
- description: "Body text inside the default CTA glass panel — ignored if the cta-panel slot is used",
68
- },
69
32
  styleClassPassthrough: {
70
33
  control: "object",
71
34
  description: "Additional CSS classes applied to the root element",
@@ -74,25 +37,22 @@ const meta: Meta<typeof ServicesSection> = {
74
37
  args: {
75
38
  tag: "section",
76
39
  headerTag: "h2",
77
- isSummary: false,
78
- summaryAlignment: "center",
40
+ alignment: "center",
79
41
  reverse: false,
80
- durationIcon: "mdi:clock-time-four-outline",
81
- priceIcon: "mdi:currency-gbp",
82
42
  styleClassPassthrough: [],
83
43
  },
84
44
  parameters: {
85
45
  docs: {
86
46
  description: {
87
47
  component:
88
- "Renders a single service as a two-column section: image on one side, detailed content on the other. Two modes: summary (compact, with navigation slot) and full (complete content with CTA slot). Icon names for the price/duration row are configurable via `durationIcon` and `priceIcon` props. Section headings and the CTA panel copy are configurable via props (`processHeading`, `idealForHeading`, `maintenanceHeading`, `faqsHeading`, `ctaHeading`, `ctaBody`); the whole CTA panel can be replaced with the `cta-panel` scoped slot.",
48
+ "Renders a single service as a compact two-column preview: image on one side, eyebrow/title/price-duration pills/summary text on the other, with a summary-link slot for navigating to the full ServiceDetail page. Superseded ServicesSection's old full mode — a ServiceDetail page is the correct place for process/ideal-for/FAQ/CTA content now.",
89
49
  },
90
50
  },
91
51
  },
92
52
  };
93
53
 
94
54
  export default meta;
95
- type Story = StoryObj<typeof ServicesSection>;
55
+ type Story = StoryObj<typeof ServiceSummary>;
96
56
 
97
57
  // ─── Fixtures ─────────────────────────────────────────────────────────────────
98
58
 
@@ -153,136 +113,138 @@ const sampleService: Service = {
153
113
 
154
114
  // ─── Stories ──────────────────────────────────────────────────────────────────
155
115
 
156
- export const FullMode: Story = {
157
- name: "Full Mode",
158
- args: {
159
- ctaHeading: "Ready to book your appointment?",
160
- ctaBody: "Mobile service across Bath — I come to you.",
161
- },
116
+ export const Default: Story = {
117
+ name: "Default",
162
118
  render: (args) => ({
163
- components: { ServicesSection, InputButtonCore },
119
+ components: { ServiceSummary },
164
120
  setup() {
165
121
  return { args, sampleService };
166
122
  },
167
123
  template: `
168
- <ServicesSection v-bind="args" :service-data="sampleService">
169
- <template #cta>
170
- <InputButtonCore tag="a" href="/contact" button-text="Book now" variant="primary" />
124
+ <ServiceSummary v-bind="args" :service-data="sampleService">
125
+ <template #summary-link="{ serviceData }">
126
+ <a :href="'/services/' + serviceData.slug" style="color:inherit;">
127
+ More about {{ serviceData.title }} →
128
+ </a>
171
129
  </template>
172
- </ServicesSection>
130
+ </ServiceSummary>
173
131
  `,
174
132
  }),
175
133
  parameters: {
176
134
  docs: {
177
135
  description: {
178
- story:
179
- "Full mode (isSummary: false) — renders all content including a six-step process, ideal-for list, FAQs, and the CTA panel, matching the real service detail page content shape.",
136
+ story: "Eyebrow, title, price/duration pills, whatIsIt summary, and the summary-link slot.",
180
137
  },
181
138
  },
182
139
  },
183
140
  };
184
141
 
185
- export const SummaryMode: Story = {
186
- name: "Summary Mode",
142
+ export const Reversed: Story = {
143
+ name: "Reversed Layout",
187
144
  args: {
188
- isSummary: true,
145
+ reverse: true,
189
146
  },
190
147
  render: (args) => ({
191
- components: { ServicesSection },
148
+ components: { ServiceSummary },
192
149
  setup() {
193
150
  return { args, sampleService };
194
151
  },
195
- template: `
196
- <ServicesSection v-bind="args" :service-data="sampleService">
197
- <template #summary-link="{ serviceData }">
198
- <a :href="'/services/' + serviceData.slug" style="color:inherit;">
199
- More about {{ serviceData.title }} →
200
- </a>
201
- </template>
202
- </ServicesSection>
203
- `,
152
+ template: `<ServiceSummary v-bind="args" :service-data="sampleService" />`,
204
153
  }),
205
154
  parameters: {
206
155
  docs: {
207
156
  description: {
208
- story: "Summary mode — compact view with eyebrow, title, price/duration, whatIsIt, and summary-link slot.",
157
+ story: "Image on the right, content on the left — use on alternating items in a list.",
209
158
  },
210
159
  },
211
160
  },
212
161
  };
213
162
 
214
- export const Reversed: Story = {
215
- name: "Reversed Layout",
216
- args: {
217
- reverse: true,
218
- },
163
+ export const FixedRowHeight: Story = {
164
+ name: "Fixed Row Height",
219
165
  render: (args) => ({
220
- components: { ServicesSection },
166
+ components: { ServiceSummary },
221
167
  setup() {
222
168
  return { args, sampleService };
223
169
  },
224
- template: `<ServicesSection v-bind="args" :service-data="sampleService" />`,
170
+ template: `
171
+ <div style="--service-summary-height-mobile: 24rem; --service-summary-height-tablet: 28rem; --service-summary-height-desktop: 32rem;">
172
+ <ServiceSummary v-bind="args" :service-data="sampleService">
173
+ <template #summary-link="{ serviceData }">
174
+ <a :href="'/services/' + serviceData.slug" style="color:inherit;">
175
+ More about {{ serviceData.title }} →
176
+ </a>
177
+ </template>
178
+ </ServiceSummary>
179
+ </div>
180
+ `,
225
181
  }),
226
182
  parameters: {
227
183
  docs: {
228
184
  description: {
229
- story: "Image on the right, content on the left — use on alternating items in a list.",
185
+ story:
186
+ "--service-summary-height-mobile/-tablet/-desktop set a fixed height for the whole row (both the image and the text column) per @container breakpoint, instead of the default auto — the image crops via object-fit: cover to fill it (--service-summary-image-aspect-ratio no longer has any visible effect once this is set), and the text column gets real leftover space for the alignment prop to position within. Useful for keeping every row in a list a consistent height regardless of each service's source photo dimensions.",
230
187
  },
231
188
  },
232
189
  },
233
190
  };
234
191
 
235
- export const CustomIcons: Story = {
236
- name: "Custom Icons",
192
+ export const CustomAspectRatio: Story = {
193
+ name: "Custom Aspect Ratio",
237
194
  args: {
238
- durationIcon: "mdi:timer-outline",
239
- priceIcon: "mdi:currency-eur",
195
+ alignment: "start",
240
196
  },
241
197
  render: (args) => ({
242
- components: { ServicesSection },
198
+ components: { ServiceSummary },
243
199
  setup() {
244
200
  return { args, sampleService };
245
201
  },
246
- template: `<ServicesSection v-bind="args" :service-data="sampleService" />`,
202
+ template: `
203
+ <div style="--service-summary-image-aspect-ratio: 3 / 4;">
204
+ <ServiceSummary v-bind="args" :service-data="sampleService">
205
+ <template #summary-link="{ serviceData }">
206
+ <a :href="'/services/' + serviceData.slug" style="color:inherit;">
207
+ More about {{ serviceData.title }} →
208
+ </a>
209
+ </template>
210
+ </ServiceSummary>
211
+ </div>
212
+ `,
247
213
  }),
248
214
  parameters: {
249
215
  docs: {
250
216
  description: {
251
217
  story:
252
- "Custom icon names passed via durationIcon and priceIcon props — useful when the consuming app uses a different currency or wants a different clock style.",
218
+ "--service-summary-image-aspect-ratio (default 1 / 1, square) controls the image's own shape when the row height is left auto (the default) — here overridden to a taller 3 / 4 portrait crop. This token only has a visible effect while no --service-summary-height-* override forces a definite row height.",
253
219
  },
254
220
  },
255
221
  },
256
222
  };
257
223
 
258
- export const CustomTextAndCta: Story = {
259
- name: "Custom Headings & CTA Copy",
260
- args: {
261
- processHeading: "How It Works",
262
- idealForHeading: "Who Is This For",
263
- maintenanceHeading: "Aftercare",
264
- faqsHeading: "FAQs",
265
- ctaHeading: "Ready to book your colour appointment?",
266
- ctaBody: "Mobile service across Bath — I come to you.",
267
- },
224
+ export const FramedImage: Story = {
225
+ name: "Framed Image (padding)",
268
226
  render: (args) => ({
269
- components: { ServicesSection, InputButtonCore },
227
+ components: { ServiceSummary },
270
228
  setup() {
271
229
  return { args, sampleService };
272
230
  },
273
231
  template: `
274
- <ServicesSection v-bind="args" :service-data="sampleService">
275
- <template #cta>
276
- <InputButtonCore tag="a" href="/contact" button-text="Book now" variant="primary" />
277
- </template>
278
- </ServicesSection>
232
+ <div style="--service-summary-image-padding-block-mobile: 1.6rem; --service-summary-image-padding-inline-mobile: 1.6rem; background: #1a1a1a;">
233
+ <ServiceSummary v-bind="args" :service-data="sampleService">
234
+ <template #summary-link="{ serviceData }">
235
+ <a :href="'/services/' + serviceData.slug" style="color:inherit;">
236
+ More about {{ serviceData.title }} →
237
+ </a>
238
+ </template>
239
+ </ServiceSummary>
240
+ </div>
279
241
  `,
280
242
  }),
281
243
  parameters: {
282
244
  docs: {
283
245
  description: {
284
246
  story:
285
- "Section headings and CTA panel copy overridden via props — useful for wording specific to the consuming business rather than the library defaults. Note the heading names the actual service, avoiding the old bug where every service page said 'highlights appointment' regardless of which service it was.",
247
+ "--service-summary-image-padding-block-*/-inline-* insets the image within .service-summary__image-wrapper instead of letting it fill edge-to-edge — the wrapper's own background shows through as a frame. Combine with a background colour set on the wrapper (via styleClassPassthrough) for a card-like look.",
286
248
  },
287
249
  },
288
250
  },
@@ -290,12 +252,8 @@ export const CustomTextAndCta: Story = {
290
252
 
291
253
  export const RealisticPageContext: Story = {
292
254
  name: "Realistic Page Context",
293
- args: {
294
- ctaHeading: "Ready to book your appointment?",
295
- ctaBody: "Mobile service across Bath — I come to you.",
296
- },
297
255
  render: (args) => ({
298
- components: { ServicesSection, PageRow, TextBlock, EyebrowText, HeroText, InputButtonCore },
256
+ components: { ServiceSummary, PageRow, TextBlock, EyebrowText, HeroText },
299
257
  setup() {
300
258
  return { args, sampleService };
301
259
  },
@@ -319,11 +277,13 @@ export const RealisticPageContext: Story = {
319
277
  </PageRow>
320
278
 
321
279
  <PageRow tag="div" variant="content" :style-class-passthrough="['mbe-20']">
322
- <ServicesSection v-bind="args" :service-data="sampleService">
323
- <template #cta>
324
- <InputButtonCore tag="a" href="/contact" button-text="Book now" variant="primary" />
280
+ <ServiceSummary v-bind="args" :service-data="sampleService">
281
+ <template #summary-link="{ serviceData }">
282
+ <a :href="'/services/' + serviceData.slug" style="color:inherit;">
283
+ More about {{ serviceData.title }} →
284
+ </a>
325
285
  </template>
326
- </ServicesSection>
286
+ </ServiceSummary>
327
287
  </PageRow>
328
288
  </div>
329
289
  `,
@@ -332,37 +292,7 @@ export const RealisticPageContext: Story = {
332
292
  docs: {
333
293
  description: {
334
294
  story:
335
- "Matches the real service detail page (app/pages/services/[slug].vue in the luxury-locs-by-natasha-nuxt3 project): a page hero (EyebrowText + HeroText inside PageRow/TextBlock) followed by ServicesSection with a real 'Book now' CTA button. Use this story to sanity-check layout and spacing changes against the actual consuming page rather than the bare component.",
336
- },
337
- },
338
- },
339
- };
340
-
341
- export const CustomCtaPanel: Story = {
342
- name: "Custom CTA Panel (slot override)",
343
- render: (args) => ({
344
- components: { ServicesSection },
345
- setup() {
346
- return { args, sampleService };
347
- },
348
- template: `
349
- <ServicesSection v-bind="args" :service-data="sampleService">
350
- <template #cta-panel="{ serviceData }">
351
- <div style="padding:2.4rem;border:2px dashed #333;border-radius:1rem;text-align:center;">
352
- <p style="margin:0 0 1.2rem;">Fully custom CTA block for {{ serviceData.title }} — not a GlassPanel at all.</p>
353
- <button style="padding:1.2rem 2.4rem;background:#333;color:#fff;border:none;border-radius:0.4rem;cursor:pointer;">
354
- Enquire Now
355
- </button>
356
- </div>
357
- </template>
358
- </ServicesSection>
359
- `,
360
- }),
361
- parameters: {
362
- docs: {
363
- description: {
364
- story:
365
- "The cta-panel scoped slot replaces the default GlassPanel entirely — use when a consumer needs a different component or layout for the closing call-to-action, not just different copy.",
295
+ "Matches the real services listing page (app/pages/services/index.vue in the luxury-locs-by-natasha-nuxt3 project): a page hero (EyebrowText + HeroText inside PageRow/TextBlock) followed by a ServiceSummary. Use ServiceSummaryGrid for the full alternating listing — this story sanity-checks a single instance's layout in context.",
366
296
  },
367
297
  },
368
298
  },
@@ -0,0 +1,210 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { mountSuspended } from "@nuxt/test-utils/runtime";
3
+ import ServiceSummary from "../ServiceSummary.vue";
4
+ import type { Service } from "~/types/types.services";
5
+
6
+ // ─── Fixtures ─────────────────────────────────────────────────────────────────
7
+
8
+ const mockService: Service = {
9
+ slug: "test-service",
10
+ category: "hair",
11
+ title: "Test Service",
12
+ subtitle: "A subtitle",
13
+ price: "£50",
14
+ duration: "60 mins",
15
+ image: "/images/test.jpg",
16
+ shortDescription: "Short description",
17
+ longDescription: "Long description text",
18
+ heroHeading: [{ text: "A great heading", styleClass: "normal" }],
19
+ whatIsIt: "What this service is",
20
+ process: ["Step one", "Step two"],
21
+ idealFor: ["Person A", "Person B"],
22
+ maintenance: "Maintenance advice",
23
+ faqs: [{ question: "FAQ question?", answer: "FAQ answer." }],
24
+ seoTitle: "SEO Title",
25
+ seoDescription: "SEO description",
26
+ };
27
+
28
+ // ─── Tests ────────────────────────────────────────────────────────────────────
29
+
30
+ describe("ServiceSummary", () => {
31
+ // ─── Mount ─────────────────────────────────────────────────────────────
32
+
33
+ it("mounts without error", async () => {
34
+ const wrapper = await mountSuspended(ServiceSummary, {
35
+ props: { serviceData: mockService },
36
+ });
37
+ expect(wrapper.vm).toBeTruthy();
38
+ });
39
+
40
+ it("renders correct HTML structure", async () => {
41
+ const wrapper = await mountSuspended(ServiceSummary, {
42
+ props: { serviceData: mockService },
43
+ });
44
+ expect(wrapper.html()).toMatchSnapshot();
45
+ });
46
+
47
+ // ─── Root element ───────────────────────────────────────────────────────
48
+
49
+ it("has service-summary class on root", async () => {
50
+ const wrapper = await mountSuspended(ServiceSummary, {
51
+ props: { serviceData: mockService },
52
+ });
53
+ expect(wrapper.classes()).toContain("service-summary");
54
+ });
55
+
56
+ it("renders as a div by default", async () => {
57
+ const wrapper = await mountSuspended(ServiceSummary, {
58
+ props: { serviceData: mockService },
59
+ });
60
+ expect(wrapper.element.tagName).toBe("DIV");
61
+ });
62
+
63
+ it("renders the tag prop as the root element", async () => {
64
+ const wrapper = await mountSuspended(ServiceSummary, {
65
+ props: { serviceData: mockService, tag: "section" },
66
+ });
67
+ expect(wrapper.element.tagName).toBe("SECTION");
68
+ });
69
+
70
+ // ─── Aria ───────────────────────────────────────────────────────────────
71
+
72
+ it("binds aria-labelledby to the title HeroText's own id when tag is section", async () => {
73
+ const wrapper = await mountSuspended(ServiceSummary, {
74
+ props: { serviceData: mockService, tag: "section" },
75
+ attachTo: document.body,
76
+ });
77
+ const ariaLabelledby = wrapper.attributes("aria-labelledby");
78
+ expect(ariaLabelledby).toBeTruthy();
79
+ expect(document.getElementById(ariaLabelledby!)?.textContent).toContain(mockService.title);
80
+ });
81
+
82
+ // ─── Pills ──────────────────────────────────────────────────────────────
83
+
84
+ it("renders duration and price as DisplayPill instances", async () => {
85
+ const wrapper = await mountSuspended(ServiceSummary, {
86
+ props: { serviceData: mockService },
87
+ });
88
+ const pills = wrapper.findAll(".display-pill");
89
+ expect(pills).toHaveLength(2);
90
+ expect(pills[0]?.text()).toContain(mockService.duration);
91
+ expect(pills[1]?.text()).toContain(mockService.price);
92
+ });
93
+
94
+ // ─── Service data ───────────────────────────────────────────────────────
95
+
96
+ it("renders service duration text", async () => {
97
+ const wrapper = await mountSuspended(ServiceSummary, {
98
+ props: { serviceData: mockService },
99
+ });
100
+ expect(wrapper.text()).toContain(mockService.duration);
101
+ });
102
+
103
+ it("renders service price text", async () => {
104
+ const wrapper = await mountSuspended(ServiceSummary, {
105
+ props: { serviceData: mockService },
106
+ });
107
+ expect(wrapper.text()).toContain(mockService.price);
108
+ });
109
+
110
+ it("renders the whatIsIt text", async () => {
111
+ const wrapper = await mountSuspended(ServiceSummary, {
112
+ props: { serviceData: mockService },
113
+ });
114
+ expect(wrapper.text()).toContain(mockService.whatIsIt);
115
+ });
116
+
117
+ it("does not render process, idealFor, maintenance, or FAQ content", async () => {
118
+ const wrapper = await mountSuspended(ServiceSummary, {
119
+ props: { serviceData: mockService },
120
+ });
121
+ expect(wrapper.text()).not.toContain(mockService.longDescription);
122
+ expect(wrapper.text()).not.toContain(mockService.maintenance);
123
+ expect(wrapper.text()).not.toContain(mockService.faqs[0]?.question);
124
+ });
125
+
126
+ // ─── summary-link slot ────────────────────────────────────────────────
127
+
128
+ it("renders summary-link slot content", async () => {
129
+ const wrapper = await mountSuspended(ServiceSummary, {
130
+ props: { serviceData: mockService },
131
+ slots: {
132
+ "summary-link": '<a class="test-link" href="/services/test">View service</a>',
133
+ },
134
+ });
135
+ expect(wrapper.find(".test-link").exists()).toBe(true);
136
+ });
137
+
138
+ it("passes serviceData to the summary-link slot", async () => {
139
+ const wrapper = await mountSuspended(ServiceSummary, {
140
+ props: { serviceData: mockService },
141
+ slots: {
142
+ "summary-link": `<template #summary-link="{ serviceData }"><a class="test-link">{{ serviceData.title }}</a></template>`,
143
+ },
144
+ });
145
+ expect(wrapper.find(".test-link").text()).toBe(mockService.title);
146
+ });
147
+
148
+ // ─── Reverse ────────────────────────────────────────────────────────────
149
+
150
+ it("applies reverse class when reverse prop is true", async () => {
151
+ const wrapper = await mountSuspended(ServiceSummary, {
152
+ props: { serviceData: mockService, reverse: true },
153
+ });
154
+ expect(wrapper.find(".service-summary__grid").classes()).toContain("service-summary__grid--reverse");
155
+ });
156
+
157
+ it("does not apply reverse class by default", async () => {
158
+ const wrapper = await mountSuspended(ServiceSummary, {
159
+ props: { serviceData: mockService },
160
+ });
161
+ expect(wrapper.find(".service-summary__grid").classes()).not.toContain("service-summary__grid--reverse");
162
+ });
163
+
164
+ // ─── Alignment ──────────────────────────────────────────────────────────
165
+
166
+ it("defaults to center alignment", async () => {
167
+ const wrapper = await mountSuspended(ServiceSummary, {
168
+ props: { serviceData: mockService },
169
+ });
170
+ expect(wrapper.find(".service-summary__info-wrapper").classes()).toContain(
171
+ "service-summary__info-wrapper--align-center"
172
+ );
173
+ });
174
+
175
+ it("applies the alignment prop", async () => {
176
+ const wrapper = await mountSuspended(ServiceSummary, {
177
+ props: { serviceData: mockService, alignment: "start" },
178
+ });
179
+ expect(wrapper.find(".service-summary__info-wrapper").classes()).toContain(
180
+ "service-summary__info-wrapper--align-start"
181
+ );
182
+ });
183
+
184
+ // ─── styleClassPassthrough ──────────────────────────────────────────────
185
+
186
+ it("applies a single styleClassPassthrough string", async () => {
187
+ const wrapper = await mountSuspended(ServiceSummary, {
188
+ props: { serviceData: mockService, styleClassPassthrough: "custom-class" },
189
+ });
190
+ expect(wrapper.classes()).toContain("custom-class");
191
+ });
192
+
193
+ it("applies multiple styleClassPassthrough classes from an array", async () => {
194
+ const wrapper = await mountSuspended(ServiceSummary, {
195
+ props: { serviceData: mockService, styleClassPassthrough: ["class-a", "class-b"] },
196
+ });
197
+ expect(wrapper.classes()).toContain("class-a");
198
+ expect(wrapper.classes()).toContain("class-b");
199
+ });
200
+
201
+ it("updates classes when styleClassPassthrough prop changes", async () => {
202
+ const wrapper = await mountSuspended(ServiceSummary, {
203
+ props: { serviceData: mockService, styleClassPassthrough: ["original"] },
204
+ });
205
+ expect(wrapper.classes()).toContain("original");
206
+ await wrapper.setProps({ styleClassPassthrough: ["updated"] });
207
+ expect(wrapper.classes()).not.toContain("original");
208
+ expect(wrapper.classes()).toContain("updated");
209
+ });
210
+ });
@@ -0,0 +1,17 @@
1
+ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
+
3
+ exports[`ServiceSummary > renders correct HTML structure 1`] = `
4
+ "<div class="service-summary">
5
+ <div class="service-summary__grid">
6
+ <div class="service-summary__image-wrapper"><img data-nuxt-img="" srcset="/_ipx/_/images/test.jpg 1x, /_ipx/_/images/test.jpg 2x" alt="Test Service" loading="eager" fetchpriority="high" class="service-summary__image" src="/_ipx/_/images/test.jpg"></div>
7
+ <div class="service-summary__info-wrapper service-summary__info-wrapper--align-center">
8
+ <div class="eyebrow-text large">A subtitle</div>
9
+ <h2 id="v-0-0" class="hero-text mb-20 title axis-horizontal">
10
+ <!--v-if--><span class="text-block-0 normal">Test Service</span>
11
+ </h2>
12
+ <div class="service-summary__pills"><span class="display-pill md neutral"><span class="pill-label">60 mins</span></span><span class="display-pill md neutral"><span class="pill-label">From £50</span></span></div>
13
+ <p class="page-body-normal">What this service is</p>
14
+ </div>
15
+ </div>
16
+ </div>"
17
+ `;
@@ -1,18 +1,17 @@
1
1
  <template>
2
- <component :is="tag" class="services-grid" :class="[elementClasses]">
3
- <ServicesSection
2
+ <component :is="tag" class="service-summary-grid" :class="[elementClasses]">
3
+ <ServiceSummary
4
4
  v-for="(item, index) in servicesData"
5
5
  :key="index"
6
6
  :index="index"
7
7
  :service-data="item"
8
- :is-summary="true"
9
8
  :reverse="props.useAlternateReverse ? index % 2 !== 0 : false"
10
- :summary-alignment="summaryAlignment"
9
+ :alignment="alignment"
11
10
  >
12
11
  <template #summary-link="{ serviceData }">
13
12
  <slot name="summary-link" :service-data="serviceData"></slot>
14
13
  </template>
15
- </ServicesSection>
14
+ </ServiceSummary>
16
15
  </component>
17
16
  </template>
18
17
 
@@ -23,14 +22,14 @@ interface Props {
23
22
  tag?: "div" | "section" | "main";
24
23
  servicesData: Service[];
25
24
  useAlternateReverse?: boolean;
26
- summaryAlignment?: "start" | "center" | "end";
25
+ alignment?: "start" | "center" | "end";
27
26
  styleClassPassthrough?: string | string[];
28
27
  }
29
28
 
30
29
  const props = withDefaults(defineProps<Props>(), {
31
30
  tag: "div",
32
31
  useAlternateReverse: false,
33
- summaryAlignment: "center",
32
+ alignment: "center",
34
33
  styleClassPassthrough: () => [],
35
34
  });
36
35
 
@@ -46,11 +45,10 @@ watch(
46
45
 
47
46
  <style lang="css">
48
47
  @layer components {
49
- .services-grid {
50
- /* Component styles */
48
+ .service-summary-grid {
51
49
  display: grid;
52
50
  grid-template-columns: 1fr;
53
- gap: 4rem;
51
+ gap: var(--service-summary-grid-row-gap, 4rem);
54
52
  }
55
53
  }
56
54
  </style>