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.
- package/.claude/skills/component-aria-landmark.md +3 -3
- package/.claude/skills/components/auto-grid.md +1 -1
- package/.claude/skills/components/page-hero-highlights.md +1 -1
- package/.claude/skills/components/service-detail.md +13 -14
- package/.claude/skills/components/service-summary-grid.md +141 -0
- package/.claude/skills/components/service-summary.md +165 -0
- package/.claude/skills/icon-sets.md +1 -1
- package/.claude/skills/index.md +3 -2
- package/.claude/skills/theming-component-token-pattern.md +126 -0
- package/.vscode/srcdev-component-service-summary.code-snippets +67 -0
- package/app/assets/styles/setup/04.elements/forms/06.button-geometry.css +10 -1
- package/app/components/02.molecules/input-copy/CONSUMER-STYLING.md +3 -3
- package/app/components/02.molecules/input-copy/InputCopy.vue +9 -3
- package/app/components/02.molecules/pricing-card/CONSUMER-STYLING.md +3 -3
- package/app/components/02.molecules/pricing-card/PricingCard.vue +6 -3
- package/app/components/03.organisms/services/service-detail/CONSUMER-STYLING.md +7 -8
- package/app/components/03.organisms/services/service-detail/ServiceDetail.vue +0 -1
- package/app/components/03.organisms/services/service-detail/stories/ServiceDetail.stories.ts +2 -2
- package/app/components/03.organisms/services/service-summary/CONSUMER-STYLING.md +116 -0
- package/app/components/03.organisms/services/service-summary/ServiceSummary.vue +183 -0
- package/app/components/03.organisms/services/{services-section/stories/ServicesSection.stories.ts → service-summary/stories/ServiceSummary.stories.ts} +78 -148
- package/app/components/03.organisms/services/service-summary/tests/ServiceSummary.spec.ts +210 -0
- package/app/components/03.organisms/services/service-summary/tests/__snapshots__/ServiceSummary.spec.ts.snap +17 -0
- package/app/components/03.organisms/services/services-grids/{ServicesSectionGrid.vue → ServiceSummaryGrid.vue} +8 -10
- package/app/components/03.organisms/services/services-grids/stories/{ServicesSectionGrid.stories.ts → ServiceSummaryGrid.stories.ts} +21 -21
- package/app/components/03.organisms/services/services-grids/tests/{ServicesSectionGrid.spec.ts → ServiceSummaryGrid.spec.ts} +28 -28
- package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServiceSummaryGrid.spec.ts.snap +45 -0
- package/app/components/05.forms/input-button/CONSUMER-STYLING.md +117 -0
- package/app/components/05.forms/input-button/InputButtonCore.vue +83 -37
- package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/combo-icons-secondary-chromium-darwin.png +0 -0
- package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/combo-icons-tertiary-chromium-darwin.png +0 -0
- package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/combo-primary-pending-chromium-darwin.png +0 -0
- package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/combo-secondary-pill-chromium-darwin.png +0 -0
- package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/combo-tertiary-readonly-chromium-darwin.png +0 -0
- package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/default-chromium-darwin.png +0 -0
- package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/default-theme-default-chromium-darwin.png +0 -0
- package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/default-theme-error-chromium-darwin.png +0 -0
- package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/default-theme-success-chromium-darwin.png +0 -0
- package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/default-theme-warning-chromium-darwin.png +0 -0
- package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/default-variant-primary-chromium-darwin.png +0 -0
- package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/default-variant-secondary-chromium-darwin.png +0 -0
- package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/default-variant-tertiary-chromium-darwin.png +0 -0
- package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/nuxt-icon-only-component-chromium-darwin.png +0 -0
- package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/state-pending-chromium-darwin.png +0 -0
- package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/state-pill-chromium-darwin.png +0 -0
- package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/state-readonly-chromium-darwin.png +0 -0
- package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/with-both-nuxt-icon-components-chromium-darwin.png +0 -0
- package/app/components/05.forms/input-button/stories/InputButtonCore.stories.ts +35 -0
- package/app/components/05.forms/input-checkbox-radio/CONSUMER-STYLING.md +68 -0
- package/app/components/05.forms/input-checkbox-radio/InputCheckboxRadioButton.vue +19 -9
- package/app/components/05.forms/input-checkbox-radio/InputCheckboxRadioCore.vue +8 -5
- package/app/components/05.forms/input-checkbox-radio/stories/InputCheckboxRadioButton.stories.ts +93 -0
- package/app/components/05.forms/input-checkbox-radio/stories/InputCheckboxRadioCore.stories.ts +75 -0
- package/app/components/05.forms/input-number/CONSUMER-STYLING.md +41 -0
- package/app/components/05.forms/input-number/InputNumberCore.vue +12 -6
- package/app/components/05.forms/input-select/CONSUMER-STYLING.md +35 -29
- package/app/components/05.forms/input-select/InputSelectCore.vue +32 -20
- package/app/components/05.forms/input-select/stories/InputSelectCore.stories.ts +69 -0
- package/app/components/05.forms/input-text/CONSUMER-STYLING.md +38 -48
- package/app/components/05.forms/input-text/InputTextCore.vue +35 -18
- package/app/components/05.forms/patterns/stories/ContactForm.stories.ts +184 -0
- package/app/components/05.forms/patterns/stories/LoginForm.stories.ts +125 -0
- package/app/components/05.forms/toggle-switch/CONSUMER-STYLING.md +49 -0
- package/app/components/05.forms/toggle-switch/ToggleSwitchCore.vue +23 -16
- package/app/components/05.forms/triple-toggle-switch/CONSUMER-STYLING.md +72 -0
- package/app/components/05.forms/triple-toggle-switch/TripleToggleSwitchCore.vue +42 -20
- package/app/components/05.forms/triple-toggle-switch/stories/TripleToggleSwitchCore.stories.ts +63 -0
- package/app/components/display-theme-switch/DisplayThemeSwitch.vue +13 -6
- package/package.json +1 -1
- package/.claude/skills/components/services-section-grid.md +0 -130
- package/.claude/skills/components/services-section.md +0 -211
- package/.vscode/srcdev-component-services-section.code-snippets +0 -84
- package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesSectionGrid.spec.ts.snap +0 -87
- package/app/components/03.organisms/services/services-section/CONSUMER-STYLING.md +0 -149
- package/app/components/03.organisms/services/services-section/ServicesSection.vue +0 -345
- package/app/components/03.organisms/services/services-section/tests/ServicesSection.spec.ts +0 -297
- package/app/components/03.organisms/services/services-section/tests/__snapshots__/ServicesSection.spec.ts.snap +0 -87
|
@@ -1,15 +1,14 @@
|
|
|
1
|
-
import
|
|
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
|
|
11
|
-
title: "Organisms/Services/
|
|
12
|
-
component:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
157
|
-
name: "
|
|
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: {
|
|
119
|
+
components: { ServiceSummary },
|
|
164
120
|
setup() {
|
|
165
121
|
return { args, sampleService };
|
|
166
122
|
},
|
|
167
123
|
template: `
|
|
168
|
-
<
|
|
169
|
-
<template #
|
|
170
|
-
<
|
|
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
|
-
</
|
|
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
|
|
186
|
-
name: "
|
|
142
|
+
export const Reversed: Story = {
|
|
143
|
+
name: "Reversed Layout",
|
|
187
144
|
args: {
|
|
188
|
-
|
|
145
|
+
reverse: true,
|
|
189
146
|
},
|
|
190
147
|
render: (args) => ({
|
|
191
|
-
components: {
|
|
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: "
|
|
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
|
|
215
|
-
name: "
|
|
216
|
-
args: {
|
|
217
|
-
reverse: true,
|
|
218
|
-
},
|
|
163
|
+
export const FixedRowHeight: Story = {
|
|
164
|
+
name: "Fixed Row Height",
|
|
219
165
|
render: (args) => ({
|
|
220
|
-
components: {
|
|
166
|
+
components: { ServiceSummary },
|
|
221
167
|
setup() {
|
|
222
168
|
return { args, sampleService };
|
|
223
169
|
},
|
|
224
|
-
template:
|
|
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:
|
|
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
|
|
236
|
-
name: "Custom
|
|
192
|
+
export const CustomAspectRatio: Story = {
|
|
193
|
+
name: "Custom Aspect Ratio",
|
|
237
194
|
args: {
|
|
238
|
-
|
|
239
|
-
priceIcon: "mdi:currency-eur",
|
|
195
|
+
alignment: "start",
|
|
240
196
|
},
|
|
241
197
|
render: (args) => ({
|
|
242
|
-
components: {
|
|
198
|
+
components: { ServiceSummary },
|
|
243
199
|
setup() {
|
|
244
200
|
return { args, sampleService };
|
|
245
201
|
},
|
|
246
|
-
template:
|
|
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
|
-
"
|
|
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
|
|
259
|
-
name: "
|
|
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: {
|
|
227
|
+
components: { ServiceSummary },
|
|
270
228
|
setup() {
|
|
271
229
|
return { args, sampleService };
|
|
272
230
|
},
|
|
273
231
|
template: `
|
|
274
|
-
<
|
|
275
|
-
<
|
|
276
|
-
<
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
"
|
|
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: {
|
|
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
|
-
<
|
|
323
|
-
<template #
|
|
324
|
-
<
|
|
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
|
-
</
|
|
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
|
|
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="
|
|
3
|
-
<
|
|
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
|
-
:
|
|
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
|
-
</
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
.
|
|
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>
|