srcdev-nuxt-components 9.3.11 → 9.3.12
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 +2 -2
- package/.vscode/srcdev-component-service-summary.code-snippets +67 -0
- 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/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,345 +0,0 @@
|
|
|
1
|
-
<template>
|
|
2
|
-
<component :is="tag" class="services-section" :class="[elementClasses]" :aria-labelledby="ariaLabelledby">
|
|
3
|
-
<div class="services-section__grid" :class="{ 'services-section__grid--reverse': reverse }">
|
|
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
|
-
/>
|
|
12
|
-
</div>
|
|
13
|
-
<div class="services-section__info-wrapper" :class="infoWrapperClasses">
|
|
14
|
-
<EyebrowText font-size="large" :text-content="serviceData.subtitle" />
|
|
15
|
-
<HeroText
|
|
16
|
-
:id="headingId"
|
|
17
|
-
:tag="headerTag"
|
|
18
|
-
font-size="title"
|
|
19
|
-
:text-content="[
|
|
20
|
-
{
|
|
21
|
-
text: serviceData.title,
|
|
22
|
-
styleClass: 'normal',
|
|
23
|
-
},
|
|
24
|
-
]"
|
|
25
|
-
:style-class-passthrough="['mb-20']"
|
|
26
|
-
/>
|
|
27
|
-
|
|
28
|
-
<div class="services-section__price-duration">
|
|
29
|
-
<div class="flex-row">
|
|
30
|
-
<Icon :name="durationIcon" class="services-section__decorator" />
|
|
31
|
-
<span>{{ serviceData.duration }}</span>
|
|
32
|
-
</div>
|
|
33
|
-
<div class="flex-row">
|
|
34
|
-
<Icon :name="priceIcon" class="services-section__decorator" />
|
|
35
|
-
<span>{{ serviceData.price }}</span>
|
|
36
|
-
</div>
|
|
37
|
-
</div>
|
|
38
|
-
|
|
39
|
-
<p v-if="!isSummary" class="page-body-normal">{{ serviceData.longDescription }}</p>
|
|
40
|
-
|
|
41
|
-
<HeroText
|
|
42
|
-
v-if="!isSummary"
|
|
43
|
-
:tag="headerTag"
|
|
44
|
-
axis="horizontal"
|
|
45
|
-
font-size="subheading"
|
|
46
|
-
:text-content="serviceData.heroHeading"
|
|
47
|
-
:style-class-passthrough="['mb-20']"
|
|
48
|
-
/>
|
|
49
|
-
<p class="page-body-normal">
|
|
50
|
-
{{ serviceData.whatIsIt }}
|
|
51
|
-
</p>
|
|
52
|
-
|
|
53
|
-
<slot v-if="isSummary" name="summary-link" :service-data="serviceData"></slot>
|
|
54
|
-
|
|
55
|
-
<HeroText
|
|
56
|
-
v-if="!isSummary"
|
|
57
|
-
:tag="headerTag"
|
|
58
|
-
axis="horizontal"
|
|
59
|
-
font-size="subheading"
|
|
60
|
-
:text-content="[{ text: processHeading, styleClass: 'normal' }]"
|
|
61
|
-
:style-class-passthrough="['mb-20']"
|
|
62
|
-
/>
|
|
63
|
-
|
|
64
|
-
<StepperList
|
|
65
|
-
v-if="!isSummary"
|
|
66
|
-
tag="ol"
|
|
67
|
-
indicator-alignment="top"
|
|
68
|
-
indicator-variant="circle"
|
|
69
|
-
:show-connectors="true"
|
|
70
|
-
:item-count="serviceData.process.length"
|
|
71
|
-
>
|
|
72
|
-
<template v-for="(item, i) in serviceData.process" :key="i" #[`item-${i}`]>
|
|
73
|
-
<p class="page-body-normal">{{ item }}</p>
|
|
74
|
-
</template>
|
|
75
|
-
</StepperList>
|
|
76
|
-
|
|
77
|
-
<HeroText
|
|
78
|
-
v-if="!isSummary"
|
|
79
|
-
:tag="headerTag"
|
|
80
|
-
axis="horizontal"
|
|
81
|
-
font-size="subheading"
|
|
82
|
-
:text-content="[{ text: idealForHeading, styleClass: 'normal' }]"
|
|
83
|
-
:style-class-passthrough="['mb-20']"
|
|
84
|
-
/>
|
|
85
|
-
|
|
86
|
-
<StepperList v-if="!isSummary" :connected="false" :item-count="serviceData.idealFor.length">
|
|
87
|
-
<template v-for="(_, i) in serviceData.idealFor" :key="i" #[`indicator-${i}`]>
|
|
88
|
-
<Icon name="mdi:checkbox-marked-circle-outline" class="indicator-icon" />
|
|
89
|
-
</template>
|
|
90
|
-
<template v-for="(item, i) in serviceData.idealFor" :key="i" #[`item-${i}`]>
|
|
91
|
-
<p class="page-body-normal">{{ item }}</p>
|
|
92
|
-
</template>
|
|
93
|
-
</StepperList>
|
|
94
|
-
|
|
95
|
-
<HeroText
|
|
96
|
-
v-if="!isSummary"
|
|
97
|
-
:tag="headerTag"
|
|
98
|
-
axis="horizontal"
|
|
99
|
-
font-size="subheading"
|
|
100
|
-
:text-content="[{ text: maintenanceHeading, styleClass: 'normal' }]"
|
|
101
|
-
:style-class-passthrough="['mb-20']"
|
|
102
|
-
/>
|
|
103
|
-
|
|
104
|
-
<p v-if="!isSummary" class="page-body-normal">{{ serviceData.maintenance }}</p>
|
|
105
|
-
|
|
106
|
-
<HeroText
|
|
107
|
-
v-if="!isSummary"
|
|
108
|
-
:tag="headerTag"
|
|
109
|
-
axis="horizontal"
|
|
110
|
-
font-size="subheading"
|
|
111
|
-
:text-content="[{ text: faqsHeading, styleClass: 'normal' }]"
|
|
112
|
-
:style-class-passthrough="['mb-20']"
|
|
113
|
-
/>
|
|
114
|
-
|
|
115
|
-
<AccordianCore
|
|
116
|
-
v-if="!isSummary"
|
|
117
|
-
id="faq"
|
|
118
|
-
:item-count="serviceData.faqs.length"
|
|
119
|
-
:name="`faq-${useId()}`"
|
|
120
|
-
:style-class-passthrough="['services-section__faq']"
|
|
121
|
-
>
|
|
122
|
-
<template v-for="(_, key) in serviceData.faqs" :key="`summary-${key}`" #[`accordian-${key}-summary`]>
|
|
123
|
-
{{ serviceData.faqs[key]?.question }}
|
|
124
|
-
</template>
|
|
125
|
-
<template v-for="(_, key) in serviceData.faqs" :key="`icon-${key}`" #[`accordian-${key}-icon`]>
|
|
126
|
-
<Icon name="mdi:chevron-down" />
|
|
127
|
-
</template>
|
|
128
|
-
<template v-for="(_, key) in serviceData.faqs" :key="`content-${key}`" #[`accordian-${key}-content`]>
|
|
129
|
-
<p class="services-section__faq-answer">{{ serviceData.faqs[key]?.answer }}</p>
|
|
130
|
-
</template>
|
|
131
|
-
</AccordianCore>
|
|
132
|
-
|
|
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>
|
|
152
|
-
</div>
|
|
153
|
-
</div>
|
|
154
|
-
</component>
|
|
155
|
-
</template>
|
|
156
|
-
|
|
157
|
-
<script setup lang="ts">
|
|
158
|
-
import type { Service } from "~/types/types.services";
|
|
159
|
-
|
|
160
|
-
interface Props {
|
|
161
|
-
tag?: "div" | "section" | "article" | "main";
|
|
162
|
-
headerTag?: "h1" | "h2" | "h3";
|
|
163
|
-
index?: number;
|
|
164
|
-
serviceData: Service;
|
|
165
|
-
isSummary?: boolean;
|
|
166
|
-
summaryAlignment?: "start" | "center" | "end";
|
|
167
|
-
reverse?: boolean;
|
|
168
|
-
durationIcon?: string;
|
|
169
|
-
priceIcon?: string;
|
|
170
|
-
processHeading?: string;
|
|
171
|
-
idealForHeading?: string;
|
|
172
|
-
maintenanceHeading?: string;
|
|
173
|
-
faqsHeading?: string;
|
|
174
|
-
ctaHeading?: string;
|
|
175
|
-
ctaBody?: string;
|
|
176
|
-
styleClassPassthrough?: string | string[];
|
|
177
|
-
}
|
|
178
|
-
const props = withDefaults(defineProps<Props>(), {
|
|
179
|
-
tag: "div",
|
|
180
|
-
headerTag: "h2",
|
|
181
|
-
index: 0,
|
|
182
|
-
isSummary: false,
|
|
183
|
-
summaryAlignment: "center",
|
|
184
|
-
durationIcon: "mdi:clock-time-four-outline",
|
|
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.",
|
|
192
|
-
styleClassPassthrough: () => [],
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
const { headingId, ariaLabelledby } = useAriaLabelledById(() => props.tag);
|
|
196
|
-
|
|
197
|
-
const infoWrapperClasses = computed(() => {
|
|
198
|
-
return {
|
|
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",
|
|
203
|
-
};
|
|
204
|
-
});
|
|
205
|
-
|
|
206
|
-
// Computed to return loading="lazy" if index is greater than 1, so that the first two sections prioritise image loading, but if there are more than 2 sections, the rest will lazy load their images to improve performance.
|
|
207
|
-
const imageLoading = computed(() => (props.index !== undefined && props.index > 1 ? "lazy" : "eager"));
|
|
208
|
-
|
|
209
|
-
// Only the first image (LCP candidate) gets fetchpriority="high" to reduce resource load delay.
|
|
210
|
-
const imageFetchPriority = computed(() => (props.index === 0 ? "high" : "auto"));
|
|
211
|
-
|
|
212
|
-
const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
|
|
213
|
-
|
|
214
|
-
watch(
|
|
215
|
-
() => props.styleClassPassthrough,
|
|
216
|
-
() => {
|
|
217
|
-
resetElementClasses(props.styleClassPassthrough);
|
|
218
|
-
}
|
|
219
|
-
);
|
|
220
|
-
</script>
|
|
221
|
-
|
|
222
|
-
<style lang="css">
|
|
223
|
-
@layer components {
|
|
224
|
-
.services-section {
|
|
225
|
-
container-type: inline-size;
|
|
226
|
-
container-name: services-section;
|
|
227
|
-
|
|
228
|
-
.services-section__grid {
|
|
229
|
-
--_grid-gap: var(--services-section-grid-gap, 2rem);
|
|
230
|
-
|
|
231
|
-
display: grid;
|
|
232
|
-
grid-template-columns: 1fr;
|
|
233
|
-
gap: var(--_grid-gap);
|
|
234
|
-
|
|
235
|
-
@media (width >= 768px) {
|
|
236
|
-
--_grid-gap: var(--services-section-grid-gap-desktop, 3rem);
|
|
237
|
-
|
|
238
|
-
grid-template-columns: repeat(auto-fit, minmax(446px, 1fr));
|
|
239
|
-
|
|
240
|
-
&.services-section__grid--reverse {
|
|
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. */
|
|
243
|
-
order: 2;
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
.services-section__info-wrapper {
|
|
249
|
-
&.services-section__info-wrapper--summary {
|
|
250
|
-
display: grid;
|
|
251
|
-
|
|
252
|
-
&.services-section__info-wrapper--align-start {
|
|
253
|
-
align-content: start;
|
|
254
|
-
}
|
|
255
|
-
&.services-section__info-wrapper--align-center {
|
|
256
|
-
align-content: center;
|
|
257
|
-
}
|
|
258
|
-
&.services-section__info-wrapper--align-end {
|
|
259
|
-
align-content: end;
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
.services-section__image-wrapper {
|
|
265
|
-
align-self: start;
|
|
266
|
-
border-radius: var(--services-section-image-border-radius, 0.8rem);
|
|
267
|
-
overflow: hidden;
|
|
268
|
-
|
|
269
|
-
.services-section__image {
|
|
270
|
-
display: block;
|
|
271
|
-
width: 100%;
|
|
272
|
-
height: 100%;
|
|
273
|
-
object-fit: cover;
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
.services-section__price-duration {
|
|
278
|
-
display: flex;
|
|
279
|
-
flex-direction: row;
|
|
280
|
-
gap: var(--services-section-price-duration-gap, 3rem);
|
|
281
|
-
align-items: center;
|
|
282
|
-
margin-block-end: var(--services-section-price-duration-margin-block-end, 2rem);
|
|
283
|
-
|
|
284
|
-
> div {
|
|
285
|
-
display: flex;
|
|
286
|
-
flex-direction: row;
|
|
287
|
-
gap: var(--services-section-price-duration-item-gap, 0.8rem);
|
|
288
|
-
align-items: center;
|
|
289
|
-
|
|
290
|
-
.services-section__decorator {
|
|
291
|
-
width: var(--services-section-decorator-size, 2rem);
|
|
292
|
-
height: var(--services-section-decorator-size, 2rem);
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
.services-section__faq {
|
|
298
|
-
margin-block-end: var(--services-section-faq-margin-block-end, 2.4rem);
|
|
299
|
-
|
|
300
|
-
&.display-accordian {
|
|
301
|
-
max-width: none;
|
|
302
|
-
|
|
303
|
-
.accordian-item.expanding-panel {
|
|
304
|
-
border-block-end: 1px solid var(--services-section-faq-divider-color, currentColor);
|
|
305
|
-
opacity: var(--services-section-faq-divider-opacity, 0.7);
|
|
306
|
-
|
|
307
|
-
&:first-child {
|
|
308
|
-
border-block-start: 1px solid var(--services-section-faq-divider-color, currentColor);
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
.expanding-panel-details .expanding-panel-summary {
|
|
312
|
-
padding-block: 1.2rem;
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
.expanding-panel-content .inner {
|
|
316
|
-
.services-section__faq-answer {
|
|
317
|
-
line-height: var(--services-section-faq-answer-line-height, 1.6);
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
.accordian-item.expanding-panel-classic {
|
|
323
|
-
border-block-end: 1px solid var(--services-section-faq-divider-color, currentColor);
|
|
324
|
-
opacity: var(--services-section-faq-divider-opacity, 0.7);
|
|
325
|
-
|
|
326
|
-
&:first-child {
|
|
327
|
-
border-block-start: 1px solid var(--services-section-faq-divider-color, currentColor);
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
.expanding-panel-classic-details .expanding-panel-classic-summary {
|
|
331
|
-
padding-block: 1.2rem;
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
.expanding-panel-classic-content .inner {
|
|
335
|
-
.services-section__faq-answer {
|
|
336
|
-
line-height: var(--services-section-faq-answer-line-height, 1.6);
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
</style>
|
|
@@ -1,297 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { mountSuspended } from "@nuxt/test-utils/runtime";
|
|
3
|
-
import ServicesSection from "../ServicesSection.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("ServicesSection", () => {
|
|
31
|
-
// ─── Mount ─────────────────────────────────────────────────────────────
|
|
32
|
-
|
|
33
|
-
it("mounts without error", async () => {
|
|
34
|
-
const wrapper = await mountSuspended(ServicesSection, {
|
|
35
|
-
props: { serviceData: mockService },
|
|
36
|
-
});
|
|
37
|
-
expect(wrapper.vm).toBeTruthy();
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
it("renders correct HTML structure", async () => {
|
|
41
|
-
const wrapper = await mountSuspended(ServicesSection, {
|
|
42
|
-
props: { serviceData: mockService },
|
|
43
|
-
});
|
|
44
|
-
expect(wrapper.html()).toMatchSnapshot();
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
// ─── Root element ───────────────────────────────────────────────────────
|
|
48
|
-
|
|
49
|
-
it("has services-section class on root", async () => {
|
|
50
|
-
const wrapper = await mountSuspended(ServicesSection, {
|
|
51
|
-
props: { serviceData: mockService },
|
|
52
|
-
});
|
|
53
|
-
expect(wrapper.classes()).toContain("services-section");
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
it("renders as a div by default", async () => {
|
|
57
|
-
const wrapper = await mountSuspended(ServicesSection, {
|
|
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(ServicesSection, {
|
|
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(ServicesSection, {
|
|
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
|
-
// ─── Icons ──────────────────────────────────────────────────────────────
|
|
83
|
-
|
|
84
|
-
it("renders the default duration icon name in the template", async () => {
|
|
85
|
-
const wrapper = await mountSuspended(ServicesSection, {
|
|
86
|
-
props: { serviceData: mockService },
|
|
87
|
-
});
|
|
88
|
-
expect(wrapper.html()).toContain("mdi:clock-time-four-outline");
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
it("renders the default price icon name in the template", async () => {
|
|
92
|
-
const wrapper = await mountSuspended(ServicesSection, {
|
|
93
|
-
props: { serviceData: mockService },
|
|
94
|
-
});
|
|
95
|
-
expect(wrapper.html()).toContain("mdi:currency-gbp");
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
it("overrides the duration icon via durationIcon prop", async () => {
|
|
99
|
-
const wrapper = await mountSuspended(ServicesSection, {
|
|
100
|
-
props: { serviceData: mockService, durationIcon: "mdi:timer-outline" },
|
|
101
|
-
});
|
|
102
|
-
expect(wrapper.html()).toContain("mdi:timer-outline");
|
|
103
|
-
expect(wrapper.html()).not.toContain("mdi:clock-time-four-outline");
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
it("overrides the price icon via priceIcon prop", async () => {
|
|
107
|
-
const wrapper = await mountSuspended(ServicesSection, {
|
|
108
|
-
props: { serviceData: mockService, priceIcon: "mdi:currency-usd" },
|
|
109
|
-
});
|
|
110
|
-
expect(wrapper.html()).toContain("mdi:currency-usd");
|
|
111
|
-
expect(wrapper.html()).not.toContain("mdi:currency-gbp");
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
it("both icons can be overridden together", async () => {
|
|
115
|
-
const wrapper = await mountSuspended(ServicesSection, {
|
|
116
|
-
props: {
|
|
117
|
-
serviceData: mockService,
|
|
118
|
-
durationIcon: "mdi:timer-outline",
|
|
119
|
-
priceIcon: "mdi:currency-eur",
|
|
120
|
-
},
|
|
121
|
-
});
|
|
122
|
-
expect(wrapper.html()).toContain("mdi:timer-outline");
|
|
123
|
-
expect(wrapper.html()).toContain("mdi:currency-eur");
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
// ─── Service data ───────────────────────────────────────────────────────
|
|
127
|
-
|
|
128
|
-
it("renders service duration text", async () => {
|
|
129
|
-
const wrapper = await mountSuspended(ServicesSection, {
|
|
130
|
-
props: { serviceData: mockService },
|
|
131
|
-
});
|
|
132
|
-
expect(wrapper.text()).toContain(mockService.duration);
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
it("renders service price text", async () => {
|
|
136
|
-
const wrapper = await mountSuspended(ServicesSection, {
|
|
137
|
-
props: { serviceData: mockService },
|
|
138
|
-
});
|
|
139
|
-
expect(wrapper.text()).toContain(mockService.price);
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
// ─── Summary mode ───────────────────────────────────────────────────────
|
|
143
|
-
|
|
144
|
-
it("renders summary-link slot in summary mode", async () => {
|
|
145
|
-
const wrapper = await mountSuspended(ServicesSection, {
|
|
146
|
-
props: { serviceData: mockService, isSummary: true },
|
|
147
|
-
slots: {
|
|
148
|
-
"summary-link": '<a class="test-link" href="/services/test">View service</a>',
|
|
149
|
-
},
|
|
150
|
-
});
|
|
151
|
-
expect(wrapper.find(".test-link").exists()).toBe(true);
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
it("does not render summary-link slot in full mode", async () => {
|
|
155
|
-
const wrapper = await mountSuspended(ServicesSection, {
|
|
156
|
-
props: { serviceData: mockService, isSummary: false },
|
|
157
|
-
slots: {
|
|
158
|
-
"summary-link": '<a class="test-link" href="/services/test">View service</a>',
|
|
159
|
-
},
|
|
160
|
-
});
|
|
161
|
-
expect(wrapper.find(".test-link").exists()).toBe(false);
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
it("renders cta slot in full mode", async () => {
|
|
165
|
-
const wrapper = await mountSuspended(ServicesSection, {
|
|
166
|
-
props: { serviceData: mockService, isSummary: false },
|
|
167
|
-
slots: {
|
|
168
|
-
cta: '<button class="test-cta">Book now</button>',
|
|
169
|
-
},
|
|
170
|
-
});
|
|
171
|
-
expect(wrapper.find(".test-cta").exists()).toBe(true);
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
it("does not render cta slot in summary mode", async () => {
|
|
175
|
-
const wrapper = await mountSuspended(ServicesSection, {
|
|
176
|
-
props: { serviceData: mockService, isSummary: true },
|
|
177
|
-
slots: {
|
|
178
|
-
cta: '<button class="test-cta">Book now</button>',
|
|
179
|
-
},
|
|
180
|
-
});
|
|
181
|
-
expect(wrapper.find(".test-cta").exists()).toBe(false);
|
|
182
|
-
});
|
|
183
|
-
|
|
184
|
-
// ─── Reverse ────────────────────────────────────────────────────────────
|
|
185
|
-
|
|
186
|
-
it("applies reverse class when reverse prop is true", async () => {
|
|
187
|
-
const wrapper = await mountSuspended(ServicesSection, {
|
|
188
|
-
props: { serviceData: mockService, reverse: true },
|
|
189
|
-
});
|
|
190
|
-
expect(wrapper.find(".services-section__grid").classes()).toContain("services-section__grid--reverse");
|
|
191
|
-
});
|
|
192
|
-
|
|
193
|
-
it("does not apply reverse class by default", async () => {
|
|
194
|
-
const wrapper = await mountSuspended(ServicesSection, {
|
|
195
|
-
props: { serviceData: mockService },
|
|
196
|
-
});
|
|
197
|
-
expect(wrapper.find(".services-section__grid").classes()).not.toContain("services-section__grid--reverse");
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
// ─── styleClassPassthrough ──────────────────────────────────────────────
|
|
201
|
-
|
|
202
|
-
it("applies a single styleClassPassthrough string", async () => {
|
|
203
|
-
const wrapper = await mountSuspended(ServicesSection, {
|
|
204
|
-
props: { serviceData: mockService, styleClassPassthrough: "custom-class" },
|
|
205
|
-
});
|
|
206
|
-
expect(wrapper.classes()).toContain("custom-class");
|
|
207
|
-
});
|
|
208
|
-
|
|
209
|
-
it("applies multiple styleClassPassthrough classes from an array", async () => {
|
|
210
|
-
const wrapper = await mountSuspended(ServicesSection, {
|
|
211
|
-
props: { serviceData: mockService, styleClassPassthrough: ["class-a", "class-b"] },
|
|
212
|
-
});
|
|
213
|
-
expect(wrapper.classes()).toContain("class-a");
|
|
214
|
-
expect(wrapper.classes()).toContain("class-b");
|
|
215
|
-
});
|
|
216
|
-
|
|
217
|
-
it("updates classes when styleClassPassthrough prop changes", async () => {
|
|
218
|
-
const wrapper = await mountSuspended(ServicesSection, {
|
|
219
|
-
props: { serviceData: mockService, styleClassPassthrough: ["original"] },
|
|
220
|
-
});
|
|
221
|
-
expect(wrapper.classes()).toContain("original");
|
|
222
|
-
await wrapper.setProps({ styleClassPassthrough: ["updated"] });
|
|
223
|
-
expect(wrapper.classes()).not.toContain("original");
|
|
224
|
-
expect(wrapper.classes()).toContain("updated");
|
|
225
|
-
});
|
|
226
|
-
|
|
227
|
-
// ─── Headings ───────────────────────────────────────────────────────────
|
|
228
|
-
|
|
229
|
-
it("renders default section headings", async () => {
|
|
230
|
-
const wrapper = await mountSuspended(ServicesSection, {
|
|
231
|
-
props: { serviceData: mockService },
|
|
232
|
-
});
|
|
233
|
-
expect(wrapper.text()).toContain("The Process");
|
|
234
|
-
expect(wrapper.text()).toContain("Ideal For");
|
|
235
|
-
expect(wrapper.text()).toContain("Aftercare & Maintenance");
|
|
236
|
-
expect(wrapper.text()).toContain("Frequently Asked Questions");
|
|
237
|
-
});
|
|
238
|
-
|
|
239
|
-
it("overrides section headings via props", async () => {
|
|
240
|
-
const wrapper = await mountSuspended(ServicesSection, {
|
|
241
|
-
props: {
|
|
242
|
-
serviceData: mockService,
|
|
243
|
-
processHeading: "How It Works",
|
|
244
|
-
idealForHeading: "Who Is This For",
|
|
245
|
-
maintenanceHeading: "Aftercare",
|
|
246
|
-
faqsHeading: "FAQs",
|
|
247
|
-
},
|
|
248
|
-
});
|
|
249
|
-
expect(wrapper.text()).toContain("How It Works");
|
|
250
|
-
expect(wrapper.text()).toContain("Who Is This For");
|
|
251
|
-
expect(wrapper.text()).toContain("Aftercare");
|
|
252
|
-
expect(wrapper.text()).toContain("FAQs");
|
|
253
|
-
});
|
|
254
|
-
|
|
255
|
-
// ─── CTA panel ──────────────────────────────────────────────────────────
|
|
256
|
-
|
|
257
|
-
it("renders default CTA heading and body", async () => {
|
|
258
|
-
const wrapper = await mountSuspended(ServicesSection, {
|
|
259
|
-
props: { serviceData: mockService },
|
|
260
|
-
});
|
|
261
|
-
expect(wrapper.text()).toContain("Ready to book your appointment?");
|
|
262
|
-
expect(wrapper.text()).toContain("Get in touch to book your appointment.");
|
|
263
|
-
});
|
|
264
|
-
|
|
265
|
-
it("overrides CTA heading and body via props", async () => {
|
|
266
|
-
const wrapper = await mountSuspended(ServicesSection, {
|
|
267
|
-
props: {
|
|
268
|
-
serviceData: mockService,
|
|
269
|
-
ctaHeading: "Book your locs consultation",
|
|
270
|
-
ctaBody: "Mobile service across Bath — I come to you.",
|
|
271
|
-
},
|
|
272
|
-
});
|
|
273
|
-
expect(wrapper.text()).toContain("Book your locs consultation");
|
|
274
|
-
expect(wrapper.text()).toContain("Mobile service across Bath — I come to you.");
|
|
275
|
-
});
|
|
276
|
-
|
|
277
|
-
it("replaces the whole CTA panel via the cta-panel slot", async () => {
|
|
278
|
-
const wrapper = await mountSuspended(ServicesSection, {
|
|
279
|
-
props: { serviceData: mockService },
|
|
280
|
-
slots: {
|
|
281
|
-
"cta-panel": '<div class="test-custom-cta">Custom CTA</div>',
|
|
282
|
-
},
|
|
283
|
-
});
|
|
284
|
-
expect(wrapper.find(".test-custom-cta").exists()).toBe(true);
|
|
285
|
-
expect(wrapper.text()).not.toContain("Ready to book your appointment?");
|
|
286
|
-
});
|
|
287
|
-
|
|
288
|
-
it("does not render cta-panel slot in summary mode", async () => {
|
|
289
|
-
const wrapper = await mountSuspended(ServicesSection, {
|
|
290
|
-
props: { serviceData: mockService, isSummary: true },
|
|
291
|
-
slots: {
|
|
292
|
-
"cta-panel": '<div class="test-custom-cta">Custom CTA</div>',
|
|
293
|
-
},
|
|
294
|
-
});
|
|
295
|
-
expect(wrapper.find(".test-custom-cta").exists()).toBe(false);
|
|
296
|
-
});
|
|
297
|
-
});
|