srcdev-nuxt-components 9.4.7 → 9.4.9
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/component-ledger/audit.json +1 -1
- package/.claude/component-ledger/output.html +1 -1
- package/.claude/skills/component-dynamic-slots.md +1 -1
- package/.claude/skills/components/header-block.md +92 -0
- package/.claude/skills/components/rotating-carousel-image.md +101 -0
- package/.claude/skills/components/skip-links.md +82 -0
- package/.claude/skills/components/tabs-core.md +187 -0
- package/.claude/skills/index.md +4 -0
- package/.vscode/srcdev-component-header-block.code-snippets +41 -0
- package/.vscode/srcdev-component-rotating-carousel-image.code-snippets +74 -0
- package/.vscode/srcdev-component-skip-links.code-snippets +55 -0
- package/.vscode/srcdev-component-tabs-core.code-snippets +78 -0
- package/app/assets/styles/setup/05.typography/02.utility-classes/_font-classes-page-heading.css +2 -1
- package/app/components/01.atoms/animations/rotating-carousel-image/CONSUMER-STYLING.md +54 -0
- package/app/components/01.atoms/animations/rotating-carousel-image/RotatingCarouselImage.vue +315 -0
- package/app/components/01.atoms/animations/rotating-carousel-image/stories/RotatingCarouselImage.stories.ts +156 -0
- package/app/components/01.atoms/animations/rotating-carousel-image/tests/RotatingCarouselImage.spec.ts +230 -0
- package/app/components/01.atoms/animations/rotating-carousel-image/tests/__snapshots__/RotatingCarouselImage.spec.ts.snap +19 -0
- package/app/components/01.atoms/navigation/skip-links/CONSUMER-STYLING.md +31 -0
- package/app/components/01.atoms/navigation/skip-links/SkipLinks.vue +98 -0
- package/app/components/01.atoms/navigation/skip-links/stories/SkipLinks.stories.ts +113 -0
- package/app/components/01.atoms/navigation/skip-links/tests/SkipLinks.spec.ts +71 -0
- package/app/components/01.atoms/navigation/skip-links/tests/__snapshots__/SkipLinks.spec.ts.snap +15 -0
- package/app/components/01.atoms/navigation/tabs/CONSUMER-STYLING.md +99 -0
- package/app/components/01.atoms/navigation/tabs/TabsCore.vue +272 -0
- package/app/components/01.atoms/navigation/tabs/stories/TabsCore.stories.ts +199 -0
- package/app/components/01.atoms/navigation/tabs/tests/TabsCore.spec.ts +274 -0
- package/app/components/01.atoms/text-blocks/header-block/CONSUMER-STYLING.md +43 -0
- package/app/components/01.atoms/text-blocks/header-block/HeaderBlock.vue +50 -0
- package/app/components/01.atoms/text-blocks/header-block/stories/HeaderBlock.stories.ts +111 -0
- package/app/components/01.atoms/text-blocks/header-block/tests/HeaderBlock.spec.ts +119 -0
- package/app/components/01.atoms/text-blocks/header-block/tests/__snapshots__/HeaderBlock.spec.ts.snap +5 -0
- package/app/components/03.organisms/site-header/SiteHeader.vue +1 -1
- package/app/components/03.organisms/site-header/tests/SiteHeader.spec.ts +1 -1
- package/app/components/03.organisms/site-header/tests/__snapshots__/SiteHeader.spec.ts.snap +3 -3
- package/app/composables/useTabs.ts +225 -207
- package/app/types/components/index.ts +2 -0
- package/app/types/components/rotating-carousel-image.d.ts +4 -0
- package/app/types/components/skip-links.d.ts +4 -0
- package/package.json +1 -1
- package/app/components/01.atoms/grids/data-grid/tests/__snapshots__/DataGrid.spec.ts.snap +0 -11
- package/app/components/rotating-carousel/RotatingCarouselImage.vue +0 -216
- package/app/components/skip-links/SkipLinks.vue +0 -60
- package/app/components/tabs/TabsCore.vue +0 -306
- package/app/components/typography/HeaderBlock.vue +0 -35
- /package/app/components/01.atoms/grids/{data-grid → auto-grid}/AutoGrid.vue +0 -0
- /package/app/components/01.atoms/grids/{data-grid → auto-grid}/stories/AutoGrid.stories.ts +0 -0
- /package/app/components/01.atoms/grids/{data-grid → auto-grid}/tests/AutoGrid.spec.ts +0 -0
- /package/app/components/01.atoms/grids/{data-grid → auto-grid}/tests/__snapshots__/AutoGrid.spec.ts.snap +0 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { mountSuspended } from "@nuxt/test-utils/runtime";
|
|
3
|
+
import TabsCore from "../TabsCore.vue";
|
|
4
|
+
|
|
5
|
+
class MockResizeObserver {
|
|
6
|
+
observe = vi.fn();
|
|
7
|
+
unobserve = vi.fn();
|
|
8
|
+
disconnect = vi.fn();
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const slots = {
|
|
12
|
+
"tab-0-trigger": "Tab One",
|
|
13
|
+
"tab-0-content": "<p class='panel-0'>Content one</p>",
|
|
14
|
+
"tab-1-trigger": "Tab Two",
|
|
15
|
+
"tab-1-content": "<p class='panel-1'>Content two</p>",
|
|
16
|
+
"tab-2-trigger": "Tab Three",
|
|
17
|
+
"tab-2-content": "<p class='panel-2'>Content three</p>",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const fiveSlots = {
|
|
21
|
+
...slots,
|
|
22
|
+
"tab-3-trigger": "Tab Four",
|
|
23
|
+
"tab-3-content": "<p class='panel-3'>Content four</p>",
|
|
24
|
+
"tab-4-trigger": "Tab Five",
|
|
25
|
+
"tab-4-content": "<p class='panel-4'>Content five</p>",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
describe("TabsCore", () => {
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
vi.stubGlobal("ResizeObserver", MockResizeObserver);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// ─── Mount ───────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
it("mounts without error", async () => {
|
|
36
|
+
const wrapper = await mountSuspended(TabsCore, { props: { itemCount: 3 }, slots });
|
|
37
|
+
expect(wrapper.vm).toBeTruthy();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("renders one trigger and one panel per itemCount", async () => {
|
|
41
|
+
const wrapper = await mountSuspended(TabsCore, { props: { itemCount: 3 }, slots });
|
|
42
|
+
expect(wrapper.findAll(".tabs-list-item")).toHaveLength(3);
|
|
43
|
+
expect(wrapper.findAll(".tab-content")).toHaveLength(3);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// ─── Accessibility structure ─────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
it("renders a tablist with the default aria-label", async () => {
|
|
49
|
+
const wrapper = await mountSuspended(TabsCore, { props: { itemCount: 3 }, slots });
|
|
50
|
+
const tablist = wrapper.find("[role='tablist']");
|
|
51
|
+
expect(tablist.exists()).toBe(true);
|
|
52
|
+
expect(tablist.attributes("aria-label")).toBe("Tabs");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("uses a custom ariaLabel when provided", async () => {
|
|
56
|
+
const wrapper = await mountSuspended(TabsCore, { props: { itemCount: 3, ariaLabel: "Settings" }, slots });
|
|
57
|
+
expect(wrapper.find("[role='tablist']").attributes("aria-label")).toBe("Settings");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("renders each trigger as a type=button with role=tab", async () => {
|
|
61
|
+
const wrapper = await mountSuspended(TabsCore, { props: { itemCount: 3 }, slots });
|
|
62
|
+
const triggers = wrapper.findAll(".tabs-list-item");
|
|
63
|
+
triggers.forEach((trigger) => {
|
|
64
|
+
expect(trigger.attributes("type")).toBe("button");
|
|
65
|
+
expect(trigger.attributes("role")).toBe("tab");
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("renders each panel as role=tabpanel labelled by its trigger", async () => {
|
|
70
|
+
const wrapper = await mountSuspended(TabsCore, { props: { itemCount: 3 }, slots });
|
|
71
|
+
const panels = wrapper.findAll(".tab-content");
|
|
72
|
+
panels.forEach((panel, index) => {
|
|
73
|
+
expect(panel.attributes("role")).toBe("tabpanel");
|
|
74
|
+
expect(panel.attributes("aria-labelledby")).toBe(`tab-${index}-trigger`);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// ─── Active tab state ────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
it("marks the first tab active and its panel visible/unhidden on mount", async () => {
|
|
81
|
+
const wrapper = await mountSuspended(TabsCore, { props: { itemCount: 3 }, slots });
|
|
82
|
+
const triggers = wrapper.findAll(".tabs-list-item");
|
|
83
|
+
const panels = wrapper.findAll(".tab-content");
|
|
84
|
+
|
|
85
|
+
expect(triggers[0]!.attributes("aria-selected")).toBe("true");
|
|
86
|
+
expect(triggers[0]!.attributes("tabindex")).toBe("0");
|
|
87
|
+
expect(triggers[1]!.attributes("aria-selected")).toBe("false");
|
|
88
|
+
expect(triggers[1]!.attributes("tabindex")).toBe("-1");
|
|
89
|
+
|
|
90
|
+
expect(panels[0]!.attributes("aria-hidden")).toBe("false");
|
|
91
|
+
expect((panels[0]!.element as HTMLElement).style.display).toBe("block");
|
|
92
|
+
expect(panels[1]!.attributes("aria-hidden")).toBe("true");
|
|
93
|
+
expect((panels[1]!.element as HTMLElement).style.display).toBe("none");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("activates a tab on click and updates aria-selected, tabindex, and panel visibility", async () => {
|
|
97
|
+
const wrapper = await mountSuspended(TabsCore, { props: { itemCount: 3 }, slots });
|
|
98
|
+
const triggers = wrapper.findAll(".tabs-list-item");
|
|
99
|
+
const panels = wrapper.findAll(".tab-content");
|
|
100
|
+
|
|
101
|
+
await triggers[1]!.trigger("click");
|
|
102
|
+
|
|
103
|
+
expect(triggers[0]!.attributes("aria-selected")).toBe("false");
|
|
104
|
+
expect(triggers[0]!.attributes("tabindex")).toBe("-1");
|
|
105
|
+
expect(triggers[1]!.attributes("aria-selected")).toBe("true");
|
|
106
|
+
expect(triggers[1]!.attributes("tabindex")).toBe("0");
|
|
107
|
+
|
|
108
|
+
expect(panels[0]!.attributes("aria-hidden")).toBe("true");
|
|
109
|
+
expect(panels[1]!.attributes("aria-hidden")).toBe("false");
|
|
110
|
+
expect((panels[1]!.element as HTMLElement).style.display).toBe("block");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// ─── Keyboard navigation ─────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
it("moves focus and activates the next tab on ArrowRight (axis x)", async () => {
|
|
116
|
+
const wrapper = await mountSuspended(TabsCore, { props: { itemCount: 3 }, slots });
|
|
117
|
+
const triggers = wrapper.findAll(".tabs-list-item");
|
|
118
|
+
|
|
119
|
+
await triggers[0]!.trigger("keydown", { key: "ArrowRight" });
|
|
120
|
+
|
|
121
|
+
expect(triggers[1]!.attributes("aria-selected")).toBe("true");
|
|
122
|
+
expect(triggers[1]!.attributes("tabindex")).toBe("0");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("wraps to the first tab on ArrowRight from the last tab", async () => {
|
|
126
|
+
const wrapper = await mountSuspended(TabsCore, { props: { itemCount: 3 }, slots });
|
|
127
|
+
const triggers = wrapper.findAll(".tabs-list-item");
|
|
128
|
+
|
|
129
|
+
await triggers[2]!.trigger("click");
|
|
130
|
+
await triggers[2]!.trigger("keydown", { key: "ArrowRight" });
|
|
131
|
+
|
|
132
|
+
expect(triggers[0]!.attributes("aria-selected")).toBe("true");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("moves focus to the previous tab on ArrowLeft (axis x)", async () => {
|
|
136
|
+
const wrapper = await mountSuspended(TabsCore, { props: { itemCount: 3 }, slots });
|
|
137
|
+
const triggers = wrapper.findAll(".tabs-list-item");
|
|
138
|
+
|
|
139
|
+
await triggers[1]!.trigger("click");
|
|
140
|
+
await triggers[1]!.trigger("keydown", { key: "ArrowLeft" });
|
|
141
|
+
|
|
142
|
+
expect(triggers[0]!.attributes("aria-selected")).toBe("true");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("uses ArrowUp/ArrowDown instead of ArrowLeft/ArrowRight when axis is y", async () => {
|
|
146
|
+
const wrapper = await mountSuspended(TabsCore, { props: { itemCount: 3, axis: "y" }, slots });
|
|
147
|
+
const triggers = wrapper.findAll(".tabs-list-item");
|
|
148
|
+
|
|
149
|
+
await triggers[0]!.trigger("keydown", { key: "ArrowRight" });
|
|
150
|
+
expect(triggers[0]!.attributes("aria-selected")).toBe("true");
|
|
151
|
+
|
|
152
|
+
await triggers[0]!.trigger("keydown", { key: "ArrowDown" });
|
|
153
|
+
expect(triggers[1]!.attributes("aria-selected")).toBe("true");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("jumps to the last tab on End and the first tab on Home", async () => {
|
|
157
|
+
const wrapper = await mountSuspended(TabsCore, { props: { itemCount: 3 }, slots });
|
|
158
|
+
const triggers = wrapper.findAll(".tabs-list-item");
|
|
159
|
+
|
|
160
|
+
await triggers[0]!.trigger("keydown", { key: "End" });
|
|
161
|
+
expect(triggers[2]!.attributes("aria-selected")).toBe("true");
|
|
162
|
+
|
|
163
|
+
await triggers[2]!.trigger("keydown", { key: "Home" });
|
|
164
|
+
expect(triggers[0]!.attributes("aria-selected")).toBe("true");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// ─── trackHover / trackActive / trackIndicator ──────────────────────────
|
|
168
|
+
|
|
169
|
+
it("renders all three indicator decorators by default", async () => {
|
|
170
|
+
const wrapper = await mountSuspended(TabsCore, { props: { itemCount: 3 }, slots });
|
|
171
|
+
expect(wrapper.find(".nav__hovered").exists()).toBe(true);
|
|
172
|
+
expect(wrapper.find(".nav__active").exists()).toBe(true);
|
|
173
|
+
expect(wrapper.find(".nav__active-indicator").exists()).toBe(true);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("omits the hover decorator when trackHover is false", async () => {
|
|
177
|
+
const wrapper = await mountSuspended(TabsCore, { props: { itemCount: 3, trackHover: false }, slots });
|
|
178
|
+
expect(wrapper.find(".nav__hovered").exists()).toBe(false);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("omits the active decorator when trackActive is false", async () => {
|
|
182
|
+
const wrapper = await mountSuspended(TabsCore, { props: { itemCount: 3, trackActive: false }, slots });
|
|
183
|
+
expect(wrapper.find(".nav__active").exists()).toBe(false);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("omits the underline indicator when trackIndicator is false", async () => {
|
|
187
|
+
const wrapper = await mountSuspended(TabsCore, { props: { itemCount: 3, trackIndicator: false }, slots });
|
|
188
|
+
expect(wrapper.find(".nav__active-indicator").exists()).toBe(false);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// ─── Hover indicator movement ────────────────────────────────────────────
|
|
192
|
+
|
|
193
|
+
it("moves the hover indicator position on mouseenter", async () => {
|
|
194
|
+
const wrapper = await mountSuspended(TabsCore, { props: { itemCount: 3 }, slots });
|
|
195
|
+
const tabsList = wrapper.find(".tabs-list").element as HTMLElement;
|
|
196
|
+
const triggers = wrapper.findAll(".tabs-list-item");
|
|
197
|
+
|
|
198
|
+
expect(tabsList.style.getPropertyValue("--_width-hovered")).toBe("");
|
|
199
|
+
|
|
200
|
+
await triggers[1]!.trigger("mouseenter");
|
|
201
|
+
|
|
202
|
+
expect(tabsList.style.getPropertyValue("--_width-hovered")).not.toBe("");
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("resets the hover indicator to the active tab on mouseleave", async () => {
|
|
206
|
+
const wrapper = await mountSuspended(TabsCore, { props: { itemCount: 3 }, slots });
|
|
207
|
+
const tabsList = wrapper.find(".tabs-list");
|
|
208
|
+
const triggers = wrapper.findAll(".tabs-list-item");
|
|
209
|
+
|
|
210
|
+
await triggers[1]!.trigger("mouseenter");
|
|
211
|
+
await tabsList.trigger("mouseleave");
|
|
212
|
+
|
|
213
|
+
// Should not throw, and the hover indicator settles back onto the active (first) tab.
|
|
214
|
+
expect((tabsList.element as HTMLElement).style.getPropertyValue("--_x-hovered")).toBe("0px");
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("clears a pending settle timeout when hovering a second tab before the first settles", async () => {
|
|
218
|
+
const wrapper = await mountSuspended(TabsCore, { props: { itemCount: 3 }, slots });
|
|
219
|
+
const triggers = wrapper.findAll(".tabs-list-item");
|
|
220
|
+
const clearTimeoutSpy = vi.spyOn(global, "clearTimeout");
|
|
221
|
+
|
|
222
|
+
await triggers[1]!.trigger("mouseenter");
|
|
223
|
+
await triggers[2]!.trigger("mouseenter");
|
|
224
|
+
|
|
225
|
+
expect(clearTimeoutSpy).toHaveBeenCalled();
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// ─── Regression: more than 3 tabs ────────────────────────────────────────
|
|
229
|
+
// The original hand-rolled version of this component had an indicator-positioning issue
|
|
230
|
+
// that only showed up with itemCount > 3 — these lock in correct behaviour at 5.
|
|
231
|
+
|
|
232
|
+
it("activates a distant tab (index 0 -> index 4) directly via click", async () => {
|
|
233
|
+
const wrapper = await mountSuspended(TabsCore, { props: { itemCount: 5 }, slots: fiveSlots });
|
|
234
|
+
const triggers = wrapper.findAll(".tabs-list-item");
|
|
235
|
+
|
|
236
|
+
await triggers[4]!.trigger("click");
|
|
237
|
+
|
|
238
|
+
expect(triggers[0]!.attributes("aria-selected")).toBe("false");
|
|
239
|
+
expect(triggers[4]!.attributes("aria-selected")).toBe("true");
|
|
240
|
+
expect(triggers[4]!.attributes("tabindex")).toBe("0");
|
|
241
|
+
expect(wrapper.findAll(".tab-content")[4]!.attributes("aria-hidden")).toBe("false");
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("does not recolour tabs spanned by a distant activation jump (regression: intermediate labels going invisible)", async () => {
|
|
245
|
+
// The old implementation force-applied a "transitioning" class to every tab spanned by a
|
|
246
|
+
// jump, forcing it to the active-indicator's text colour for the whole transition — even
|
|
247
|
+
// though the sliding indicator only visually reaches each spanned tab partway through, and
|
|
248
|
+
// for a forward jump doesn't reach the middle tabs until late in the transition. That made
|
|
249
|
+
// intermediate labels flash a colour matching neither their own background nor (yet) the
|
|
250
|
+
// indicator's, going invisible. Spanned tabs should only ever carry aria-selected/hover
|
|
251
|
+
// state of their own — never a class forcing them to look active.
|
|
252
|
+
const wrapper = await mountSuspended(TabsCore, { props: { itemCount: 5 }, slots: fiveSlots });
|
|
253
|
+
const triggers = wrapper.findAll(".tabs-list-item");
|
|
254
|
+
|
|
255
|
+
// Active tab starts at index 0, so activating index 4 spans every tab in between.
|
|
256
|
+
await triggers[4]!.trigger("click");
|
|
257
|
+
|
|
258
|
+
[1, 2, 3].forEach((index) => {
|
|
259
|
+
expect(triggers[index]!.attributes("aria-selected")).toBe("false");
|
|
260
|
+
expect(triggers[index]!.classes()).not.toContain("transitioning");
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("wraps correctly with End then ArrowRight across 5 tabs", async () => {
|
|
265
|
+
const wrapper = await mountSuspended(TabsCore, { props: { itemCount: 5 }, slots: fiveSlots });
|
|
266
|
+
const triggers = wrapper.findAll(".tabs-list-item");
|
|
267
|
+
|
|
268
|
+
await triggers[0]!.trigger("keydown", { key: "End" });
|
|
269
|
+
expect(triggers[4]!.attributes("aria-selected")).toBe("true");
|
|
270
|
+
|
|
271
|
+
await triggers[4]!.trigger("keydown", { key: "ArrowRight" });
|
|
272
|
+
expect(triggers[0]!.attributes("aria-selected")).toBe("true");
|
|
273
|
+
});
|
|
274
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# HeaderBlock — Consumer Styling Guide
|
|
2
|
+
|
|
3
|
+
## No component-owned CSS tokens
|
|
4
|
+
|
|
5
|
+
`HeaderBlock` has no `<style>` block of its own. Visual size comes from applying one of six global
|
|
6
|
+
`.page-heading-1`–`.page-heading-6` utility classes (`classLevel` prop), defined in
|
|
7
|
+
`app/assets/styles/setup/05.typography/02.utility-classes/_font-classes-page-heading.css`:
|
|
8
|
+
|
|
9
|
+
```css
|
|
10
|
+
.page-heading-1 { font-size: var(--step-8); }
|
|
11
|
+
.page-heading-2 { font-size: var(--step-7); }
|
|
12
|
+
/* … down to .page-heading-6 { font-size: var(--step-3); } */
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Each size is a fluid type-scale token (`--step-3` through `--step-8`, defined globally, not by
|
|
16
|
+
this component) — override those tokens at a page/section scope to change every `page-heading-*`
|
|
17
|
+
element's size at once, not just this component's.
|
|
18
|
+
|
|
19
|
+
All six levels also reset `margin-block: 0rem` and `padding-block: 0rem` (instead of relying on the
|
|
20
|
+
browser's own per-level heading margins, which vary by `<h1>`–`<h6>` and would otherwise fight
|
|
21
|
+
`classLevel`/`tagLevel` being independent). This is a deliberate blank slate — add spacing in your
|
|
22
|
+
own CSS rather than expecting a level to come with baked-in margin:
|
|
23
|
+
|
|
24
|
+
```css
|
|
25
|
+
.my-page .page-heading-1 {
|
|
26
|
+
margin-block: 2.4rem 1.6rem;
|
|
27
|
+
}
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Overriding one instance
|
|
31
|
+
|
|
32
|
+
Use `styleClassPassthrough` to add your own class and override `font-size`/`font-weight`/etc. for
|
|
33
|
+
a single `HeaderBlock` instance without touching the shared `.page-heading-*` utility classes:
|
|
34
|
+
|
|
35
|
+
```vue
|
|
36
|
+
<HeaderBlock :tag-level="2" :class-level="1" style-class-passthrough="hero-title">Big idea</HeaderBlock>
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
```css
|
|
40
|
+
.hero-title {
|
|
41
|
+
font-size: 6rem;
|
|
42
|
+
}
|
|
43
|
+
```
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<component :is="tag" :id :class="[classLevelClass, elementClasses]">
|
|
3
|
+
<slot></slot>
|
|
4
|
+
</component>
|
|
5
|
+
</template>
|
|
6
|
+
|
|
7
|
+
<script setup lang="ts">
|
|
8
|
+
type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6;
|
|
9
|
+
|
|
10
|
+
interface Props {
|
|
11
|
+
/** Semantic heading level — controls which <h1>-<h6> tag is rendered. */
|
|
12
|
+
tagLevel?: HeadingLevel | `${HeadingLevel}`;
|
|
13
|
+
/** Visual size level — controls which `.page-heading-N` utility class is applied. Independent of tagLevel, so a component can be, e.g., a semantic h2 styled at the page-heading-1 size. */
|
|
14
|
+
classLevel?: HeadingLevel | `${HeadingLevel}`;
|
|
15
|
+
/** Bind to a wrapping section's aria-labelledby target (e.g. the heading-id slot prop from PageRow/useAriaLabelledById). */
|
|
16
|
+
id?: string | null;
|
|
17
|
+
styleClassPassthrough?: string | string[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
21
|
+
tagLevel: 1,
|
|
22
|
+
classLevel: 1,
|
|
23
|
+
id: null,
|
|
24
|
+
styleClassPassthrough: () => [],
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const validLevels = [1, 2, 3, 4, 5, 6];
|
|
28
|
+
|
|
29
|
+
const tagLevel = computed(() => {
|
|
30
|
+
const n = Number(props.tagLevel);
|
|
31
|
+
return validLevels.includes(n) ? n : 1;
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const classLevel = computed(() => {
|
|
35
|
+
const n = Number(props.classLevel);
|
|
36
|
+
return validLevels.includes(n) ? n : 1;
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const tag = computed(() => `h${tagLevel.value}`);
|
|
40
|
+
const classLevelClass = computed(() => `page-heading-${classLevel.value}`);
|
|
41
|
+
|
|
42
|
+
const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
|
|
43
|
+
|
|
44
|
+
watch(
|
|
45
|
+
() => props.styleClassPassthrough,
|
|
46
|
+
(newVal) => {
|
|
47
|
+
resetElementClasses(newVal ?? []);
|
|
48
|
+
}
|
|
49
|
+
);
|
|
50
|
+
</script>
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import HeaderBlock from "../HeaderBlock.vue";
|
|
2
|
+
import type { Meta, StoryObj } from "@nuxtjs/storybook";
|
|
3
|
+
|
|
4
|
+
const meta: Meta<typeof HeaderBlock> = {
|
|
5
|
+
title: "Atoms/Text Blocks/Header Block",
|
|
6
|
+
component: HeaderBlock,
|
|
7
|
+
argTypes: {
|
|
8
|
+
tagLevel: {
|
|
9
|
+
control: { type: "select" },
|
|
10
|
+
options: [1, 2, 3, 4, 5, 6],
|
|
11
|
+
description: "Semantic heading level — which <h1>-<h6> tag is rendered",
|
|
12
|
+
},
|
|
13
|
+
classLevel: {
|
|
14
|
+
control: { type: "select" },
|
|
15
|
+
options: [1, 2, 3, 4, 5, 6],
|
|
16
|
+
description: "Visual size level — which .page-heading-N utility class is applied, independent of tagLevel",
|
|
17
|
+
},
|
|
18
|
+
id: {
|
|
19
|
+
control: "text",
|
|
20
|
+
description: "Bind to a wrapping section's aria-labelledby target (e.g. PageRow's heading-id slot prop)",
|
|
21
|
+
},
|
|
22
|
+
styleClassPassthrough: {
|
|
23
|
+
control: "object",
|
|
24
|
+
description: "Additional classes applied to the root element",
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
args: {
|
|
28
|
+
tagLevel: 1,
|
|
29
|
+
classLevel: 1,
|
|
30
|
+
styleClassPassthrough: [],
|
|
31
|
+
},
|
|
32
|
+
parameters: {
|
|
33
|
+
docs: {
|
|
34
|
+
description: {
|
|
35
|
+
component:
|
|
36
|
+
"Renders an <h1>-<h6> tag chosen by tagLevel (document structure/accessibility), styled by the global .page-heading-{classLevel} utility class (visual size) — the two are independent, so a component can be a semantic h2 styled at the page-heading-1 size.",
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export default meta;
|
|
43
|
+
type Story = StoryObj<typeof HeaderBlock>;
|
|
44
|
+
|
|
45
|
+
export const Default: Story = {
|
|
46
|
+
render: (args) => ({
|
|
47
|
+
components: { HeaderBlock },
|
|
48
|
+
setup() {
|
|
49
|
+
return { args };
|
|
50
|
+
},
|
|
51
|
+
template: `<HeaderBlock v-bind="args">Page title</HeaderBlock>`,
|
|
52
|
+
}),
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export const DecoupledLevels: Story = {
|
|
56
|
+
name: "Semantic vs Visual Level Decoupled",
|
|
57
|
+
args: { tagLevel: 2, classLevel: 1 },
|
|
58
|
+
parameters: {
|
|
59
|
+
docs: {
|
|
60
|
+
description: {
|
|
61
|
+
story:
|
|
62
|
+
"tagLevel=2 (renders an <h2>, correct for document structure below the page's real <h1>) but classLevel=1 (styled at the largest page-heading size) — inspect the DOM to confirm the tag is <h2> while it visually reads as the biggest heading on the page.",
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
render: (args) => ({
|
|
67
|
+
components: { HeaderBlock },
|
|
68
|
+
setup() {
|
|
69
|
+
return { args };
|
|
70
|
+
},
|
|
71
|
+
template: `<HeaderBlock v-bind="args">Section heading, styled big</HeaderBlock>`,
|
|
72
|
+
}),
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export const SectionAriaLabelledby: Story = {
|
|
76
|
+
name: "Paired with a Section's aria-labelledby",
|
|
77
|
+
parameters: {
|
|
78
|
+
docs: {
|
|
79
|
+
description: {
|
|
80
|
+
story:
|
|
81
|
+
"The id prop lets HeaderBlock be the target of a wrapping landmark's aria-labelledby — pass the same id both places (or, inside this library, the heading-id slot prop that PageRow/other section wrappers expose via useAriaLabelledById).",
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
render: () => ({
|
|
86
|
+
components: { HeaderBlock },
|
|
87
|
+
template: `
|
|
88
|
+
<section aria-labelledby="story-section-heading">
|
|
89
|
+
<HeaderBlock id="story-section-heading" :tag-level="2" :class-level="2">Section title</HeaderBlock>
|
|
90
|
+
<p>Section content…</p>
|
|
91
|
+
</section>
|
|
92
|
+
`,
|
|
93
|
+
}),
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export const AllSizes: Story = {
|
|
97
|
+
name: "All classLevel Sizes",
|
|
98
|
+
render: () => ({
|
|
99
|
+
components: { HeaderBlock },
|
|
100
|
+
template: `
|
|
101
|
+
<div>
|
|
102
|
+
<HeaderBlock :tag-level="1" :class-level="1">page-heading-1</HeaderBlock>
|
|
103
|
+
<HeaderBlock :tag-level="2" :class-level="2">page-heading-2</HeaderBlock>
|
|
104
|
+
<HeaderBlock :tag-level="3" :class-level="3">page-heading-3</HeaderBlock>
|
|
105
|
+
<HeaderBlock :tag-level="4" :class-level="4">page-heading-4</HeaderBlock>
|
|
106
|
+
<HeaderBlock :tag-level="5" :class-level="5">page-heading-5</HeaderBlock>
|
|
107
|
+
<HeaderBlock :tag-level="6" :class-level="6">page-heading-6</HeaderBlock>
|
|
108
|
+
</div>
|
|
109
|
+
`,
|
|
110
|
+
}),
|
|
111
|
+
};
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { mountSuspended } from "@nuxt/test-utils/runtime";
|
|
3
|
+
import HeaderBlock from "../HeaderBlock.vue";
|
|
4
|
+
|
|
5
|
+
describe("HeaderBlock", () => {
|
|
6
|
+
// ─── Mount ───────────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
it("mounts without error", async () => {
|
|
9
|
+
const wrapper = await mountSuspended(HeaderBlock, {
|
|
10
|
+
slots: { default: "Hello World" },
|
|
11
|
+
});
|
|
12
|
+
expect(wrapper.vm).toBeTruthy();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
// ─── Snapshots ───────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
it("renders correct HTML structure with default props", async () => {
|
|
18
|
+
const wrapper = await mountSuspended(HeaderBlock, {
|
|
19
|
+
slots: { default: "Default heading" },
|
|
20
|
+
});
|
|
21
|
+
expect(wrapper.html()).toMatchSnapshot();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("renders correct HTML structure with all props set", async () => {
|
|
25
|
+
const wrapper = await mountSuspended(HeaderBlock, {
|
|
26
|
+
props: { tagLevel: 3, classLevel: 5, id: "section-heading", styleClassPassthrough: ["custom-class"] },
|
|
27
|
+
slots: { default: "Custom heading" },
|
|
28
|
+
});
|
|
29
|
+
expect(wrapper.html()).toMatchSnapshot();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// ─── tagLevel / classLevel ───────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
it("defaults to an h1 with the page-heading-1 class", async () => {
|
|
35
|
+
const wrapper = await mountSuspended(HeaderBlock, { slots: { default: "Title" } });
|
|
36
|
+
expect(wrapper.element.tagName).toBe("H1");
|
|
37
|
+
expect(wrapper.classes()).toContain("page-heading-1");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("renders the tag matching tagLevel", async () => {
|
|
41
|
+
const wrapper = await mountSuspended(HeaderBlock, {
|
|
42
|
+
props: { tagLevel: 4 },
|
|
43
|
+
slots: { default: "Title" },
|
|
44
|
+
});
|
|
45
|
+
expect(wrapper.element.tagName).toBe("H4");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("applies the page-heading class matching classLevel, independent of tagLevel", async () => {
|
|
49
|
+
const wrapper = await mountSuspended(HeaderBlock, {
|
|
50
|
+
props: { tagLevel: 2, classLevel: 6 },
|
|
51
|
+
slots: { default: "Title" },
|
|
52
|
+
});
|
|
53
|
+
expect(wrapper.element.tagName).toBe("H2");
|
|
54
|
+
expect(wrapper.classes()).toContain("page-heading-6");
|
|
55
|
+
expect(wrapper.classes()).not.toContain("page-heading-2");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("accepts tagLevel/classLevel as numeric strings", async () => {
|
|
59
|
+
const wrapper = await mountSuspended(HeaderBlock, {
|
|
60
|
+
props: { tagLevel: "5", classLevel: "2" },
|
|
61
|
+
slots: { default: "Title" },
|
|
62
|
+
});
|
|
63
|
+
expect(wrapper.element.tagName).toBe("H5");
|
|
64
|
+
expect(wrapper.classes()).toContain("page-heading-2");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("clamps an out-of-range tagLevel to 1", async () => {
|
|
68
|
+
const wrapper = await mountSuspended(HeaderBlock, {
|
|
69
|
+
// @ts-expect-error deliberately invalid at the type level to test the runtime clamp
|
|
70
|
+
props: { tagLevel: 9 },
|
|
71
|
+
slots: { default: "Title" },
|
|
72
|
+
});
|
|
73
|
+
expect(wrapper.element.tagName).toBe("H1");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("clamps an out-of-range classLevel to 1", async () => {
|
|
77
|
+
const wrapper = await mountSuspended(HeaderBlock, {
|
|
78
|
+
// @ts-expect-error deliberately invalid at the type level to test the runtime clamp
|
|
79
|
+
props: { classLevel: 0 },
|
|
80
|
+
slots: { default: "Title" },
|
|
81
|
+
});
|
|
82
|
+
expect(wrapper.classes()).toContain("page-heading-1");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// ─── Slot content ────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
it("renders slot content", async () => {
|
|
88
|
+
const wrapper = await mountSuspended(HeaderBlock, {
|
|
89
|
+
slots: { default: "<span class='highlight'>Big</span> idea" },
|
|
90
|
+
});
|
|
91
|
+
expect(wrapper.find(".highlight").exists()).toBe(true);
|
|
92
|
+
expect(wrapper.text()).toContain("idea");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// ─── id ──────────────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
it("does not render an id attribute by default", async () => {
|
|
98
|
+
const wrapper = await mountSuspended(HeaderBlock, { slots: { default: "Title" } });
|
|
99
|
+
expect(wrapper.attributes("id")).toBeUndefined();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("renders a given id, for pairing with a wrapping section's aria-labelledby", async () => {
|
|
103
|
+
const wrapper = await mountSuspended(HeaderBlock, {
|
|
104
|
+
props: { id: "section-heading" },
|
|
105
|
+
slots: { default: "Title" },
|
|
106
|
+
});
|
|
107
|
+
expect(wrapper.attributes("id")).toBe("section-heading");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// ─── styleClassPassthrough ───────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
it("applies styleClassPassthrough classes", async () => {
|
|
113
|
+
const wrapper = await mountSuspended(HeaderBlock, {
|
|
114
|
+
props: { styleClassPassthrough: "hero-title" },
|
|
115
|
+
slots: { default: "Title" },
|
|
116
|
+
});
|
|
117
|
+
expect(wrapper.classes()).toContain("hero-title");
|
|
118
|
+
});
|
|
119
|
+
});
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
|
2
|
+
|
|
3
|
+
exports[`HeaderBlock > renders correct HTML structure with all props set 1`] = `"<h3 id="section-heading" class="page-heading-5 custom-class">Custom heading</h3>"`;
|
|
4
|
+
|
|
5
|
+
exports[`HeaderBlock > renders correct HTML structure with default props 1`] = `"<h1 class="page-heading-1">Default heading</h1>"`;
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
<script setup lang="ts">
|
|
34
34
|
import type { ResponsiveHeaderProp } from "../../../types/components";
|
|
35
35
|
import PageRow from "../../01.atoms/page-row/PageRow.vue";
|
|
36
|
-
import SkipLinks from "../../skip-links/SkipLinks.vue";
|
|
36
|
+
import SkipLinks from "../../01.atoms/navigation/skip-links/SkipLinks.vue";
|
|
37
37
|
import ResponsiveHeader from "../responsive-header/ResponsiveHeader.vue";
|
|
38
38
|
|
|
39
39
|
interface Props {
|
|
@@ -34,7 +34,7 @@ describe("SiteHeader", () => {
|
|
|
34
34
|
slots: { branding: "<a href='/' class='brand-link'>Brand</a>" },
|
|
35
35
|
global: { stubs: { ResponsiveHeader: responsiveHeaderStub } },
|
|
36
36
|
});
|
|
37
|
-
expect(wrapper.find(".
|
|
37
|
+
expect(wrapper.find(".skip-links__home .brand-link").exists()).toBe(true);
|
|
38
38
|
});
|
|
39
39
|
|
|
40
40
|
it("does not render the secondaryNavigation slot when not provided", async () => {
|
|
@@ -4,9 +4,9 @@ exports[`SiteHeader > renders correct HTML structure with default props 1`] = `
|
|
|
4
4
|
"<div class="page-row content">
|
|
5
5
|
<header class="site-header">
|
|
6
6
|
<nav class="home-navigation" aria-label="Home Navigation">
|
|
7
|
-
<div class="
|
|
8
|
-
<div class="
|
|
9
|
-
<nav class="skip-
|
|
7
|
+
<div class="skip-links">
|
|
8
|
+
<div class="skip-links__home"></div>
|
|
9
|
+
<nav class="skip-links__nav" aria-label="Skip navigation"><a href="#main-content" class="skip-links__link">Skip to main content</a><a href="#footer-content" class="skip-links__link">Skip to footer</a></nav>
|
|
10
10
|
</div>
|
|
11
11
|
</nav>
|
|
12
12
|
<div class="responsive-header-stub" main-nav-aria-label="Main navigation" secondary-nav-aria-label="Secondary navigation" overflow-menu-aria-label="Overflow navigation menu"></div>
|