srcdev-nuxt-components 9.1.59 → 9.2.1

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 (28) hide show
  1. package/.claude/settings.json +5 -2
  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/banner-video.md +88 -23
  5. package/.claude/skills/components/page-hero-highlights.md +3 -1
  6. package/.claude/skills/components/page-row.md +14 -5
  7. package/.claude/skills/components/profile-section.md +1 -1
  8. package/.claude/skills/components/services-section.md +1 -1
  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/banner-video/BannerVideo.vue +62 -29
  12. package/app/components/01.atoms/banner-video/CONSUMER-STYLING.md +120 -0
  13. package/app/components/01.atoms/banner-video/stories/BannerVideo.stories.ts +53 -2
  14. package/app/components/01.atoms/banner-video/tests/BannerVideo.spec.ts +95 -0
  15. package/app/components/01.atoms/banner-video/tests/__snapshots__/BannerVideo.spec.ts.snap +2 -2
  16. package/app/components/01.atoms/grids/data-grid/AutoGrid.vue +1 -2
  17. package/app/components/01.atoms/grids/data-grid/tests/AutoGrid.spec.ts +11 -19
  18. package/app/components/01.atoms/grids/data-grid/tests/__snapshots__/AutoGrid.spec.ts.snap +1 -1
  19. package/app/components/01.atoms/page-row/tests/PageRow.spec.ts +28 -3
  20. package/app/components/02.molecules/profile-section/tests/ProfileSection.spec.ts +2 -2
  21. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesSectionGrid.spec.ts.snap +3 -3
  22. package/app/components/03.organisms/services/services-section/ServicesSection.vue +2 -1
  23. package/app/components/03.organisms/services/services-section/tests/ServicesSection.spec.ts +12 -0
  24. package/app/components/03.organisms/services/services-section/tests/__snapshots__/ServicesSection.spec.ts.snap +1 -1
  25. package/app/components/04.templates/page-hero-highlights/tests/PageHeroHighlights.spec.ts +2 -2
  26. package/app/composables/tests/useAriaLabelledById.spec.ts +7 -2
  27. package/app/composables/useAriaLabelledById.ts +18 -1
  28. package/package.json +1 -1
@@ -0,0 +1,120 @@
1
+ # BannerVideo — Consumer Styling Guide
2
+
3
+ ## Public token API
4
+
5
+ All `--banner-video-*` tokens are the stable override surface. Set them globally in a theme
6
+ file, scoped to a page wrapper, or per-instance via `styleClassPassthrough`.
7
+
8
+ ### Depth tier max-height
9
+
10
+ | Token | Default | Controls |
11
+ |---|---|---|
12
+ | `--banner-video-max-height-xs` | `clamp(12rem, 15vw, 24rem)` | Max height at `depth="xs"` |
13
+ | `--banner-video-max-height-sm` | `clamp(18rem, 22vw, 36rem)` | Max height at `depth="sm"` |
14
+ | `--banner-video-max-height-md` | `clamp(28rem, 38vw, 56rem)` | Max height at `depth="md"` (default) |
15
+ | `--banner-video-max-height-lg` | `clamp(40rem, 52vw, 72rem)` | Max height at `depth="lg"` |
16
+ | `--banner-video-max-height-xl` | `clamp(52rem, 65vw, 90rem)` | Max height at `depth="xl"` |
17
+ | `--banner-video-max-height` | `clamp(28rem, 38vw, 56rem)` | Fallback used if the active depth tier's own token isn't set |
18
+
19
+ ### Pause/play toggle button
20
+
21
+ | Token | Default | Controls |
22
+ |---|---|---|
23
+ | `--banner-video-toggle-size` | `3.2rem` | Width and height of the toggle button |
24
+ | `--banner-video-toggle-offset` | `1.2rem` | Margin from the bottom-right corner of the banner |
25
+ | `--banner-video-toggle-background` | `oklch(0% 0 0 / 0.4)` | Button background |
26
+ | `--banner-video-toggle-background-hover` | `oklch(0% 0 0 / 0.6)` | Button background on hover/focus |
27
+ | `--banner-video-toggle-icon-color` | `white` | Icon colour (and any text colour, via `color`) |
28
+
29
+ ---
30
+
31
+ ## Icon and content overrides — props and slots, not CSS
32
+
33
+ The toggle's icon is prop-driven, not hardcoded:
34
+
35
+ ```vue
36
+ <BannerVideo
37
+ src="/videos/hero.mp4"
38
+ poster="/images/hero-poster.jpg"
39
+ play-icon="mdi:play-circle"
40
+ pause-icon="mdi:pause-circle"
41
+ />
42
+ ```
43
+
44
+ For anything beyond swapping the icon name (e.g. a custom SVG, or an icon set the `Icon`
45
+ component doesn't cover), replace it with the `toggle-icon` scoped slot, which receives
46
+ `isPlaying`:
47
+
48
+ ```vue
49
+ <BannerVideo src="/videos/hero.mp4" poster="/images/hero-poster.jpg">
50
+ <template #toggle-icon="{ isPlaying }">
51
+ <MyCustomIcon :name="isPlaying ? 'pause' : 'play'" />
52
+ </template>
53
+ </BannerVideo>
54
+ ```
55
+
56
+ ---
57
+
58
+ ## Global theming — recommended approach
59
+
60
+ Create `assets/styles/setup/07.components/banner-video.css` in the consuming app and set
61
+ tokens on `:root`. This applies to every `BannerVideo` across the site.
62
+
63
+ ```css
64
+ /* assets/styles/setup/07.components/banner-video.css */
65
+ :root {
66
+ --banner-video-max-height-md: clamp(32rem, 45vw, 64rem);
67
+ --banner-video-toggle-background: var(--brand-overlay);
68
+ --banner-video-toggle-background-hover: var(--brand-overlay-strong);
69
+ --banner-video-toggle-icon-color: var(--brand-on-overlay);
70
+ }
71
+ ```
72
+
73
+ ---
74
+
75
+ ## Page-scoped overrides
76
+
77
+ Override tokens for a specific banner by scoping them under the page or layout wrapper.
78
+ No `:deep()` is required (component styles are unscoped).
79
+
80
+ ```css
81
+ /* In the consuming page's unscoped <style> block */
82
+ .homepage-hero {
83
+ .banner-video {
84
+ --banner-video-max-height-xl: 80rem;
85
+ --banner-video-toggle-size: 4rem;
86
+ --banner-video-toggle-offset: 2rem;
87
+ }
88
+ }
89
+ ```
90
+
91
+ ---
92
+
93
+ ## Per-instance overrides via styleClassPassthrough
94
+
95
+ Use sparingly — prefer global or page-scoped CSS. When a single instance needs a distinct
96
+ visual style, pass a modifier class:
97
+
98
+ ```vue
99
+ <BannerVideo :style-class-passthrough="['quiet-toggle']" src="…" poster="…" />
100
+ ```
101
+
102
+ ```css
103
+ .banner-video.quiet-toggle {
104
+ --banner-video-toggle-background: transparent;
105
+ --banner-video-toggle-background-hover: oklch(0% 0 0 / 0.3);
106
+ }
107
+ ```
108
+
109
+ ---
110
+
111
+ ## Notes
112
+
113
+ - `--banner-video-max-height` (no suffix) only applies if the active `data-depth` tier's own
114
+ token isn't set — in practice this only matters if you delete a depth tier's token without
115
+ replacing it. Prefer setting the specific `-{depth}` token you need.
116
+ - The toggle button's `border-radius` (`100vw`, i.e. a full circle) and `transition-duration`
117
+ (`0.2s`) are not tokenized — they're low-variance visual details rather than something
118
+ consuming apps are expected to reskin per-brand.
119
+ - `aspectRatio`, `objectFit`, `verticalPosition`, and `horizontalPosition` are props, not
120
+ tokens — set them directly on the component.
@@ -39,7 +39,7 @@ const meta: Meta<typeof BannerVideo> = {
39
39
  depth: {
40
40
  control: "select",
41
41
  options: ["xs", "sm", "md", "lg", "xl"],
42
- description: "Responsive max-height tier. Each maps to a clamp() scale; override via --theme-banner-video-max-height-{depth}",
42
+ description: "Responsive max-height tier. Each maps to a clamp() scale; override via --banner-video-max-height-{depth}",
43
43
  table: { category: "Layout" },
44
44
  },
45
45
  aspectRatio: {
@@ -65,6 +65,16 @@ const meta: Meta<typeof BannerVideo> = {
65
65
  description: "Horizontal crop position — start (left), center, or end (right). Maps to object-position on the fallback image",
66
66
  table: { category: "Appearance" },
67
67
  },
68
+ playIcon: {
69
+ control: "text",
70
+ description: "Icon name for the toggle button before playback starts (any Iconify icon)",
71
+ table: { category: "Appearance" },
72
+ },
73
+ pauseIcon: {
74
+ control: "text",
75
+ description: "Icon name for the toggle button once playback starts (any Iconify icon)",
76
+ table: { category: "Appearance" },
77
+ },
68
78
  styleClassPassthrough: {
69
79
  table: { disable: true },
70
80
  },
@@ -73,7 +83,7 @@ const meta: Meta<typeof BannerVideo> = {
73
83
  docs: {
74
84
  description: {
75
85
  component:
76
- "A full-width banner that plays a muted, looping mp4 video. The poster image is shown before the video loads, when the video fails to play, and whenever the user has `prefers-reduced-motion: reduce` set. The banner is sized via `aspect-ratio` with a responsive `max-height` driven by the `depth` tier (`xs` → `xl`). Each tier uses a `clamp()` scale and exposes a `--theme-banner-video-max-height-{depth}` token for consuming pages to override. `objectFit` controls how media fills the frame. `verticalPosition` and `horizontalPosition` control the crop focal point.",
86
+ "A full-width banner that plays a muted, looping mp4 video. The poster image is shown before the video loads, when the video fails to play, and whenever the user has `prefers-reduced-motion: reduce` set. The banner is sized via `aspect-ratio` with a responsive `max-height` driven by the `depth` tier (`xs` → `xl`). Each tier uses a `clamp()` scale and exposes a `--banner-video-max-height-{depth}` token for consuming pages to override. `objectFit` controls how media fills the frame. `verticalPosition` and `horizontalPosition` control the crop focal point. The pause/play toggle's icon is configurable via `playIcon`/`pauseIcon` props, or fully replaceable via the `toggle-icon` scoped slot.",
77
87
  },
78
88
  },
79
89
  },
@@ -178,3 +188,44 @@ export const Standard169: Story = {
178
188
  },
179
189
  },
180
190
  };
191
+
192
+ export const CustomToggleIcons: Story = {
193
+ args: {
194
+ ...Default.args,
195
+ playIcon: "mdi:play-circle",
196
+ pauseIcon: "mdi:pause-circle",
197
+ },
198
+ parameters: {
199
+ docs: {
200
+ description: {
201
+ story: "Custom icon names passed via playIcon and pauseIcon props — swaps the toggle button's icon without touching markup.",
202
+ },
203
+ },
204
+ },
205
+ };
206
+
207
+ export const CustomToggleIconSlot: Story = {
208
+ args: {
209
+ ...Default.args,
210
+ },
211
+ render: (args) => ({
212
+ components: { BannerVideo },
213
+ setup() {
214
+ return { args };
215
+ },
216
+ template: `
217
+ <BannerVideo v-bind="args">
218
+ <template #toggle-icon="{ isPlaying }">
219
+ <span style="font-size:1.4rem;font-weight:700;">{{ isPlaying ? 'II' : '▶' }}</span>
220
+ </template>
221
+ </BannerVideo>
222
+ `,
223
+ }),
224
+ parameters: {
225
+ docs: {
226
+ description: {
227
+ story: "The toggle-icon scoped slot replaces the toggle button's icon entirely, receiving isPlaying — use when an Iconify icon name isn't enough (a custom SVG, or a different icon set).",
228
+ },
229
+ },
230
+ },
231
+ };
@@ -127,6 +127,51 @@ describe("BannerVideo", () => {
127
127
  expect(play).toHaveBeenCalled();
128
128
  });
129
129
 
130
+ // ─── Pause/play toggle (WCAG 2.2.2) ─────────────────────────────────────
131
+
132
+ it("renders a pause/play toggle button", async () => {
133
+ const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
134
+ expect(wrapper.find(".banner-video__toggle").exists()).toBe(true);
135
+ });
136
+
137
+ it("toggle defaults to a 'Pause background video' label once playback starts", async () => {
138
+ const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
139
+ await wrapper.find("video").trigger("play");
140
+ expect(wrapper.find(".banner-video__toggle").attributes("aria-label")).toBe("Pause background video");
141
+ });
142
+
143
+ it("toggle reads 'Play background video' before playback has started", async () => {
144
+ const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
145
+ expect(wrapper.find(".banner-video__toggle").attributes("aria-label")).toBe("Play background video");
146
+ });
147
+
148
+ it("clicking the toggle calls pause() while playing", async () => {
149
+ const pause = vi.fn();
150
+ const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
151
+ const videoElement = wrapper.find("video").element as HTMLVideoElement;
152
+ videoElement.pause = pause;
153
+ Object.defineProperty(videoElement, "paused", { value: false, configurable: true });
154
+ await wrapper.find(".banner-video__toggle").trigger("click");
155
+ expect(pause).toHaveBeenCalled();
156
+ });
157
+
158
+ it("clicking the toggle calls play() while paused", async () => {
159
+ const play = vi.fn().mockResolvedValue(undefined);
160
+ const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
161
+ const videoElement = wrapper.find("video").element as HTMLVideoElement;
162
+ videoElement.play = play;
163
+ Object.defineProperty(videoElement, "paused", { value: true, configurable: true });
164
+ await wrapper.find(".banner-video__toggle").trigger("click");
165
+ expect(play).toHaveBeenCalled();
166
+ });
167
+
168
+ it("updates aria-label to 'Play background video' after the video pauses", async () => {
169
+ const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
170
+ await wrapper.find("video").trigger("play");
171
+ await wrapper.find("video").trigger("pause");
172
+ expect(wrapper.find(".banner-video__toggle").attributes("aria-label")).toBe("Play background video");
173
+ });
174
+
130
175
  // ─── Fallback image ──────────────────────────────────────────────────────
131
176
 
132
177
  it("renders a NuxtImg fallback element", async () => {
@@ -349,4 +394,54 @@ describe("BannerVideo", () => {
349
394
  expect(wrapper.classes()).not.toContain("original");
350
395
  expect(wrapper.classes()).toContain("updated");
351
396
  });
397
+
398
+ // ─── Toggle icon ─────────────────────────────────────────────────────────
399
+
400
+ it("renders the default play icon before playback starts", async () => {
401
+ const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
402
+ expect(wrapper.find(".banner-video__toggle").html()).toContain("mdi:play");
403
+ });
404
+
405
+ it("renders the default pause icon once playback starts", async () => {
406
+ const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
407
+ await wrapper.find("video").trigger("play");
408
+ expect(wrapper.find(".banner-video__toggle").html()).toContain("mdi:pause");
409
+ });
410
+
411
+ it("renders a custom playIcon", async () => {
412
+ const wrapper = await mountSuspended(BannerVideo, {
413
+ props: { ...defaultProps, playIcon: "mdi:play-circle" },
414
+ });
415
+ expect(wrapper.find(".banner-video__toggle").html()).toContain("mdi:play-circle");
416
+ });
417
+
418
+ it("renders a custom pauseIcon", async () => {
419
+ const wrapper = await mountSuspended(BannerVideo, {
420
+ props: { ...defaultProps, pauseIcon: "mdi:pause-circle" },
421
+ });
422
+ await wrapper.find("video").trigger("play");
423
+ expect(wrapper.find(".banner-video__toggle").html()).toContain("mdi:pause-circle");
424
+ });
425
+
426
+ it("replaces the toggle icon via the toggle-icon slot", async () => {
427
+ const wrapper = await mountSuspended(BannerVideo, {
428
+ props: defaultProps,
429
+ slots: {
430
+ "toggle-icon": '<span class="test-custom-icon">custom</span>',
431
+ },
432
+ });
433
+ expect(wrapper.find(".test-custom-icon").exists()).toBe(true);
434
+ });
435
+
436
+ it("passes isPlaying to the toggle-icon slot", async () => {
437
+ const wrapper = await mountSuspended(BannerVideo, {
438
+ props: defaultProps,
439
+ slots: {
440
+ "toggle-icon": `<span class="test-is-playing">{{ params.isPlaying }}</span>`,
441
+ },
442
+ });
443
+ expect(wrapper.find(".test-is-playing").text()).toBe("false");
444
+ await wrapper.find("video").trigger("play");
445
+ expect(wrapper.find(".test-is-playing").text()).toBe("true");
446
+ });
352
447
  });
@@ -3,11 +3,11 @@
3
3
  exports[`BannerVideo > renders correct HTML structure (all props set) 1`] = `
4
4
  "<header class="banner-video full-bleed" data-depth="xl" style="--_aspect-ratio: 16/9; --_align-self: center; --_justify-self: center;"><video class="video" autoplay="" muted="" loop="" playsinline="" preload="auto" fetchpriority="high" poster="/images/banners/video/lake-banner.jpg" style="object-fit: cover;">
5
5
  <source src="/images/banners/video/lake-banner.mp4" type="video/mp4">
6
- </video><img width="1280" height="720" data-nuxt-img="" srcset="/_ipx/s_1280x720/images/banners/video/lake-banner.jpg 1x, /_ipx/s_2560x1440/images/banners/video/lake-banner.jpg 2x" class="fallback" alt="Lake banner" loading="eager" fetchpriority="high" decoding="async" style="object-fit: cover; object-position: center center;" src="/_ipx/s_1280x720/images/banners/video/lake-banner.jpg"></header>"
6
+ </video><img width="1280" height="720" data-nuxt-img="" srcset="/_ipx/s_1280x720/images/banners/video/lake-banner.jpg 1x, /_ipx/s_2560x1440/images/banners/video/lake-banner.jpg 2x" class="fallback" alt="Lake banner" loading="eager" fetchpriority="high" decoding="async" style="object-fit: cover; object-position: center center;" src="/_ipx/s_1280x720/images/banners/video/lake-banner.jpg"><button type="button" class="banner-video__toggle" aria-label="Play background video"><span class="iconify i-mdi:play" aria-hidden="true"></span></button></header>"
7
7
  `;
8
8
 
9
9
  exports[`BannerVideo > renders correct HTML structure (default props) 1`] = `
10
10
  "<div class="banner-video" data-depth="md" style="--_aspect-ratio: 21/9; --_align-self: center; --_justify-self: center;"><video class="video" autoplay="" muted="" loop="" playsinline="" preload="auto" fetchpriority="high" poster="/images/banners/video/lake-banner.jpg" style="object-fit: cover;">
11
11
  <source src="/images/banners/video/lake-banner.mp4" type="video/mp4">
12
- </video><img width="1920" height="1080" data-nuxt-img="" srcset="/_ipx/s_1920x1080/images/banners/video/lake-banner.jpg 1x, /_ipx/s_3840x2160/images/banners/video/lake-banner.jpg 2x" class="fallback" alt="" loading="eager" fetchpriority="high" decoding="async" style="object-fit: cover; object-position: center center;" src="/_ipx/s_1920x1080/images/banners/video/lake-banner.jpg"></div>"
12
+ </video><img width="1920" height="1080" data-nuxt-img="" srcset="/_ipx/s_1920x1080/images/banners/video/lake-banner.jpg 1x, /_ipx/s_3840x2160/images/banners/video/lake-banner.jpg 2x" class="fallback" alt="" loading="eager" fetchpriority="high" decoding="async" style="object-fit: cover; object-position: center center;" src="/_ipx/s_1920x1080/images/banners/video/lake-banner.jpg"><button type="button" class="banner-video__toggle" aria-label="Play background video"><span class="iconify i-mdi:play" aria-hidden="true"></span></button></div>"
13
13
  `;
@@ -1,5 +1,5 @@
1
1
  <template>
2
- <component :is="tag" class="auto-grid" :class="[elementClasses, { 'is-responsive': isResponsive }]" :aria-labelledby="ariaLabelledby">
2
+ <component :is="tag" class="auto-grid" :class="[elementClasses, { 'is-responsive': isResponsive }]">
3
3
  <slot v-for="(_, name) in $slots" :key="name" :name="name"></slot>
4
4
  </component>
5
5
  </template>
@@ -18,7 +18,6 @@ const props = withDefaults(defineProps<Props>(), {
18
18
  });
19
19
 
20
20
  const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
21
- const { ariaLabelledby } = useAriaLabelledById(props.tag);
22
21
 
23
22
  watch(
24
23
  () => props.styleClassPassthrough,
@@ -64,25 +64,17 @@ describe("AutoGrid", () => {
64
64
 
65
65
  // ─── Aria ─────────────────────────────────────────────────────────────────
66
66
 
67
- it("does not set aria-labelledby when tag is div", async () => {
68
- const wrapper = await mountSuspended(AutoGrid, { props: { tag: "div" } });
69
- expect(wrapper.attributes("aria-labelledby")).toBeUndefined();
70
- });
71
-
72
- it("sets aria-labelledby when tag is section", async () => {
73
- const wrapper = await mountSuspended(AutoGrid, { props: { tag: "section" } });
74
- expect(wrapper.attributes("aria-labelledby")).toBeTruthy();
75
- });
76
-
77
- it("sets aria-labelledby when tag is article", async () => {
78
- const wrapper = await mountSuspended(AutoGrid, { props: { tag: "article" } });
79
- expect(wrapper.attributes("aria-labelledby")).toBeTruthy();
80
- });
81
-
82
- it("sets aria-labelledby when tag is main", async () => {
83
- const wrapper = await mountSuspended(AutoGrid, { props: { tag: "main" } });
84
- expect(wrapper.attributes("aria-labelledby")).toBeTruthy();
85
- });
67
+ // AutoGrid has no heading of its own, so it never auto-generates an
68
+ // aria-labelledby regardless of tag doing so would always be a broken
69
+ // ARIA reference, since there is no way for a consumer to bind a heading
70
+ // to it. Pass an explicit aria-label if a landmark tag needs a name.
71
+ it.each(["div", "section", "article", "main"] as const)(
72
+ "does not set aria-labelledby when tag is %s",
73
+ async (tag) => {
74
+ const wrapper = await mountSuspended(AutoGrid, { props: { tag } });
75
+ expect(wrapper.attributes("aria-labelledby")).toBeUndefined();
76
+ }
77
+ );
86
78
 
87
79
  // ─── isResponsive ────────────────────────────────────────────────────────
88
80
 
@@ -1,7 +1,7 @@
1
1
  // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
2
 
3
3
  exports[`AutoGrid > renders correct HTML structure (all props and slots set) 1`] = `
4
- "<section class="auto-grid custom-class is-responsive" aria-labelledby="v-0-0">
4
+ "<section class="auto-grid custom-class is-responsive">
5
5
  <div>Item 1</div>
6
6
  <div>Item 2</div>
7
7
  <div>Item 3</div>
@@ -1,4 +1,5 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { h } from "vue";
2
3
  import { mountSuspended } from "@nuxt/test-utils/runtime";
3
4
  import PageRow from "../PageRow.vue";
4
5
 
@@ -18,6 +19,9 @@ describe("PageRow", () => {
18
19
  wrapper = await mountSuspended(PageRow, {
19
20
  props: { ...props },
20
21
  slots,
22
+ // Attached to the real document so the aria-labelledby dev-warning check
23
+ // (which uses document.getElementById) can actually find slotted headings.
24
+ attachTo: document.body,
21
25
  });
22
26
  return wrapper;
23
27
  };
@@ -132,7 +136,7 @@ describe("PageRow", () => {
132
136
  expect(wrapper.attributes("aria-labelledby")).toBeUndefined();
133
137
  });
134
138
 
135
- it.each(["section", "main", "article", "aside"] as const)(
139
+ it.each(["section", "article", "aside"] as const)(
136
140
  "sets aria-labelledby on <%s>",
137
141
  async (tag) => {
138
142
  await createWrapper({ tag });
@@ -140,8 +144,8 @@ describe("PageRow", () => {
140
144
  }
141
145
  );
142
146
 
143
- it("does not set aria-labelledby on header, footer, nav", async () => {
144
- for (const tag of ["header", "footer", "nav"] as const) {
147
+ it("does not set aria-labelledby on header, footer, nav, main", async () => {
148
+ for (const tag of ["header", "footer", "nav", "main"] as const) {
145
149
  await createWrapper({ tag });
146
150
  expect(wrapper.attributes("aria-labelledby")).toBeUndefined();
147
151
  wrapper.unmount();
@@ -167,6 +171,27 @@ describe("PageRow", () => {
167
171
  await createWrapper({}, { default: '<nav aria-label="Main navigation">Nav</nav>' });
168
172
  expect(wrapper.find("nav").attributes("aria-label")).toBe("Main navigation");
169
173
  });
174
+
175
+ it("warns when aria-labelledby is set but no element binds headingId", async () => {
176
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
177
+ await createWrapper({ tag: "section" }, { default: "<p>No heading here</p>" });
178
+ await nextTick();
179
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("broken ARIA reference"));
180
+ warnSpy.mockRestore();
181
+ });
182
+
183
+ it("does not warn when the heading-id slot prop is bound to a real heading", async () => {
184
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
185
+ await createWrapper(
186
+ { tag: "section" },
187
+ {
188
+ default: (slotProps: { headingId: string }) => h("h2", { id: slotProps.headingId }, "Title"),
189
+ }
190
+ );
191
+ await nextTick();
192
+ expect(warnSpy).not.toHaveBeenCalled();
193
+ warnSpy.mockRestore();
194
+ });
170
195
  });
171
196
 
172
197
  describe("CSS classes", () => {
@@ -152,11 +152,11 @@ describe("ProfileSection", () => {
152
152
  expect(wrapper.find(".profile-section").attributes("aria-labelledby")).toBeUndefined();
153
153
  });
154
154
 
155
- it("adds aria-labelledby when tag is main", async () => {
155
+ it("does not add aria-labelledby when tag is main (main never needs an accessible name)", async () => {
156
156
  const wrapper = await mountSuspended(ProfileSection, {
157
157
  props: { ...defaultProps, tag: "main" },
158
158
  });
159
- expect(wrapper.find(".profile-section").attributes("aria-labelledby")).toBeTruthy();
159
+ expect(wrapper.find(".profile-section").attributes("aria-labelledby")).toBeUndefined();
160
160
  });
161
161
 
162
162
  it("does not render profile-links section when slot is not provided", async () => {
@@ -7,7 +7,7 @@ exports[`ServicesSectionGrid > renders correct HTML structure 1`] = `
7
7
  <div class="services-section__image-wrapper"><img data-nuxt-img="" srcset="/_ipx/_/images/locs-installation.jpg 1x, /_ipx/_/images/locs-installation.jpg 2x" alt="Locs Installation" loading="eager" fetchpriority="high" class="services-section__image" src="/_ipx/_/images/locs-installation.jpg"></div>
8
8
  <div class="services-section__info-wrapper services-section__info-wrapper--summary services-section__info-wrapper--align-center">
9
9
  <div class="eyebrow-text large">Subtitle for Locs Installation</div>
10
- <h2 class="hero-text mb-20 title axis-horizontal">
10
+ <h2 id="v-0-0-0" class="hero-text mb-20 title axis-horizontal">
11
11
  <!--v-if--><span class="text-block-0 normal">Locs Installation</span>
12
12
  </h2>
13
13
  <div class="services-section__price-duration">
@@ -34,7 +34,7 @@ exports[`ServicesSectionGrid > renders correct HTML structure 1`] = `
34
34
  <div class="services-section__image-wrapper"><img data-nuxt-img="" srcset="/_ipx/_/images/locs-retwist.jpg 1x, /_ipx/_/images/locs-retwist.jpg 2x" alt="Locs Retwist" loading="eager" fetchpriority="auto" class="services-section__image" src="/_ipx/_/images/locs-retwist.jpg"></div>
35
35
  <div class="services-section__info-wrapper services-section__info-wrapper--summary services-section__info-wrapper--align-center">
36
36
  <div class="eyebrow-text large">Subtitle for Locs Retwist</div>
37
- <h2 class="hero-text mb-20 title axis-horizontal">
37
+ <h2 id="v-0-0-1" class="hero-text mb-20 title axis-horizontal">
38
38
  <!--v-if--><span class="text-block-0 normal">Locs Retwist</span>
39
39
  </h2>
40
40
  <div class="services-section__price-duration">
@@ -61,7 +61,7 @@ exports[`ServicesSectionGrid > renders correct HTML structure 1`] = `
61
61
  <div class="services-section__image-wrapper"><img data-nuxt-img="" srcset="/_ipx/_/images/colour-treatment.jpg 1x, /_ipx/_/images/colour-treatment.jpg 2x" alt="Colour Treatment" loading="lazy" fetchpriority="auto" class="services-section__image" src="/_ipx/_/images/colour-treatment.jpg"></div>
62
62
  <div class="services-section__info-wrapper services-section__info-wrapper--summary services-section__info-wrapper--align-center">
63
63
  <div class="eyebrow-text large">Subtitle for Colour Treatment</div>
64
- <h2 class="hero-text mb-20 title axis-horizontal">
64
+ <h2 id="v-0-0-2" class="hero-text mb-20 title axis-horizontal">
65
65
  <!--v-if--><span class="text-block-0 normal">Colour Treatment</span>
66
66
  </h2>
67
67
  <div class="services-section__price-duration">
@@ -13,6 +13,7 @@
13
13
  <div class="services-section__info-wrapper" :class="infoWrapperClasses">
14
14
  <EyebrowText font-size="large" :text-content="serviceData.subtitle" />
15
15
  <HeroText
16
+ :id="headingId"
16
17
  :tag="headerTag"
17
18
  font-size="title"
18
19
  :text-content="[
@@ -191,7 +192,7 @@ const props = withDefaults(defineProps<Props>(), {
191
192
  styleClassPassthrough: () => [],
192
193
  });
193
194
 
194
- const { ariaLabelledby } = useAriaLabelledById(() => props.tag);
195
+ const { headingId, ariaLabelledby } = useAriaLabelledById(() => props.tag);
195
196
 
196
197
  const infoWrapperClasses = computed(() => {
197
198
  return {
@@ -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 () => {
@@ -6,7 +6,7 @@ exports[`ServicesSection > renders correct HTML structure 1`] = `
6
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
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
12
  <div class="services-section__price-duration">
@@ -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.59",
4
+ "version": "9.2.1",
5
5
  "main": "nuxt.config.ts",
6
6
  "types": "types.d.ts",
7
7
  "license": "MIT",