srcdev-nuxt-components 9.1.58 → 9.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. package/.claude/settings.json +4 -1
  2. package/.claude/skills/component-aria-landmark.md +42 -8
  3. package/.claude/skills/components/auto-grid.md +12 -6
  4. package/.claude/skills/components/page-hero-highlights.md +3 -1
  5. package/.claude/skills/components/page-row.md +14 -5
  6. package/.claude/skills/components/profile-section.md +1 -1
  7. package/.claude/skills/components/services-section.md +63 -7
  8. package/.claude/skills/components/tab-navigation.md +49 -0
  9. package/.claude/skills/index.md +2 -2
  10. package/.claude/skills/testing-add-unit-test.md +18 -0
  11. package/app/components/01.atoms/grids/data-grid/AutoGrid.vue +1 -2
  12. package/app/components/01.atoms/grids/data-grid/tests/AutoGrid.spec.ts +11 -19
  13. package/app/components/01.atoms/grids/data-grid/tests/__snapshots__/AutoGrid.spec.ts.snap +1 -1
  14. package/app/components/01.atoms/page-row/tests/PageRow.spec.ts +28 -3
  15. package/app/components/02.molecules/profile-section/tests/ProfileSection.spec.ts +2 -2
  16. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesSectionGrid.spec.ts.snap +18 -18
  17. package/app/components/03.organisms/services/services-section/CONSUMER-STYLING.md +149 -0
  18. package/app/components/03.organisms/services/services-section/ServicesSection.vue +85 -60
  19. package/app/components/03.organisms/services/services-section/stories/ServicesSection.stories.ts +186 -32
  20. package/app/components/03.organisms/services/services-section/tests/ServicesSection.spec.ts +83 -0
  21. package/app/components/03.organisms/services/services-section/tests/__snapshots__/ServicesSection.spec.ts.snap +11 -11
  22. package/app/components/04.templates/page-hero-highlights/tests/PageHeroHighlights.spec.ts +2 -2
  23. package/app/composables/tests/useAriaLabelledById.spec.ts +7 -2
  24. package/app/composables/useAriaLabelledById.ts +18 -1
  25. package/package.json +1 -1
@@ -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: "locs-installation",
72
- category: "locs",
73
- title: "Locs Installation",
74
- subtitle: "Start your loc journey",
75
- price: "£120",
76
- duration: "34 hours",
77
- image: "https://picsum.photos/seed/locs/800/600",
78
- shortDescription: "Professional loc installation tailored to your hair type.",
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.52.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
- "Our loc installation service covers everything from initial consultation through to the finished style. We work with all hair types and lengths to create beautiful, long-lasting locs.",
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: "Why choose ", styleClass: "normal" },
83
- { text: "locs?", styleClass: "accent" },
111
+ { text: "Full colour vs. ", styleClass: "normal" },
112
+ { text: "highlights?", styleClass: "accent" },
84
113
  ],
85
114
  whatIsIt:
86
- "Locs are a protective hairstyle formed by allowing hair to naturally matt and coil over time. They require minimal daily maintenance and are suitable for all hair types.",
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 to assess hair type and length",
89
- "Scalp preparation and cleanse",
90
- "Sectioning and palm-rolling",
91
- "Drying and finishing",
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
- "Anyone looking for a low-maintenance protective style",
95
- "Those with natural hair wanting a permanent style",
96
- "People seeking a versatile long-term hairstyle",
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
- "Re-twist every 4–6 weeks to maintain neat roots. Keep scalp moisturised and protect hair at night with a satin bonnet.",
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: "How long do locs take to mature?",
103
- answer: "Locs typically take 12–18 months to fully mature depending on hair type and texture.",
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 I wash my locs?",
107
- answer: "Yes — regular washing is encouraged. We recommend a residue-free shampoo every 1–2 weeks.",
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: "Locs Installation | Professional Hair Locs Service",
149
+ seoTitle: "Full Colour | Luxury Locs by Natasha",
111
150
  seoDescription:
112
- "Professional locs installation service. Book a consultation today and start your loc journey with an experienced stylist.",
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
- <button style="margin-top:1.6rem;padding:1.2rem 2.4rem;background:#333;color:#fff;border:none;border-radius:0.4rem;cursor:pointer;">
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 GlassPanel.",
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="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="image" src="/_ipx/_/images/test.jpg"></div>
7
- <div class="info-wrapper">
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="price-duration">
13
- <div class="flex-row"><span class="iconify i-mdi:clock-time-four-outline decorator" aria-hidden="true"></span><span>60 mins</span></div>
14
- <div class="flex-row"><span class="iconify i-mdi:currency-gbp decorator" aria-hidden="true"></span><span>£50</span></div>
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-faq" id="faq">
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 highlights appointment?</span>
77
+ <!--v-if--><span class="text-block-0 normal">Ready to book your appointment?</span>
78
78
  </h2>
79
- <p class="page-body-normal">Mobile service across Bath I come to you.</p>
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("adds aria-labelledby when tag is main", async () => {
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")).toBeTruthy();
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", "main", "article", "aside"])(
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
- const LABELLED_TAGS = new Set(["section", "main", "article", "aside"]);
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
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "srcdev-nuxt-components",
3
3
  "type": "module",
4
- "version": "9.1.58",
4
+ "version": "9.2.0",
5
5
  "main": "nuxt.config.ts",
6
6
  "types": "types.d.ts",
7
7
  "license": "MIT",