srcdev-nuxt-components 9.1.58 → 9.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.json +4 -1
- package/.claude/skills/component-aria-landmark.md +42 -8
- package/.claude/skills/components/auto-grid.md +12 -6
- package/.claude/skills/components/page-hero-highlights.md +3 -1
- package/.claude/skills/components/page-row.md +14 -5
- package/.claude/skills/components/profile-section.md +1 -1
- package/.claude/skills/components/services-section.md +63 -7
- package/.claude/skills/components/tab-navigation.md +49 -0
- package/.claude/skills/index.md +2 -2
- package/.claude/skills/testing-add-unit-test.md +18 -0
- package/app/components/01.atoms/grids/data-grid/AutoGrid.vue +1 -2
- package/app/components/01.atoms/grids/data-grid/tests/AutoGrid.spec.ts +11 -19
- package/app/components/01.atoms/grids/data-grid/tests/__snapshots__/AutoGrid.spec.ts.snap +1 -1
- package/app/components/01.atoms/page-row/tests/PageRow.spec.ts +28 -3
- package/app/components/02.molecules/profile-section/tests/ProfileSection.spec.ts +2 -2
- package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesSectionGrid.spec.ts.snap +18 -18
- package/app/components/03.organisms/services/services-section/CONSUMER-STYLING.md +149 -0
- package/app/components/03.organisms/services/services-section/ServicesSection.vue +85 -60
- package/app/components/03.organisms/services/services-section/stories/ServicesSection.stories.ts +186 -32
- package/app/components/03.organisms/services/services-section/tests/ServicesSection.spec.ts +83 -0
- package/app/components/03.organisms/services/services-section/tests/__snapshots__/ServicesSection.spec.ts.snap +11 -11
- package/app/components/04.templates/page-hero-highlights/tests/PageHeroHighlights.spec.ts +2 -2
- package/app/composables/tests/useAriaLabelledById.spec.ts +7 -2
- package/app/composables/useAriaLabelledById.ts +18 -1
- package/package.json +1 -1
package/.claude/settings.json
CHANGED
|
@@ -54,7 +54,10 @@
|
|
|
54
54
|
"Bash(SRCDEV_STANDALONE=true npx vitest run app/components/01.atoms/toast)",
|
|
55
55
|
"Bash(SRCDEV_STANDALONE=true npx vitest run app/components/02.molecules/alert-content)",
|
|
56
56
|
"Bash(SRCDEV_STANDALONE=true npx vitest run app/components/01.atoms/prompt)",
|
|
57
|
-
"Bash(git fetch *)"
|
|
57
|
+
"Bash(git fetch *)",
|
|
58
|
+
"Bash(python3 -m json.tool /Users/simoncornforth/websites/nuxt-components/.vscode/srcdev-component-services-section.code-snippets)",
|
|
59
|
+
"Bash(node -e \"JSON.parse\\(require\\('fs'\\).readFileSync\\('/Users/simoncornforth/websites/nuxt-components/.vscode/srcdev-component-services-section.code-snippets','utf8'\\)\\); console.log\\('valid json'\\)\")",
|
|
60
|
+
"WebFetch(domain:luxury-locs-by-natasha-nuxt3.vercel.app)"
|
|
58
61
|
],
|
|
59
62
|
"additionalDirectories": [
|
|
60
63
|
"/Users/simoncornforth/websites/instepreflexology",
|
|
@@ -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`, `
|
|
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`, `
|
|
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
|
-
- `
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
- `
|
|
62
|
-
- `
|
|
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
|
|
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
|
-
-
|
|
132
|
-
-
|
|
133
|
-
|
|
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`, `
|
|
88
|
-
`aria-labelledby` attribute pointing to the first heading inside.
|
|
89
|
-
to that heading's `id`
|
|
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
|
-
|
|
101
|
-
|
|
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
|
|
|
@@ -15,14 +15,40 @@ All routing and CTA decisions are delegated to the consumer via slots.
|
|
|
15
15
|
|------|------|---------|----------|
|
|
16
16
|
| `serviceData` | `Service` | — | **yes** |
|
|
17
17
|
| `tag` | `"div" \| "section" \| "article" \| "main"` | `"div"` | no |
|
|
18
|
+
| `headerTag` | `"h1" \| "h2" \| "h3"` | `"h2"` | no |
|
|
18
19
|
| `index` | `number` | `0` | no |
|
|
19
20
|
| `isSummary` | `boolean` | `false` | no |
|
|
20
21
|
| `summaryAlignment` | `"start" \| "center" \| "end"` | `"center"` | no |
|
|
21
22
|
| `reverse` | `boolean` | `false` | no |
|
|
22
23
|
| `durationIcon` | `string` | `"mdi:clock-time-four-outline"` | no |
|
|
23
24
|
| `priceIcon` | `string` | `"mdi:currency-gbp"` | no |
|
|
25
|
+
| `processHeading` | `string` | `"The Process"` | no |
|
|
26
|
+
| `idealForHeading` | `string` | `"Ideal For"` | no |
|
|
27
|
+
| `maintenanceHeading` | `string` | `"Aftercare & Maintenance"` | no |
|
|
28
|
+
| `faqsHeading` | `string` | `"Frequently Asked Questions"` | no |
|
|
29
|
+
| `ctaHeading` | `string` | `"Ready to book your appointment?"` | no |
|
|
30
|
+
| `ctaBody` | `string` | `"Get in touch to book your appointment."` | no |
|
|
24
31
|
| `styleClassPassthrough` | `string \| string[]` | `[]` | no |
|
|
25
32
|
|
|
33
|
+
### Heading and CTA copy props
|
|
34
|
+
|
|
35
|
+
All section subheadings and the default CTA panel's heading/body are props, not hardcoded
|
|
36
|
+
text — override them with wording specific to the consuming business:
|
|
37
|
+
|
|
38
|
+
```vue
|
|
39
|
+
<ServicesSection
|
|
40
|
+
:service-data="service"
|
|
41
|
+
process-heading="How It Works"
|
|
42
|
+
cta-heading="Ready to book your colour appointment?"
|
|
43
|
+
cta-body="Mobile service across Bath — I come to you."
|
|
44
|
+
/>
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Name the actual service in `ctaHeading` (rather than a generic phrase) if every service page
|
|
48
|
+
shares one CTA wrapper — a single hardcoded heading applied to every service previously caused
|
|
49
|
+
a real bug in production, where a "Colour" service page's CTA read "Ready to book your
|
|
50
|
+
**highlights** appointment?" regardless of which service was being viewed.
|
|
51
|
+
|
|
26
52
|
### Icon customisation
|
|
27
53
|
|
|
28
54
|
The two icons in the price/duration row default to `mdi:clock-time-four-outline` and `mdi:currency-gbp`. Override them for a different currency or icon style:
|
|
@@ -51,8 +77,12 @@ Flips the image to the right column and content to the left (CSS `order: 2` on t
|
|
|
51
77
|
|------|-----------|---------------|---------|
|
|
52
78
|
| `summary-link` | `{ serviceData: Service }` | `isSummary` is `true` | Navigation link below the `whatIsIt` text in summary mode |
|
|
53
79
|
| `cta` | `{ serviceData: Service }` | `isSummary` is `false` | CTA button/link inside the closing `GlassPanel` in full mode |
|
|
80
|
+
| `cta-panel` | `{ serviceData: Service, ctaHeading: string, ctaBody: string }` | `isSummary` is `false` | Replaces the entire default `GlassPanel` CTA block — use when a consumer needs a different component or layout, not just different copy |
|
|
54
81
|
|
|
55
|
-
|
|
82
|
+
All three slots receive `serviceData` as a scoped prop. `cta-panel`'s default content *is*
|
|
83
|
+
the `GlassPanel` + `ctaHeading`/`ctaBody` + `cta` slot combination described above — providing
|
|
84
|
+
`cta-panel` replaces that whole block, so the `ctaHeading`/`ctaBody` props and `cta` slot are
|
|
85
|
+
only useful if the `cta-panel` slot fallback (or the consumer's own override) still renders them.
|
|
56
86
|
|
|
57
87
|
## Summary mode usage
|
|
58
88
|
|
|
@@ -102,6 +132,21 @@ Both slots receive `serviceData` as a scoped prop.
|
|
|
102
132
|
</ServicesSection>
|
|
103
133
|
```
|
|
104
134
|
|
|
135
|
+
## Replacing the whole CTA panel (slot override)
|
|
136
|
+
|
|
137
|
+
Use `cta-panel` instead of `ctaHeading`/`ctaBody` when the CTA needs a different component
|
|
138
|
+
entirely — not just different text inside `GlassPanel`:
|
|
139
|
+
|
|
140
|
+
```vue
|
|
141
|
+
<ServicesSection :service-data="service">
|
|
142
|
+
<template #cta-panel="{ serviceData, ctaHeading, ctaBody }">
|
|
143
|
+
<MyPromoBanner :heading="ctaHeading" :body="ctaBody">
|
|
144
|
+
<template #cta><InputButtonCore button-text="Enquire Now" href="/contact" /></template>
|
|
145
|
+
</MyPromoBanner>
|
|
146
|
+
</template>
|
|
147
|
+
</ServicesSection>
|
|
148
|
+
```
|
|
149
|
+
|
|
105
150
|
## Rendering a list (summary mode)
|
|
106
151
|
|
|
107
152
|
```vue
|
|
@@ -122,9 +167,12 @@ Both slots receive `serviceData` as a scoped prop.
|
|
|
122
167
|
|
|
123
168
|
## Local style override scaffold
|
|
124
169
|
|
|
125
|
-
When consuming this component,
|
|
170
|
+
When consuming this component, prefer the `--services-section-*` CSS custom properties
|
|
171
|
+
documented in `CONSUMER-STYLING.md` (in the component's own folder) over raw class overrides.
|
|
172
|
+
For anything the tokens don't cover, scaffold a style block using `styleClassPassthrough`.
|
|
173
|
+
Delete the block if unused.
|
|
126
174
|
|
|
127
|
-
See [component-local-style-override.md](../component-local-style-override.md) for the
|
|
175
|
+
See [component-local-style-override.md](../component-local-style-override.md) for the general pattern.
|
|
128
176
|
|
|
129
177
|
```vue
|
|
130
178
|
<ServicesSection :style-class-passthrough="['my-section']" :service-data="service">
|
|
@@ -138,8 +186,10 @@ See [component-local-style-override.md](../component-local-style-override.md) fo
|
|
|
138
186
|
─────────────────────────────────────────────────────────────────── */
|
|
139
187
|
.services-section {
|
|
140
188
|
&.my-section {
|
|
141
|
-
|
|
142
|
-
|
|
189
|
+
--services-section-image-border-radius: 1.2rem;
|
|
190
|
+
|
|
191
|
+
/* Deeper overrides target the BEM element classes directly, e.g.: */
|
|
192
|
+
/* .services-section__faq { } */
|
|
143
193
|
}
|
|
144
194
|
}
|
|
145
195
|
</style>
|
|
@@ -150,6 +200,12 @@ See [component-local-style-override.md](../component-local-style-override.md) fo
|
|
|
150
200
|
- Component is auto-imported in Nuxt — no import needed.
|
|
151
201
|
- The `Service` type is imported from `~/types/types.services`.
|
|
152
202
|
- `summary-link` slot is guarded by `v-if="isSummary"` — it will not render in full mode even if provided.
|
|
153
|
-
- `cta`
|
|
154
|
-
-
|
|
203
|
+
- `cta` and `cta-panel` slots live inside `v-if="!isSummary"` — neither renders in summary mode.
|
|
204
|
+
- Internal element classes are BEM-namespaced: `services-section__image-wrapper`,
|
|
205
|
+
`services-section__info-wrapper`, `services-section__price-duration`,
|
|
206
|
+
`services-section__decorator`, `services-section__faq`, `services-section__faq-answer`,
|
|
207
|
+
`services-section__glass-panel`. If a consuming app has CSS overrides referencing older
|
|
208
|
+
unprefixed names (`.image-wrapper`, `.price-duration`, `.services-faq`, etc. — from before
|
|
209
|
+
this component's classnames were namespaced), those selectors need updating to match.
|
|
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).)
|
|
155
211
|
- `summaryAlignment` only has effect when `isSummary` is `true` — it aligns the info-wrapper content vertically within the grid cell.
|
|
@@ -220,3 +220,52 @@ the component source folder for the full token reference.
|
|
|
220
220
|
stacking-context constraint.
|
|
221
221
|
- `--tab-nav-panel-bg` should match `--page-bg` for a seamless panel appearance in the collapsed
|
|
222
222
|
state.
|
|
223
|
+
|
|
224
|
+
## Known issue: SSR/hydration layout shift when the nav needs to collapse (2026-07-07)
|
|
225
|
+
|
|
226
|
+
**Symptom:** on first page load (hard reload/direct navigation), the header briefly renders taller
|
|
227
|
+
than its settled state, then shrinks — visibly shifting all content below it up. Most noticeable on
|
|
228
|
+
short pages (little content below the fold makes the jump occupy a large fraction of the viewport),
|
|
229
|
+
but the same underlying reflow happens on every page where the nav actually needs to collapse at
|
|
230
|
+
the viewport's width; it's just less visible on long pages or when the timing happens to resolve
|
|
231
|
+
before first paint.
|
|
232
|
+
|
|
233
|
+
**Root cause:** `useNavCollapse`'s `isLoaded` ref (and therefore `isCollapsed`) can only be
|
|
234
|
+
determined client-side — the server has no way to measure real DOM/viewport widths. So:
|
|
235
|
+
|
|
236
|
+
```vue
|
|
237
|
+
<ul v-if="!isCollapsed || !isLoaded" ...>
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
SSR and the very first client paint always render the **full, uncollapsed** `<ul>` (since
|
|
241
|
+
`!isLoaded` is true pre-mount), regardless of whether it will actually fit. Once mounted,
|
|
242
|
+
`checkOverflow()` runs (in `onMounted`, after `nextTick()`), and if the nav overflows, sets
|
|
243
|
+
`isCollapsed = true` — swapping the full list out for the shorter collapsed/burger variant. That
|
|
244
|
+
swap is what shrinks the header and shifts everything below it.
|
|
245
|
+
|
|
246
|
+
Confirmed via `PerformanceObserver({type: "layout-shift"})` plus DOM `MutationObserver` tracing in a
|
|
247
|
+
consuming app (`luxury-locs-by-natasha-nuxt3`): the class mutation on `.tab-navigation` (adding
|
|
248
|
+
`is-loaded is-animated`) lines up exactly with `.main-content`'s bounding-rect top moving by the
|
|
249
|
+
header's full height delta, at a variable point (roughly 100ms–800ms after first paint depending on
|
|
250
|
+
hydration timing) after first paint.
|
|
251
|
+
|
|
252
|
+
**Candidate fixes (not yet implemented — pick up later):**
|
|
253
|
+
|
|
254
|
+
1. **CSS-only collapse instead of JS-measured overflow.** Use a `@container` or `@media` breakpoint
|
|
255
|
+
to switch between the full nav and the burger button, so the correct variant renders on the very
|
|
256
|
+
first paint (both server and client agree, no measurement round-trip needed). Loses the
|
|
257
|
+
"collapse exactly when it overflows, regardless of item count" precision of the current
|
|
258
|
+
JS-measured approach, gains zero-reflow correctness. Probably the most robust fix if a
|
|
259
|
+
reasonably conservative breakpoint can be chosen.
|
|
260
|
+
2. **Reserve height instead of eliminating the swap.** Give the header a `min-height` matching the
|
|
261
|
+
taller (uncollapsed) state so the swap doesn't change the header's box size — trades a visible
|
|
262
|
+
"jump" for a slightly awkward gap during the pre-collapse window, but never moves content below
|
|
263
|
+
it. Cheap, but doesn't fix the actual root cause, just its visible symptom.
|
|
264
|
+
3. **Suppress the flash rather than the shift**, e.g. hide the header's nav content entirely
|
|
265
|
+
(`visibility: hidden` or `opacity: 0`) until `isLoaded` is true, then fade in — avoids showing
|
|
266
|
+
the "wrong" state at all, but delays when the nav becomes visible/interactive, and would need
|
|
267
|
+
care to avoid its own CLS/accessibility issues (e.g. focus order, screen readers encountering
|
|
268
|
+
hidden nav).
|
|
269
|
+
4. Investigate whether a plausible default guess for `isCollapsed` could be derived from something
|
|
270
|
+
SSR does have access to (e.g. a `Sec-CH-Viewport-Width` client hint header, if the deployment
|
|
271
|
+
target reliably sends one) — likely not worth the complexity/fragility versus option 1.
|
package/.claude/skills/index.md
CHANGED
|
@@ -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/
|
|
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()
|
|
@@ -67,7 +67,7 @@ Each skill is a single markdown file named `<area>-<task>.md`.
|
|
|
67
67
|
├── services-card.md — ServicesCard props (incl. eyebrowConfig/heroConfig), actions slot, CSS tokens, page boilerplate
|
|
68
68
|
├── services-card-grid.md — ServicesCardGrid props, config pass-through, CSS tokens, full page boilerplate
|
|
69
69
|
├── services-section-grid.md — ServicesSectionGrid props, useAlternateReverse zigzag layout, page boilerplate
|
|
70
|
-
├── services-section.md — ServicesSection props, summary-link/cta slots, summary vs full mode
|
|
70
|
+
├── services-section.md — ServicesSection props (incl. heading/CTA copy), summary-link/cta/cta-panel slots, summary vs full mode
|
|
71
71
|
├── contact-section.md — ContactSection props (stepperIndicatorSize pass-through), 3-item info+form layout, slot API
|
|
72
72
|
├── stepper-list.md — StepperList dynamic slots (item-{n}/indicator-{n}), props, connector behaviour
|
|
73
73
|
├── expanding-panel.md — ExpandingPanel v-model, forceOpened, slots (summary/icon/content), ARIA wiring
|
|
@@ -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 }]"
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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"
|
|
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", "
|
|
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("
|
|
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")).
|
|
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 () => {
|
|
@@ -4,15 +4,15 @@ exports[`ServicesSectionGrid > renders correct HTML structure 1`] = `
|
|
|
4
4
|
"<div class="services-grid">
|
|
5
5
|
<div class="services-section">
|
|
6
6
|
<div class="services-section__grid">
|
|
7
|
-
<div class="
|
|
8
|
-
<div class="
|
|
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
|
+
<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
|
-
<div class="
|
|
14
|
-
<div class="flex-row"><span class="iconify i-mdi:clock-time-four-outline
|
|
15
|
-
<div class="flex-row"><span class="iconify i-mdi:currency-gbp
|
|
13
|
+
<div class="services-section__price-duration">
|
|
14
|
+
<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>
|
|
15
|
+
<div class="flex-row"><span class="iconify i-mdi:currency-gbp services-section__decorator" aria-hidden="true"></span><span>£50</span></div>
|
|
16
16
|
</div>
|
|
17
17
|
<!--v-if-->
|
|
18
18
|
<!--v-if-->
|
|
@@ -31,15 +31,15 @@ exports[`ServicesSectionGrid > renders correct HTML structure 1`] = `
|
|
|
31
31
|
</div>
|
|
32
32
|
<div class="services-section">
|
|
33
33
|
<div class="services-section__grid">
|
|
34
|
-
<div class="
|
|
35
|
-
<div class="
|
|
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
|
+
<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
|
-
<div class="
|
|
41
|
-
<div class="flex-row"><span class="iconify i-mdi:clock-time-four-outline
|
|
42
|
-
<div class="flex-row"><span class="iconify i-mdi:currency-gbp
|
|
40
|
+
<div class="services-section__price-duration">
|
|
41
|
+
<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>
|
|
42
|
+
<div class="flex-row"><span class="iconify i-mdi:currency-gbp services-section__decorator" aria-hidden="true"></span><span>£50</span></div>
|
|
43
43
|
</div>
|
|
44
44
|
<!--v-if-->
|
|
45
45
|
<!--v-if-->
|
|
@@ -58,15 +58,15 @@ exports[`ServicesSectionGrid > renders correct HTML structure 1`] = `
|
|
|
58
58
|
</div>
|
|
59
59
|
<div class="services-section">
|
|
60
60
|
<div class="services-section__grid">
|
|
61
|
-
<div class="
|
|
62
|
-
<div class="
|
|
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
|
+
<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
|
-
<div class="
|
|
68
|
-
<div class="flex-row"><span class="iconify i-mdi:clock-time-four-outline
|
|
69
|
-
<div class="flex-row"><span class="iconify i-mdi:currency-gbp
|
|
67
|
+
<div class="services-section__price-duration">
|
|
68
|
+
<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>
|
|
69
|
+
<div class="flex-row"><span class="iconify i-mdi:currency-gbp services-section__decorator" aria-hidden="true"></span><span>£50</span></div>
|
|
70
70
|
</div>
|
|
71
71
|
<!--v-if-->
|
|
72
72
|
<!--v-if-->
|