srcdev-nuxt-components 9.1.58 → 9.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.json +4 -1
- package/.claude/skills/component-aria-landmark.md +42 -8
- package/.claude/skills/components/auto-grid.md +12 -6
- package/.claude/skills/components/page-hero-highlights.md +3 -1
- package/.claude/skills/components/page-row.md +14 -5
- package/.claude/skills/components/profile-section.md +1 -1
- package/.claude/skills/components/services-section.md +63 -7
- package/.claude/skills/components/tab-navigation.md +49 -0
- package/.claude/skills/index.md +2 -2
- package/.claude/skills/testing-add-unit-test.md +18 -0
- package/app/components/01.atoms/grids/data-grid/AutoGrid.vue +1 -2
- package/app/components/01.atoms/grids/data-grid/tests/AutoGrid.spec.ts +11 -19
- package/app/components/01.atoms/grids/data-grid/tests/__snapshots__/AutoGrid.spec.ts.snap +1 -1
- package/app/components/01.atoms/page-row/tests/PageRow.spec.ts +28 -3
- package/app/components/02.molecules/profile-section/tests/ProfileSection.spec.ts +2 -2
- package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesSectionGrid.spec.ts.snap +18 -18
- package/app/components/03.organisms/services/services-section/CONSUMER-STYLING.md +149 -0
- package/app/components/03.organisms/services/services-section/ServicesSection.vue +85 -60
- package/app/components/03.organisms/services/services-section/stories/ServicesSection.stories.ts +186 -32
- package/app/components/03.organisms/services/services-section/tests/ServicesSection.spec.ts +83 -0
- package/app/components/03.organisms/services/services-section/tests/__snapshots__/ServicesSection.spec.ts.snap +11 -11
- package/app/components/04.templates/page-hero-highlights/tests/PageHeroHighlights.spec.ts +2 -2
- package/app/composables/tests/useAriaLabelledById.spec.ts +7 -2
- package/app/composables/useAriaLabelledById.ts +18 -1
- package/package.json +1 -1
package/app/components/03.organisms/services/services-section/stories/ServicesSection.stories.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import ServicesSection from "../ServicesSection.vue";
|
|
2
|
+
import PageRow from "../../../../01.atoms/page-row/PageRow.vue";
|
|
3
|
+
import TextBlock from "../../../../01.atoms/text-block/TextBlock.vue";
|
|
4
|
+
import EyebrowText from "../../../../01.atoms/text-blocks/eyebrow-text/EyebrowText.vue";
|
|
5
|
+
import HeroText from "../../../../01.atoms/text-blocks/hero-text/HeroText.vue";
|
|
6
|
+
import InputButtonCore from "../../../../05.forms/input-button/InputButtonCore.vue";
|
|
2
7
|
import type { Meta, StoryObj } from "@nuxtjs/storybook";
|
|
3
8
|
import type { Service } from "~/types/types.services";
|
|
4
9
|
|
|
@@ -37,6 +42,30 @@ const meta: Meta<typeof ServicesSection> = {
|
|
|
37
42
|
control: { type: "text" },
|
|
38
43
|
description: "Icon name for the price row (any Iconify icon)",
|
|
39
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
|
+
},
|
|
40
69
|
styleClassPassthrough: {
|
|
41
70
|
control: "object",
|
|
42
71
|
description: "Additional CSS classes applied to the root element",
|
|
@@ -56,7 +85,7 @@ const meta: Meta<typeof ServicesSection> = {
|
|
|
56
85
|
docs: {
|
|
57
86
|
description: {
|
|
58
87
|
component:
|
|
59
|
-
"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.",
|
|
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.",
|
|
60
89
|
},
|
|
61
90
|
},
|
|
62
91
|
},
|
|
@@ -68,65 +97,77 @@ type Story = StoryObj<typeof ServicesSection>;
|
|
|
68
97
|
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
|
69
98
|
|
|
70
99
|
const sampleService: Service = {
|
|
71
|
-
slug: "
|
|
72
|
-
category: "
|
|
73
|
-
title: "
|
|
74
|
-
subtitle: "
|
|
75
|
-
price: "£
|
|
76
|
-
duration: "
|
|
77
|
-
image: "https://picsum.photos/seed/
|
|
78
|
-
shortDescription: "
|
|
100
|
+
slug: "colour",
|
|
101
|
+
category: "colour",
|
|
102
|
+
title: "Full Colour",
|
|
103
|
+
subtitle: "Rich, even colour from root to tip",
|
|
104
|
+
price: "From £75",
|
|
105
|
+
duration: "1.5–2.5 hours",
|
|
106
|
+
image: "https://picsum.photos/seed/colour/800/600",
|
|
107
|
+
shortDescription: "Full colour coverage in permanent, semi-permanent, or demi-permanent formulas.",
|
|
79
108
|
longDescription:
|
|
80
|
-
"
|
|
109
|
+
"Full colour service covering the entire head in a single, even shade — whether you're covering grey, going darker for the season, or refreshing your natural colour. We work with permanent, semi-permanent, and demi-permanent formulas depending on the level of commitment and colour result you're after.",
|
|
81
110
|
heroHeading: [
|
|
82
|
-
{ text: "
|
|
83
|
-
{ text: "
|
|
111
|
+
{ text: "Full colour vs. ", styleClass: "normal" },
|
|
112
|
+
{ text: "highlights?", styleClass: "accent" },
|
|
84
113
|
],
|
|
85
114
|
whatIsIt:
|
|
86
|
-
"
|
|
115
|
+
"Full colour applies a single, even shade across the entire head, unlike highlights or balayage which lighten select sections. It's the go-to choice for full grey coverage, a complete colour change, or a rich, uniform base tone.",
|
|
87
116
|
process: [
|
|
88
|
-
"Consultation
|
|
89
|
-
"
|
|
90
|
-
"
|
|
91
|
-
"
|
|
117
|
+
"Consultation and shade selection",
|
|
118
|
+
"Strand test for new clients",
|
|
119
|
+
"Even application, root to tip",
|
|
120
|
+
"Processing time — 25–45 minutes depending on the formula",
|
|
121
|
+
"Rinse and conditioning treatment",
|
|
122
|
+
"Blow-dry and finish",
|
|
92
123
|
],
|
|
93
124
|
idealFor: [
|
|
94
|
-
"
|
|
95
|
-
"
|
|
96
|
-
"
|
|
125
|
+
"Full grey coverage",
|
|
126
|
+
"Going darker for the season",
|
|
127
|
+
"Fashion colours needing a uniform base",
|
|
128
|
+
"Root touch-ups between full appointments",
|
|
97
129
|
],
|
|
98
130
|
maintenance:
|
|
99
|
-
"
|
|
131
|
+
"Root regrowth typically becomes visible after 4–6 weeks. Book a maintenance appointment around this point, and use a colour-safe shampoo and conditioner to protect vibrancy between visits.",
|
|
100
132
|
faqs: [
|
|
101
133
|
{
|
|
102
|
-
question: "
|
|
103
|
-
answer:
|
|
134
|
+
question: "What's the difference between permanent and semi-permanent colour?",
|
|
135
|
+
answer:
|
|
136
|
+
"Permanent colour lifts and deposits pigment for long-lasting, fade-resistant results. Semi-permanent colour sits on the hair's surface and gradually washes out over 4–8 weeks — a lower-commitment option if you're not sure about a change.",
|
|
104
137
|
},
|
|
105
138
|
{
|
|
106
|
-
question: "Can
|
|
107
|
-
answer:
|
|
139
|
+
question: "Can full colour lighten my hair significantly?",
|
|
140
|
+
answer:
|
|
141
|
+
"Full colour can lighten by a few shades, but for a dramatic lift (going several shades lighter) a lightening service or highlights are usually a better fit — we'll advise at consultation.",
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
question: "How effective is full colour at covering grey?",
|
|
145
|
+
answer:
|
|
146
|
+
"Very effective — permanent colour gives the most complete, long-lasting grey coverage. We'll match a shade that blends naturally with your regrowth pattern.",
|
|
108
147
|
},
|
|
109
148
|
],
|
|
110
|
-
seoTitle: "
|
|
149
|
+
seoTitle: "Full Colour | Luxury Locs by Natasha",
|
|
111
150
|
seoDescription:
|
|
112
|
-
"
|
|
151
|
+
"Full colour hair service — permanent, semi-permanent, and demi-permanent options for grey coverage, seasonal colour changes, and fashion shades. Mobile service across Bath.",
|
|
113
152
|
};
|
|
114
153
|
|
|
115
154
|
// ─── Stories ──────────────────────────────────────────────────────────────────
|
|
116
155
|
|
|
117
156
|
export const FullMode: Story = {
|
|
118
157
|
name: "Full Mode",
|
|
158
|
+
args: {
|
|
159
|
+
ctaHeading: "Ready to book your appointment?",
|
|
160
|
+
ctaBody: "Mobile service across Bath — I come to you.",
|
|
161
|
+
},
|
|
119
162
|
render: (args) => ({
|
|
120
|
-
components: { ServicesSection },
|
|
163
|
+
components: { ServicesSection, InputButtonCore },
|
|
121
164
|
setup() {
|
|
122
165
|
return { args, sampleService };
|
|
123
166
|
},
|
|
124
167
|
template: `
|
|
125
168
|
<ServicesSection v-bind="args" :service-data="sampleService">
|
|
126
169
|
<template #cta>
|
|
127
|
-
<
|
|
128
|
-
Book Now
|
|
129
|
-
</button>
|
|
170
|
+
<InputButtonCore tag="a" href="/contact" button-text="Book now" variant="primary" />
|
|
130
171
|
</template>
|
|
131
172
|
</ServicesSection>
|
|
132
173
|
`,
|
|
@@ -135,7 +176,7 @@ export const FullMode: Story = {
|
|
|
135
176
|
docs: {
|
|
136
177
|
description: {
|
|
137
178
|
story:
|
|
138
|
-
"Full mode (isSummary: false) — renders all content including process, ideal-for, FAQs, and the CTA
|
|
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.",
|
|
139
180
|
},
|
|
140
181
|
},
|
|
141
182
|
},
|
|
@@ -213,3 +254,116 @@ export const CustomIcons: Story = {
|
|
|
213
254
|
},
|
|
214
255
|
},
|
|
215
256
|
};
|
|
257
|
+
|
|
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
|
+
},
|
|
268
|
+
render: (args) => ({
|
|
269
|
+
components: { ServicesSection, InputButtonCore },
|
|
270
|
+
setup() {
|
|
271
|
+
return { args, sampleService };
|
|
272
|
+
},
|
|
273
|
+
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>
|
|
279
|
+
`,
|
|
280
|
+
}),
|
|
281
|
+
parameters: {
|
|
282
|
+
docs: {
|
|
283
|
+
description: {
|
|
284
|
+
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.",
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
export const RealisticPageContext: Story = {
|
|
292
|
+
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
|
+
render: (args) => ({
|
|
298
|
+
components: { ServicesSection, PageRow, TextBlock, EyebrowText, HeroText, InputButtonCore },
|
|
299
|
+
setup() {
|
|
300
|
+
return { args, sampleService };
|
|
301
|
+
},
|
|
302
|
+
template: `
|
|
303
|
+
<div>
|
|
304
|
+
<PageRow tag="div" variant="content" :style-class-passthrough="['mbe-20']">
|
|
305
|
+
<TextBlock tag="div" :style-class-passthrough="['page-lead']">
|
|
306
|
+
<EyebrowText font-size="large" text-content="Services" />
|
|
307
|
+
<HeroText
|
|
308
|
+
tag="h1"
|
|
309
|
+
axis="vertical"
|
|
310
|
+
font-size="display"
|
|
311
|
+
:text-content="[
|
|
312
|
+
{ text: 'Expert colour & ', styleClass: 'normal' },
|
|
313
|
+
{ text: 'styling', styleClass: 'accent' },
|
|
314
|
+
{ text: 'at your door', styleClass: 'normal' },
|
|
315
|
+
]"
|
|
316
|
+
:style-class-passthrough="['mb-20']"
|
|
317
|
+
/>
|
|
318
|
+
</TextBlock>
|
|
319
|
+
</PageRow>
|
|
320
|
+
|
|
321
|
+
<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" />
|
|
325
|
+
</template>
|
|
326
|
+
</ServicesSection>
|
|
327
|
+
</PageRow>
|
|
328
|
+
</div>
|
|
329
|
+
`,
|
|
330
|
+
}),
|
|
331
|
+
parameters: {
|
|
332
|
+
docs: {
|
|
333
|
+
description: {
|
|
334
|
+
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.",
|
|
366
|
+
},
|
|
367
|
+
},
|
|
368
|
+
},
|
|
369
|
+
};
|
|
@@ -67,6 +67,18 @@ describe("ServicesSection", () => {
|
|
|
67
67
|
expect(wrapper.element.tagName).toBe("SECTION");
|
|
68
68
|
});
|
|
69
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
|
+
|
|
70
82
|
// ─── Icons ──────────────────────────────────────────────────────────────
|
|
71
83
|
|
|
72
84
|
it("renders the default duration icon name in the template", async () => {
|
|
@@ -211,4 +223,75 @@ describe("ServicesSection", () => {
|
|
|
211
223
|
expect(wrapper.classes()).not.toContain("original");
|
|
212
224
|
expect(wrapper.classes()).toContain("updated");
|
|
213
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
|
+
});
|
|
214
297
|
});
|
|
@@ -3,15 +3,15 @@
|
|
|
3
3
|
exports[`ServicesSection > renders correct HTML structure 1`] = `
|
|
4
4
|
"<div class="services-section">
|
|
5
5
|
<div class="services-section__grid">
|
|
6
|
-
<div class="
|
|
7
|
-
<div class="
|
|
6
|
+
<div class="services-section__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="services-section__image" src="/_ipx/_/images/test.jpg"></div>
|
|
7
|
+
<div class="services-section__info-wrapper">
|
|
8
8
|
<div class="eyebrow-text large">A subtitle</div>
|
|
9
|
-
<h2 class="hero-text mb-20 title axis-horizontal">
|
|
9
|
+
<h2 id="v-0-0" class="hero-text mb-20 title axis-horizontal">
|
|
10
10
|
<!--v-if--><span class="text-block-0 normal">Test Service</span>
|
|
11
11
|
</h2>
|
|
12
|
-
<div class="
|
|
13
|
-
<div class="flex-row"><span class="iconify i-mdi:clock-time-four-outline
|
|
14
|
-
<div class="flex-row"><span class="iconify i-mdi:currency-gbp
|
|
12
|
+
<div class="services-section__price-duration">
|
|
13
|
+
<div class="flex-row"><span class="iconify i-mdi:clock-time-four-outline services-section__decorator" aria-hidden="true"></span><span>60 mins</span></div>
|
|
14
|
+
<div class="flex-row"><span class="iconify i-mdi:currency-gbp services-section__decorator" aria-hidden="true"></span><span>£50</span></div>
|
|
15
15
|
</div>
|
|
16
16
|
<p class="page-body-normal">Long description text</p>
|
|
17
17
|
<h2 class="hero-text mb-20 subheading axis-horizontal">
|
|
@@ -60,23 +60,23 @@ exports[`ServicesSection > renders correct HTML structure 1`] = `
|
|
|
60
60
|
<h2 class="hero-text mb-20 subheading axis-horizontal">
|
|
61
61
|
<!--v-if--><span class="text-block-0 normal">Frequently Asked Questions</span>
|
|
62
62
|
</h2>
|
|
63
|
-
<div class="display-accordian services-
|
|
63
|
+
<div class="display-accordian services-section__faq" id="faq">
|
|
64
64
|
<div class="expanding-panel accordian-item" icon-size="medium">
|
|
65
65
|
<details class="expanding-panel-details" name="faq-v-0-0-0">
|
|
66
66
|
<summary id="id-faq-v-0-0-0-trigger" class="expanding-panel-summary" aria-controls="id-faq-v-0-0-0-content" aria-expanded="false"><span class="label-wrapper">FAQ question?</span><span class="icon-wrapper"><span class="iconify i-mdi:chevron-down" aria-hidden="true"></span></span></summary>
|
|
67
67
|
</details>
|
|
68
68
|
<div id="id-faq-v-0-0-0-content" class="expanding-panel-content" aria-labelledby="id-faq-v-0-0-0-trigger" role="region">
|
|
69
69
|
<div class="inner">
|
|
70
|
-
<p>FAQ answer.</p>
|
|
70
|
+
<p class="services-section__faq-answer">FAQ answer.</p>
|
|
71
71
|
</div>
|
|
72
72
|
</div>
|
|
73
73
|
</div>
|
|
74
74
|
</div>
|
|
75
|
-
<div class="glass-panel p-24">
|
|
75
|
+
<div class="glass-panel services-section__glass-panel p-24">
|
|
76
76
|
<h2 class="hero-text mbs-0 mbe-20 subheading axis-horizontal">
|
|
77
|
-
<!--v-if--><span class="text-block-0 normal">Ready to book your
|
|
77
|
+
<!--v-if--><span class="text-block-0 normal">Ready to book your appointment?</span>
|
|
78
78
|
</h2>
|
|
79
|
-
<p class="page-body-normal">
|
|
79
|
+
<p class="page-body-normal">Get in touch to book your appointment.</p>
|
|
80
80
|
</div>
|
|
81
81
|
</div>
|
|
82
82
|
</div>
|
|
@@ -80,11 +80,11 @@ describe("PageHeroHighlights", () => {
|
|
|
80
80
|
expect(wrapper.find(".page-hero-highlights").attributes("aria-labelledby")).toBeTruthy();
|
|
81
81
|
});
|
|
82
82
|
|
|
83
|
-
it("
|
|
83
|
+
it("does not add aria-labelledby when tag is main (main never needs an accessible name)", async () => {
|
|
84
84
|
const wrapper = await mountSuspended(PageHeroHighlights, {
|
|
85
85
|
props: { tag: "main" },
|
|
86
86
|
});
|
|
87
|
-
expect(wrapper.find(".page-hero-highlights").attributes("aria-labelledby")).
|
|
87
|
+
expect(wrapper.find(".page-hero-highlights").attributes("aria-labelledby")).toBeUndefined();
|
|
88
88
|
});
|
|
89
89
|
|
|
90
90
|
it("does not add aria-labelledby when tag is div", async () => {
|
|
@@ -28,7 +28,7 @@ describe("useAriaLabelledById", () => {
|
|
|
28
28
|
// ─── Labelled tags ────────────────────────────────────────────────────────
|
|
29
29
|
|
|
30
30
|
describe("labelled tags", () => {
|
|
31
|
-
it.each(["section", "
|
|
31
|
+
it.each(["section", "article", "aside"])(
|
|
32
32
|
'"%s" returns ariaLabelledby = headingId',
|
|
33
33
|
(tag) => {
|
|
34
34
|
const { headingId, ariaLabelledby } = useAriaLabelledById(tag);
|
|
@@ -40,13 +40,18 @@ describe("useAriaLabelledById", () => {
|
|
|
40
40
|
// ─── Non-labelled tags ────────────────────────────────────────────────────
|
|
41
41
|
|
|
42
42
|
describe("non-labelled tags", () => {
|
|
43
|
-
it.each(["div", "span", "h1", "p", "ul", "nav"])(
|
|
43
|
+
it.each(["div", "span", "h1", "p", "ul", "nav", "main"])(
|
|
44
44
|
'"%s" returns ariaLabelledby = undefined',
|
|
45
45
|
(tag) => {
|
|
46
46
|
const { ariaLabelledby } = useAriaLabelledById(tag);
|
|
47
47
|
expect(ariaLabelledby.value).toBeUndefined();
|
|
48
48
|
}
|
|
49
49
|
);
|
|
50
|
+
|
|
51
|
+
it('"main" is never auto-labelled, even though it is a landmark', () => {
|
|
52
|
+
const { ariaLabelledby } = useAriaLabelledById("main");
|
|
53
|
+
expect(ariaLabelledby.value).toBeUndefined();
|
|
54
|
+
});
|
|
50
55
|
});
|
|
51
56
|
|
|
52
57
|
// ─── Reactivity ───────────────────────────────────────────────────────────
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { MaybeRefOrGetter } from "vue";
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
// "main" is deliberately excluded: a <main> landmark doesn't need an accessible
|
|
4
|
+
// name unless a page has more than one, so it should never auto-label itself.
|
|
5
|
+
const LABELLED_TAGS = new Set(["section", "article", "aside"]);
|
|
4
6
|
|
|
5
7
|
export function useAriaLabelledById(tag: MaybeRefOrGetter<string>) {
|
|
6
8
|
const headingId = useId();
|
|
@@ -9,5 +11,20 @@ export function useAriaLabelledById(tag: MaybeRefOrGetter<string>) {
|
|
|
9
11
|
return LABELLED_TAGS.has(toValue(tag)) ? headingId : undefined;
|
|
10
12
|
});
|
|
11
13
|
|
|
14
|
+
// Safety net: consumers are responsible for binding `headingId` to a real
|
|
15
|
+
// heading (directly, or via a slot prop) whenever aria-labelledby is set.
|
|
16
|
+
// Forgetting to do so produces a broken ARIA reference that otherwise only
|
|
17
|
+
// shows up in an accessibility audit (e.g. WAVE) — warn immediately instead.
|
|
18
|
+
onMounted(() => {
|
|
19
|
+
if (ariaLabelledby.value && !document.getElementById(headingId)) {
|
|
20
|
+
console.warn(
|
|
21
|
+
`[useAriaLabelledById] aria-labelledby="${headingId}" was set on a <${toValue(tag)}> element, ` +
|
|
22
|
+
`but no element with that id was found. Bind the "headingId" value returned by this composable ` +
|
|
23
|
+
`(often exposed as a "heading-id" slot prop) onto a visible heading, or the accessibility tree ` +
|
|
24
|
+
`will contain a broken ARIA reference.`
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
12
29
|
return { headingId, ariaLabelledby };
|
|
13
30
|
}
|