srcdev-nuxt-components 9.1.59 → 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 (21) hide show
  1. package/.claude/skills/component-aria-landmark.md +42 -8
  2. package/.claude/skills/components/auto-grid.md +12 -6
  3. package/.claude/skills/components/page-hero-highlights.md +3 -1
  4. package/.claude/skills/components/page-row.md +14 -5
  5. package/.claude/skills/components/profile-section.md +1 -1
  6. package/.claude/skills/components/services-section.md +1 -1
  7. package/.claude/skills/index.md +1 -1
  8. package/.claude/skills/testing-add-unit-test.md +18 -0
  9. package/app/components/01.atoms/grids/data-grid/AutoGrid.vue +1 -2
  10. package/app/components/01.atoms/grids/data-grid/tests/AutoGrid.spec.ts +11 -19
  11. package/app/components/01.atoms/grids/data-grid/tests/__snapshots__/AutoGrid.spec.ts.snap +1 -1
  12. package/app/components/01.atoms/page-row/tests/PageRow.spec.ts +28 -3
  13. package/app/components/02.molecules/profile-section/tests/ProfileSection.spec.ts +2 -2
  14. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesSectionGrid.spec.ts.snap +3 -3
  15. package/app/components/03.organisms/services/services-section/ServicesSection.vue +2 -1
  16. package/app/components/03.organisms/services/services-section/tests/ServicesSection.spec.ts +12 -0
  17. package/app/components/03.organisms/services/services-section/tests/__snapshots__/ServicesSection.spec.ts.snap +1 -1
  18. package/app/components/04.templates/page-hero-highlights/tests/PageHeroHighlights.spec.ts +2 -2
  19. package/app/composables/tests/useAriaLabelledById.spec.ts +7 -2
  20. package/app/composables/useAriaLabelledById.ts +18 -1
  21. package/package.json +1 -1
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## Overview
4
4
 
5
- Components that accept a `tag` prop may render as a semantic landmark element (`section`, `main`, `article`, `aside`). Landmarks benefit from an accessible name via `aria-labelledby` pointing to a heading inside them. The `useAriaLabelledById` composable handles this automatically.
5
+ Components that accept a `tag` prop may render as a semantic landmark element (`section`, `article`, `aside` — not `main`, see below). Landmarks benefit from an accessible name via `aria-labelledby` pointing to a heading inside them. The `useAriaLabelledById` composable handles generating and applying the attribute — but **binding the id to an actual heading is still the consumer's responsibility**; see "Built-in safety net" below for how that mistake gets caught.
6
6
 
7
7
  ## The composable
8
8
 
@@ -13,7 +13,29 @@ Returns `{ headingId, ariaLabelledby }`:
13
13
  - `headingId` — a stable ID (via `useId()`) to place on the heading element inside the slot
14
14
  - `ariaLabelledby` — computed: set to `headingId` when `tag` is a landmark, `undefined` otherwise (which removes the attribute entirely)
15
15
 
16
- Labelled tags: `section`, `main`, `article`, `aside`.
16
+ Labelled tags: `section`, `article`, `aside`.
17
+
18
+ **`main` is deliberately excluded.** A `<main>` landmark doesn't need an accessible name unless a
19
+ page has more than one — auto-labelling it produced broken references in practice (the layout's
20
+ top-level `<main>` wraps arbitrary page content with no single natural heading to bind).
21
+
22
+ ### Built-in safety net
23
+
24
+ The composable itself checks, `onMounted`, whether `document.getElementById(headingId)` actually
25
+ resolves whenever `ariaLabelledby` is set. If it doesn't, it `console.warn`s immediately with the
26
+ tag name and id. This exists because the whole pattern is opt-in for the *consumer* — the
27
+ composable has no way to force a heading to bind, so instead it makes the mistake loud instead of
28
+ silent. **Do not treat the absence of a compile-time/type error as confirmation this is wired up
29
+ correctly** — always check the console (or run an accessibility audit) after adding `tag="section"`
30
+ (or `article`/`aside`) to any component using this pattern.
31
+
32
+ This bug class previously shipped to production undetected: eight sections across one site had
33
+ `aria-labelledby` pointing at ids that were never applied to anything, only surfaced by a WAVE
34
+ audit. Two components (`ServicesSection`, `AutoGrid`) were also found to be *structurally* broken
35
+ — they set `aria-labelledby` from `tag` alone without ever exposing `headingId` anywhere a
36
+ consumer could bind it, so it was impossible to satisfy correctly. `ServicesSection` now binds
37
+ `headingId` to its own internal title heading; `AutoGrid` has no heading concept at all, so it no
38
+ longer sets `aria-labelledby` under any circumstance (pass `aria-label` directly if needed).
17
39
 
18
40
  ## Usage in a component
19
41
 
@@ -55,14 +77,26 @@ When `tag="div"` (default), the `aria-labelledby` attribute is absent and `headi
55
77
 
56
78
  ## Components already using this pattern
57
79
 
58
- - `PageHeroHighlights` (04.templates)
59
- - `ProfileSection` (02.molecules)
60
- - `ServicesSection` (03.organisms)
61
- - `LayoutGridByCols` (01.atoms)
62
- - `LayoutGridByWidth` (01.atoms)
80
+ Consumer-bound (the component exposes `headingId` via a slot prop; whoever uses the component
81
+ must bind it to their own heading):
82
+
83
+ - `PageRow` (01.atoms) — default slot
84
+ - `PageHeroHighlights` (04.templates) — `#header` slot
85
+ - `ProfileSection` (02.molecules) — `#heroText` slot
86
+
87
+ Self-bound (the component renders its own heading and binds `headingId` internally — no consumer
88
+ action needed):
89
+
90
+ - `ServicesSection` (03.organisms) — binds it to its own title `HeroText`
91
+ - `LayoutGridByCols` / `LayoutGridByWidth` (01.atoms) — render their own visually-hidden `<p>` from the `label` prop
92
+
93
+ Not using this pattern:
94
+
95
+ - `AutoGrid` (01.atoms) — has no heading concept; never sets `aria-labelledby` regardless of `tag`
63
96
 
64
97
  ## Notes
65
98
 
66
- - If a component does not expose a named slot with `:heading-id`, the `headingId` is still generated — the consumer simply places their own heading inside the slot without binding the id.
99
+ - If a component does not expose a named slot with `:heading-id`, the `headingId` is still generated — the consumer simply places their own heading inside the slot without binding the id. **This is the failure mode to watch for** — nothing prevents it at compile time, only the runtime console warning described above.
67
100
  - `headingId` is stable across renders (SSR-safe via `useId()`).
68
101
  - Do not replicate the old manual pattern (`const needsLabel = computed(() => props.tag === "section")`) — use this composable instead.
102
+ - When adding `useAriaLabelledById` to a *new* component, prefer the self-bound pattern (render the heading yourself, or a `LayoutGridByCols`-style hidden label from a `label` prop) over the consumer-bound pattern wherever the component already controls its own heading markup — it structurally cannot go wrong the way the consumer-bound pattern can.
@@ -91,11 +91,16 @@ Override `grid-template-columns` directly — there is no single token for this:
91
91
  </AutoGrid>
92
92
  ```
93
93
 
94
- ### Semantic section with auto aria-labelledby
94
+ ### Semantic section with an accessible name
95
+
96
+ `AutoGrid` has no heading concept of its own — its slots are arbitrary named items, not a
97
+ header + body — so it does **not** auto-generate `aria-labelledby` the way `PageRow` or
98
+ `ServicesSection` do (an earlier version of this component did attempt to, and it produced a
99
+ guaranteed broken ARIA reference, since there was never any way to bind a heading to it). If
100
+ `tag="section"` needs an accessible name, pass `aria-label` directly:
95
101
 
96
102
  ```vue
97
- <AutoGrid tag="section">
98
- <!-- aria-labelledby is wired automatically via useAriaLabelledById -->
103
+ <AutoGrid tag="section" aria-label="Practice stats">
99
104
  <template #item-1><div>Item 1</div></template>
100
105
  <template #item-2><div>Item 2</div></template>
101
106
  </AutoGrid>
@@ -128,9 +133,10 @@ const stats = [
128
133
 
129
134
  ## Accessibility
130
135
 
131
- - When `tag` is `section`, `article`, or `main`, `aria-labelledby` is automatically set via `useAriaLabelledById`, pointing to a generated heading ID.
132
- - When `tag="div"`, no ARIA attributes are added.
133
- - Ensure a heading element with the matching ID is present inside the grid when using semantic tags.
136
+ - `AutoGrid` never sets `aria-labelledby` automatically, regardless of `tag` it has no heading
137
+ to point to. Pass `aria-label` (or wrap it in a `PageRow`/other component that does own a
138
+ heading) if a landmark tag needs an accessible name.
139
+ - No ARIA attributes are added by default for any `tag` value.
134
140
 
135
141
  See [component-aria-landmark.md](../component-aria-landmark.md) for the full landmark pattern.
136
142
 
@@ -147,7 +147,7 @@ Omit `#end` for a single-element header — `#start` fills full width with no la
147
147
 
148
148
  ## With aria-labelledby (section tag)
149
149
 
150
- When `tag="section"`, `aria-labelledby` is set automatically. Wire the heading id via the scoped slot prop:
150
+ When `tag="section"`, `aria-labelledby` is set automatically. Wire the heading id via the scoped slot prop — if you don't, a console warning fires (from `useAriaLabelledById`) flagging the broken ARIA reference; see [component-aria-landmark.md](../component-aria-landmark.md):
151
151
 
152
152
  ```vue
153
153
  <PageHeroHighlights tag="section">
@@ -158,6 +158,8 @@ When `tag="section"`, `aria-labelledby` is set automatically. Wire the heading i
158
158
  </PageHeroHighlights>
159
159
  ```
160
160
 
161
+ `tag="main"` renders a `<main>` element but is never auto-labelled — a `<main>` landmark doesn't need an accessible name unless a page has more than one.
162
+
161
163
  See [component-aria-landmark.md](../component-aria-landmark.md) for the full pattern.
162
164
 
163
165
  ## Equal-width highlights
@@ -84,9 +84,13 @@ boundary at the track line. Useful for asymmetric imagery, pull-quotes, or decor
84
84
 
85
85
  ## Accessibility — aria-labelledby
86
86
 
87
- When `tag` is `section`, `main`, or `article`, `PageRow` automatically generates an
88
- `aria-labelledby` attribute pointing to the first heading inside. Bind the `headingId` slot prop
89
- to that heading's `id` to complete the association.
87
+ When `tag` is `section`, `article`, or `aside`, `PageRow` automatically generates an
88
+ `aria-labelledby` attribute pointing to the first heading inside. **You must bind the
89
+ `headingId` slot prop to that heading's `id`** — `PageRow` cannot verify you did this, since the
90
+ slot content is arbitrary. If you forget, the section gets an `aria-labelledby` pointing at an id
91
+ that exists nowhere in the DOM, which accessibility audits (WAVE, axe) flag as a broken ARIA
92
+ reference. A console warning fires in the browser (from `useAriaLabelledById`) the moment a
93
+ mounted instance is missing its matching heading — check the console if you see this warning.
90
94
 
91
95
  ```vue
92
96
  <PageRow tag="section">
@@ -97,8 +101,13 @@ to that heading's `id` to complete the association.
97
101
  </PageRow>
98
102
  ```
99
103
 
100
- Non-landmark tags (`div`, `header`, `footer`, `nav`) do not receive `aria-labelledby`. The
101
- `headingId` slot prop is still provided but can be ignored.
104
+ `tag="main"` is deliberately **not** auto-labelled a `<main>` landmark doesn't need an
105
+ accessible name unless a page has more than one. Other non-landmark tags (`div`, `header`,
106
+ `footer`, `nav`) also do not receive `aria-labelledby`. The `headingId` slot prop is still
107
+ provided in both cases but can be ignored.
108
+
109
+ See [component-aria-landmark.md](../component-aria-landmark.md) for the full pattern and the
110
+ list of components that share it.
102
111
 
103
112
  ---
104
113
 
@@ -8,7 +8,7 @@ type: reference
8
8
 
9
9
  ## Overview
10
10
 
11
- `ProfileSection` is a molecule that renders a practitioner/author profile: a header area (eyebrow + heading), a profile picture, and a flexible set of bio/info blocks alongside optional profile links. It is landmark-aware — the root element automatically gets `aria-labelledby` wired to the heading inside the `#heroText` slot.
11
+ `ProfileSection` is a molecule that renders a practitioner/author profile: a header area (eyebrow + heading), a profile picture, and a flexible set of bio/info blocks alongside optional profile links. It is landmark-aware — when `tag` is `section`, `article`, or `aside`, the root element automatically gets `aria-labelledby` wired to the heading inside the `#heroText` slot (**you must bind `headingId` yourself, and a console warning fires if you forget** — see [component-aria-landmark.md](../component-aria-landmark.md)). `tag="main"` is never auto-labelled.
12
12
 
13
13
  ## Props
14
14
 
@@ -207,5 +207,5 @@ See [component-local-style-override.md](../component-local-style-override.md) fo
207
207
  `services-section__glass-panel`. If a consuming app has CSS overrides referencing older
208
208
  unprefixed names (`.image-wrapper`, `.price-duration`, `.services-faq`, etc. — from before
209
209
  this component's classnames were namespaced), those selectors need updating to match.
210
- - The section gets `aria-labelledby` automatically when `tag` is `"section"` or `"article"`, pointing to the internal heading id.
210
+ - The section gets `aria-labelledby` automatically when `tag` is `"section"`, `"article"`, or `"aside"`, pointing at the id ServicesSection binds to its own title `HeroText` internally — no consumer action needed. (`tag="main"` renders a `<main>` element but is never auto-labelled; see [component-aria-landmark.md](../component-aria-landmark.md).)
211
211
  - `summaryAlignment` only has effect when `isSummary` is `true` — it aligns the info-wrapper content vertically within the grid cell.
@@ -37,7 +37,7 @@ Each skill is a single markdown file named `<area>-<task>.md`.
37
37
  ├── css-nesting-conventions.md — native CSS nesting rules: why &__child Sass BEM concatenation silently breaks, correct patterns
38
38
  ├── css-grid-max-width-gutters.md — cap a centre grid column width by growing gutters, with start/center alignment variants
39
39
  ├── css-animation-utilities.md — scroll-driven animation utility classes: scroller-x (carousel), entry-zoom-reveal, entry-slide-in, entry-exit-blur, auto-rotate
40
- ├── component-aria-landmark.md — useAriaLabelledById composable: aria-labelledby for section/main/article/aside tags
40
+ ├── component-aria-landmark.md — useAriaLabelledById composable: aria-labelledby for section/article/aside tags (not main), consumer-bound vs self-bound pattern, built-in broken-reference console warning
41
41
  ├── component-export-types.md — move inline component types to app/types/components/ barrel for consumer imports
42
42
  ├── component-inline-action-button.md — InputButtonCore variant="inline" pattern for buttons embedded in custom input wrappers
43
43
  ├── vue-video-autoplay.md — autoplay on client-side navigation: use <source> child (not :src on <video>), :key, and explicit v.load()
@@ -158,6 +158,24 @@ it("exposes headingId via scoped slot", async () => {
158
158
  });
159
159
  ```
160
160
 
161
+ ### Testing the aria-labelledby dev-warning (useAriaLabelledById)
162
+
163
+ Components using `useAriaLabelledById` (see [component-aria-landmark.md](component-aria-landmark.md))
164
+ `console.warn` on mount if `aria-labelledby` is set but no element in `document` has the matching
165
+ id. To assert this warning (or its absence) in a test, `mountSuspended` must attach to the real
166
+ document — by default VTU mounts into a detached container, so `document.getElementById` will
167
+ never find the slotted heading even when it's correctly bound, producing a false-positive warning:
168
+
169
+ ```ts
170
+ const wrapper = await mountSuspended(ComponentName, {
171
+ props: { tag: "section" },
172
+ attachTo: document.body, // required — see note above
173
+ slots: {
174
+ default: (props: { headingId: string }) => h("h2", { id: props.headingId }, "Title"),
175
+ },
176
+ });
177
+ ```
178
+
161
179
  > Returning a plain string from a slot function does **not** produce DOM — always use `h()`.
162
180
 
163
181
  ## Key rules
@@ -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.0",
5
5
  "main": "nuxt.config.ts",
6
6
  "types": "types.d.ts",
7
7
  "license": "MIT",