srcdev-nuxt-components 9.6.13 → 9.6.14
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/rules/consumer/story-register-components.md +1 -1
- package/.claude/skills/component-compliance-checklist.md +5 -0
- package/.claude/skills/components/display-qr-code.md +22 -4
- package/.claude/skills/components/eyebrow-text.md +9 -5
- package/.claude/skills/components/header-block.md +13 -5
- package/.claude/skills/components/hero-text.md +13 -9
- package/.claude/skills/components/link-text.md +8 -5
- package/.claude/skills/components/pop-over.md +17 -3
- package/.claude/skills/storybook-add-story.md +5 -2
- package/.claude/skills/testing-add-playwright.md +12 -7
- package/.vscode/srcdev-component-display-qr-code.code-snippets +12 -1
- package/.vscode/srcdev-component-hero-text.code-snippets +21 -5
- package/.vscode/srcdev-component-link-text.code-snippets +3 -3
- package/.vscode/srcdev-component-pop-over.code-snippets +28 -0
- package/app/components/01.atoms/pop-over/CONSUMER-STYLING.md +42 -9
- package/app/components/01.atoms/pop-over/PopOver.vue +28 -12
- package/app/components/01.atoms/pop-over/stories/PopOver.stories.ts +114 -2
- package/app/components/01.atoms/pop-over/tests/PopOver.spec.ts +35 -0
- package/app/components/01.atoms/pop-over/tests/__snapshots__/PopOver.spec.ts.snap +5 -1
- package/app/components/01.atoms/qr-code/CONSUMER-STYLING.md +16 -3
- package/app/components/01.atoms/qr-code/DisplayQrCode.vue +31 -0
- package/app/components/01.atoms/qr-code/stories/DisplayQrCode.stories.ts +190 -157
- package/app/components/01.atoms/qr-code/tests/DisplayQrCode.spec.ts +58 -0
- package/app/components/01.atoms/text-blocks/eyebrow-text/CONSUMER-STYLING.md +9 -1
- package/app/components/01.atoms/text-blocks/eyebrow-text/EyebrowText.vue +5 -4
- package/app/components/01.atoms/text-blocks/eyebrow-text/playwright/eyebrow-text.playwright.ts +1 -1
- package/app/components/01.atoms/text-blocks/eyebrow-text/stories/EyebrowText.stories.ts +104 -17
- package/app/components/01.atoms/text-blocks/eyebrow-text/tests/EyebrowText.spec.ts +68 -118
- package/app/components/01.atoms/text-blocks/eyebrow-text/tests/__snapshots__/EyebrowText.spec.ts.snap +8 -8
- package/app/components/01.atoms/text-blocks/header-block/CONSUMER-STYLING.md +49 -24
- package/app/components/01.atoms/text-blocks/header-block/HeaderBlock.vue +9 -1
- package/app/components/01.atoms/text-blocks/header-block/stories/HeaderBlock.stories.ts +96 -26
- package/app/components/01.atoms/text-blocks/header-block/tests/HeaderBlock.spec.ts +64 -48
- package/app/components/01.atoms/text-blocks/header-block/tests/__snapshots__/HeaderBlock.spec.ts.snap +2 -2
- package/app/components/01.atoms/text-blocks/hero-text/CONSUMER-STYLING.md +22 -6
- package/app/components/01.atoms/text-blocks/hero-text/HeroText.vue +32 -23
- package/app/components/01.atoms/text-blocks/hero-text/playwright/hero-text.playwright.ts +1 -1
- package/app/components/01.atoms/text-blocks/hero-text/stories/HeroText.stories.ts +176 -23
- package/app/components/01.atoms/text-blocks/hero-text/tests/HeroText.spec.ts +129 -123
- package/app/components/01.atoms/text-blocks/hero-text/tests/__snapshots__/HeroText.spec.ts.snap +2 -2
- package/app/components/01.atoms/text-blocks/link-text/CONSUMER-STYLING.md +17 -0
- package/app/components/01.atoms/text-blocks/link-text/LinkText.vue +9 -3
- package/app/components/01.atoms/text-blocks/link-text/stories/LinkText.stories.ts +96 -99
- package/app/components/01.atoms/text-blocks/link-text/tests/LinkText.spec.ts +84 -93
- package/app/components/02.molecules/price-list/tests/__snapshots__/PriceList.spec.ts.snap +8 -8
- package/app/components/03.organisms/services/service-detail/tests/__snapshots__/ServiceDetail.spec.ts.snap +15 -15
- package/app/components/03.organisms/services/service-summary/tests/__snapshots__/ServiceSummary.spec.ts.snap +3 -3
- package/app/components/03.organisms/services/services-card/tests/ServicesCard.spec.ts +3 -3
- package/app/components/03.organisms/services/services-card/tests/__snapshots__/ServicesCard.spec.ts.snap +3 -3
- package/app/components/03.organisms/services/services-grids/tests/ServicesCardGrid.spec.ts +1 -1
- package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServiceSummaryGrid.spec.ts.snap +9 -9
- package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesCardGrid.spec.ts.snap +9 -9
- package/package.json +1 -1
|
@@ -1,84 +1,108 @@
|
|
|
1
|
+
import { computed } from "vue";
|
|
1
2
|
import HeaderBlock from "../HeaderBlock.vue";
|
|
2
3
|
import type { Meta, StoryObj } from "@nuxtjs/storybook";
|
|
3
4
|
|
|
4
|
-
|
|
5
|
+
type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6;
|
|
6
|
+
|
|
7
|
+
interface StoryArgs {
|
|
8
|
+
tagLevel: HeadingLevel;
|
|
9
|
+
classLevel: HeadingLevel;
|
|
10
|
+
id?: string;
|
|
11
|
+
headingText: string;
|
|
12
|
+
styleClassPassthrough?: string | string[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const meta: Meta<StoryArgs> = {
|
|
5
16
|
title: "Atoms/Text Blocks/Header Block",
|
|
6
17
|
component: HeaderBlock,
|
|
7
18
|
argTypes: {
|
|
8
19
|
tagLevel: {
|
|
9
20
|
control: { type: "select" },
|
|
10
21
|
options: [1, 2, 3, 4, 5, 6],
|
|
11
|
-
description: "Semantic heading level
|
|
22
|
+
description: "Semantic heading level: which `<h1>`-`<h6>` tag is rendered.",
|
|
23
|
+
table: { category: "Basic" },
|
|
12
24
|
},
|
|
13
25
|
classLevel: {
|
|
14
26
|
control: { type: "select" },
|
|
15
27
|
options: [1, 2, 3, 4, 5, 6],
|
|
16
|
-
description: "Visual size level
|
|
28
|
+
description: "Visual size level: which `.page-heading-N` utility class is applied, independent of `tagLevel`.",
|
|
29
|
+
table: { category: "Basic" },
|
|
17
30
|
},
|
|
18
31
|
id: {
|
|
19
32
|
control: "text",
|
|
20
|
-
description: "
|
|
33
|
+
description: "Rendered as `id`, for a wrapping section's `aria-labelledby` (e.g. PageRow's `heading-id` slot prop).",
|
|
34
|
+
table: { category: "Accessibility" },
|
|
21
35
|
},
|
|
22
|
-
|
|
23
|
-
control: "
|
|
24
|
-
description: "
|
|
36
|
+
headingText: {
|
|
37
|
+
control: "text",
|
|
38
|
+
description: "Story-only: text placed in the default slot.",
|
|
39
|
+
table: { category: "Slots (story-only)" },
|
|
25
40
|
},
|
|
41
|
+
styleClassPassthrough: { table: { disable: true } },
|
|
26
42
|
},
|
|
27
43
|
args: {
|
|
28
44
|
tagLevel: 1,
|
|
29
45
|
classLevel: 1,
|
|
30
|
-
|
|
46
|
+
headingText: "Page title",
|
|
31
47
|
},
|
|
32
48
|
parameters: {
|
|
33
49
|
docs: {
|
|
34
50
|
description: {
|
|
35
51
|
component:
|
|
36
|
-
"Renders an
|
|
52
|
+
"Renders an `<h1>`-`<h6>` chosen by `tagLevel` (document structure), sized by the global `.page-heading-{classLevel}` utility class (visual size). The two are independent, so a heading can be a semantic `h2` styled at the `page-heading-1` size. The default slot is the heading content; `id` pairs it with a section's `aria-labelledby`.",
|
|
37
53
|
},
|
|
38
54
|
},
|
|
39
55
|
},
|
|
40
56
|
};
|
|
41
57
|
|
|
42
58
|
export default meta;
|
|
43
|
-
type Story = StoryObj<
|
|
59
|
+
type Story = StoryObj<StoryArgs>;
|
|
60
|
+
|
|
61
|
+
const render: Story["render"] = (args) => ({
|
|
62
|
+
components: { HeaderBlock },
|
|
63
|
+
setup() {
|
|
64
|
+
const componentArgs = computed(() => {
|
|
65
|
+
const { headingText: _headingText, ...rest } = args;
|
|
66
|
+
return rest;
|
|
67
|
+
});
|
|
68
|
+
return { args, componentArgs };
|
|
69
|
+
},
|
|
70
|
+
template: `<HeaderBlock v-bind="componentArgs">{{ args.headingText }}</HeaderBlock>`,
|
|
71
|
+
});
|
|
44
72
|
|
|
45
73
|
export const Default: Story = {
|
|
46
|
-
render
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
74
|
+
render,
|
|
75
|
+
parameters: {
|
|
76
|
+
docs: {
|
|
77
|
+
description: {
|
|
78
|
+
story: "An `h1` at the `page-heading-1` size. Use the Controls panel to change either level or the text.",
|
|
79
|
+
},
|
|
50
80
|
},
|
|
51
|
-
|
|
52
|
-
}),
|
|
81
|
+
},
|
|
53
82
|
};
|
|
54
83
|
|
|
55
84
|
export const DecoupledLevels: Story = {
|
|
56
85
|
name: "Semantic vs Visual Level Decoupled",
|
|
57
|
-
|
|
86
|
+
render,
|
|
87
|
+
args: { tagLevel: 2, classLevel: 1, headingText: "Section heading, styled big" },
|
|
58
88
|
parameters: {
|
|
59
89
|
docs: {
|
|
60
90
|
description: {
|
|
61
91
|
story:
|
|
62
|
-
"tagLevel
|
|
92
|
+
"`tagLevel` 2 renders an `<h2>`, correct for document structure below the page's real `<h1>`, while `classLevel` 1 styles it at the largest size. Inspect the DOM to confirm the tag is `<h2>`.",
|
|
63
93
|
},
|
|
64
94
|
},
|
|
65
95
|
},
|
|
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
96
|
};
|
|
74
97
|
|
|
75
98
|
export const SectionAriaLabelledby: Story = {
|
|
76
99
|
name: "Paired with a Section's aria-labelledby",
|
|
77
100
|
parameters: {
|
|
101
|
+
controls: { disable: true },
|
|
78
102
|
docs: {
|
|
79
103
|
description: {
|
|
80
104
|
story:
|
|
81
|
-
"The id prop lets HeaderBlock be the target of a wrapping landmark's aria-labelledby
|
|
105
|
+
"The `id` prop lets HeaderBlock be the target of a wrapping landmark's `aria-labelledby`. Pass the same id both places, or inside `PageRow`, `ProfileSection` (`heroText` slot) or `PageHeroHighlights` (`header` slot) bind the `heading-id` slot prop they expose. See the [PageRow](?path=/story/atoms-layouts-pagerow--aria-labelled-by) story, which wires the slot prop to a heading.",
|
|
82
106
|
},
|
|
83
107
|
},
|
|
84
108
|
},
|
|
@@ -95,6 +119,14 @@ export const SectionAriaLabelledby: Story = {
|
|
|
95
119
|
|
|
96
120
|
export const AllSizes: Story = {
|
|
97
121
|
name: "All classLevel Sizes",
|
|
122
|
+
parameters: {
|
|
123
|
+
controls: { disable: true },
|
|
124
|
+
docs: {
|
|
125
|
+
description: {
|
|
126
|
+
story: "Every `classLevel`, from `page-heading-1` (`--step-8`) down to `page-heading-6` (`--step-3`).",
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
},
|
|
98
130
|
render: () => ({
|
|
99
131
|
components: { HeaderBlock },
|
|
100
132
|
template: `
|
|
@@ -109,3 +141,41 @@ export const AllSizes: Story = {
|
|
|
109
141
|
`,
|
|
110
142
|
}),
|
|
111
143
|
};
|
|
144
|
+
|
|
145
|
+
export const StressTest: Story = {
|
|
146
|
+
name: "Stress Test (Worst-Case Data)",
|
|
147
|
+
parameters: {
|
|
148
|
+
controls: { disable: true },
|
|
149
|
+
docs: {
|
|
150
|
+
description: {
|
|
151
|
+
story:
|
|
152
|
+
"Worst-case headings. Check: an unbroken word and a long URL at `page-heading-1` wrap inside the dashed 24rem column instead of overflowing; long German copy wraps on spaces; emoji, right-to-left text and HTML-like text (shown as text) render normally; a single character still gets the full size; out-of-range levels (9 and 0) fall back to `h1` at `page-heading-1`.",
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
render: () => ({
|
|
157
|
+
components: { HeaderBlock },
|
|
158
|
+
setup() {
|
|
159
|
+
return { htmlLike: "<b>not bold</b>" };
|
|
160
|
+
},
|
|
161
|
+
template: `
|
|
162
|
+
<div style="display: grid; gap: 2.4rem; padding: 2.4rem;">
|
|
163
|
+
<p style="margin: 0; font-size: 1.4rem;">Unbroken word, long URL and long German copy in a 24rem column at the largest size.</p>
|
|
164
|
+
<div style="display: grid; gap: 1.6rem; inline-size: 24rem; outline: 0.1rem dashed currentColor;">
|
|
165
|
+
<HeaderBlock :tag-level="2" :class-level="1">Kostenloselieferungfürallebestellungen</HeaderBlock>
|
|
166
|
+
<HeaderBlock :tag-level="2" :class-level="2">https://example.invalid/a/very/long/unbroken/url</HeaderBlock>
|
|
167
|
+
<HeaderBlock :tag-level="2" :class-level="3">Kostenlose Lieferung für alle Bestellungen über einem Mindestbestellwert</HeaderBlock>
|
|
168
|
+
</div>
|
|
169
|
+
|
|
170
|
+
<p style="margin: 0; font-size: 1.4rem;">Emoji first, right-to-left, HTML-like text and a single character.</p>
|
|
171
|
+
<HeaderBlock :tag-level="2" :class-level="3">😀 Emoji at the start</HeaderBlock>
|
|
172
|
+
<HeaderBlock :tag-level="2" :class-level="3" dir="rtl">تسريحات شعر جديدة لموسم الخريف</HeaderBlock>
|
|
173
|
+
<HeaderBlock :tag-level="2" :class-level="3">{{ htmlLike }}</HeaderBlock>
|
|
174
|
+
<HeaderBlock :tag-level="2" :class-level="3">A</HeaderBlock>
|
|
175
|
+
|
|
176
|
+
<p style="margin: 0; font-size: 1.4rem;">Out-of-range levels (tag 9, class 0): both fall back to 1.</p>
|
|
177
|
+
<HeaderBlock :tag-level="9" :class-level="0">Fallback heading</HeaderBlock>
|
|
178
|
+
</div>
|
|
179
|
+
`,
|
|
180
|
+
}),
|
|
181
|
+
};
|
|
@@ -1,93 +1,107 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
1
|
+
import { describe, it, expect, afterEach, vi } from "vitest";
|
|
2
2
|
import { mountSuspended } from "@nuxt/test-utils/runtime";
|
|
3
3
|
import HeaderBlock from "../HeaderBlock.vue";
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
type HeaderBlockProps = InstanceType<typeof HeaderBlock>["$props"];
|
|
6
|
+
|
|
7
|
+
const mounted: Awaited<ReturnType<typeof mountSuspended>>[] = [];
|
|
8
|
+
|
|
9
|
+
const mountHeader = async (props: HeaderBlockProps = {}, content = "Title") => {
|
|
10
|
+
const wrapper = await mountSuspended(HeaderBlock, { props, slots: { default: content } });
|
|
11
|
+
mounted.push(wrapper);
|
|
12
|
+
return wrapper;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
mounted.splice(0).forEach((wrapper) => wrapper.unmount());
|
|
17
|
+
vi.restoreAllMocks();
|
|
18
|
+
});
|
|
7
19
|
|
|
20
|
+
describe("HeaderBlock", () => {
|
|
8
21
|
it("mounts without error", async () => {
|
|
9
|
-
const wrapper = await
|
|
10
|
-
slots: { default: "Hello World" },
|
|
11
|
-
});
|
|
22
|
+
const wrapper = await mountHeader({}, "Hello World");
|
|
12
23
|
expect(wrapper.vm).toBeTruthy();
|
|
13
24
|
});
|
|
14
25
|
|
|
15
26
|
// ─── Snapshots ───────────────────────────────────────────────────────────
|
|
16
27
|
|
|
17
28
|
it("renders correct HTML structure with default props", async () => {
|
|
18
|
-
const wrapper = await
|
|
19
|
-
slots: { default: "Default heading" },
|
|
20
|
-
});
|
|
29
|
+
const wrapper = await mountHeader({}, "Default heading");
|
|
21
30
|
expect(wrapper.html()).toMatchSnapshot();
|
|
22
31
|
});
|
|
23
32
|
|
|
24
33
|
it("renders correct HTML structure with all props set", async () => {
|
|
25
|
-
const wrapper = await
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
34
|
+
const wrapper = await mountHeader(
|
|
35
|
+
{ tagLevel: 3, classLevel: 5, id: "section-heading", styleClassPassthrough: ["custom-class"] },
|
|
36
|
+
"Custom heading"
|
|
37
|
+
);
|
|
29
38
|
expect(wrapper.html()).toMatchSnapshot();
|
|
30
39
|
});
|
|
31
40
|
|
|
41
|
+
// ─── Root class ──────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
it("always has the header-block class", async () => {
|
|
44
|
+
const wrapper = await mountHeader({ tagLevel: 4, classLevel: 2, styleClassPassthrough: "extra" });
|
|
45
|
+
expect(wrapper.classes()).toContain("header-block");
|
|
46
|
+
});
|
|
47
|
+
|
|
32
48
|
// ─── tagLevel / classLevel ───────────────────────────────────────────────
|
|
33
49
|
|
|
34
50
|
it("defaults to an h1 with the page-heading-1 class", async () => {
|
|
35
|
-
const wrapper = await
|
|
51
|
+
const wrapper = await mountHeader();
|
|
36
52
|
expect(wrapper.element.tagName).toBe("H1");
|
|
37
53
|
expect(wrapper.classes()).toContain("page-heading-1");
|
|
38
54
|
});
|
|
39
55
|
|
|
40
56
|
it("renders the tag matching tagLevel", async () => {
|
|
41
|
-
const wrapper = await
|
|
42
|
-
props: { tagLevel: 4 },
|
|
43
|
-
slots: { default: "Title" },
|
|
44
|
-
});
|
|
57
|
+
const wrapper = await mountHeader({ tagLevel: 4 });
|
|
45
58
|
expect(wrapper.element.tagName).toBe("H4");
|
|
46
59
|
});
|
|
47
60
|
|
|
48
61
|
it("applies the page-heading class matching classLevel, independent of tagLevel", async () => {
|
|
49
|
-
const wrapper = await
|
|
50
|
-
props: { tagLevel: 2, classLevel: 6 },
|
|
51
|
-
slots: { default: "Title" },
|
|
52
|
-
});
|
|
62
|
+
const wrapper = await mountHeader({ tagLevel: 2, classLevel: 6 });
|
|
53
63
|
expect(wrapper.element.tagName).toBe("H2");
|
|
54
64
|
expect(wrapper.classes()).toContain("page-heading-6");
|
|
55
65
|
expect(wrapper.classes()).not.toContain("page-heading-2");
|
|
56
66
|
});
|
|
57
67
|
|
|
58
68
|
it("accepts tagLevel/classLevel as numeric strings", async () => {
|
|
59
|
-
const wrapper = await
|
|
60
|
-
props: { tagLevel: "5", classLevel: "2" },
|
|
61
|
-
slots: { default: "Title" },
|
|
62
|
-
});
|
|
69
|
+
const wrapper = await mountHeader({ tagLevel: "5", classLevel: "2" });
|
|
63
70
|
expect(wrapper.element.tagName).toBe("H5");
|
|
64
71
|
expect(wrapper.classes()).toContain("page-heading-2");
|
|
65
72
|
});
|
|
66
73
|
|
|
67
74
|
it("clamps an out-of-range tagLevel to 1", async () => {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
props: { tagLevel: 9 },
|
|
71
|
-
slots: { default: "Title" },
|
|
72
|
-
});
|
|
75
|
+
// @ts-expect-error deliberately invalid at the type level to test the runtime clamp
|
|
76
|
+
const wrapper = await mountHeader({ tagLevel: 9 });
|
|
73
77
|
expect(wrapper.element.tagName).toBe("H1");
|
|
74
78
|
});
|
|
75
79
|
|
|
76
80
|
it("clamps an out-of-range classLevel to 1", async () => {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
props: { classLevel: 0 },
|
|
80
|
-
slots: { default: "Title" },
|
|
81
|
-
});
|
|
81
|
+
// @ts-expect-error deliberately invalid at the type level to test the runtime clamp
|
|
82
|
+
const wrapper = await mountHeader({ classLevel: 0 });
|
|
82
83
|
expect(wrapper.classes()).toContain("page-heading-1");
|
|
83
84
|
});
|
|
84
85
|
|
|
86
|
+
it("falls back to level 1 for fractional and negative levels", async () => {
|
|
87
|
+
// @ts-expect-error deliberately invalid at the type level to test the runtime clamp
|
|
88
|
+
const wrapper = await mountHeader({ tagLevel: 2.5, classLevel: -3 });
|
|
89
|
+
expect(wrapper.element.tagName).toBe("H1");
|
|
90
|
+
expect(wrapper.classes()).toContain("page-heading-1");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("updates the tag and class when the levels change after mount", async () => {
|
|
94
|
+
const wrapper = await mountHeader({ tagLevel: 2, classLevel: 2 });
|
|
95
|
+
await wrapper.setProps({ tagLevel: 3, classLevel: 4 });
|
|
96
|
+
expect(wrapper.element.tagName).toBe("H3");
|
|
97
|
+
expect(wrapper.classes()).toContain("page-heading-4");
|
|
98
|
+
expect(wrapper.classes()).not.toContain("page-heading-2");
|
|
99
|
+
});
|
|
100
|
+
|
|
85
101
|
// ─── Slot content ────────────────────────────────────────────────────────
|
|
86
102
|
|
|
87
103
|
it("renders slot content", async () => {
|
|
88
|
-
const wrapper = await
|
|
89
|
-
slots: { default: "<span class='highlight'>Big</span> idea" },
|
|
90
|
-
});
|
|
104
|
+
const wrapper = await mountHeader({}, "<span class='highlight'>Big</span> idea");
|
|
91
105
|
expect(wrapper.find(".highlight").exists()).toBe(true);
|
|
92
106
|
expect(wrapper.text()).toContain("idea");
|
|
93
107
|
});
|
|
@@ -95,25 +109,27 @@ describe("HeaderBlock", () => {
|
|
|
95
109
|
// ─── id ──────────────────────────────────────────────────────────────────
|
|
96
110
|
|
|
97
111
|
it("does not render an id attribute by default", async () => {
|
|
98
|
-
const wrapper = await
|
|
112
|
+
const wrapper = await mountHeader();
|
|
99
113
|
expect(wrapper.attributes("id")).toBeUndefined();
|
|
100
114
|
});
|
|
101
115
|
|
|
102
116
|
it("renders a given id, for pairing with a wrapping section's aria-labelledby", async () => {
|
|
103
|
-
const wrapper = await
|
|
104
|
-
props: { id: "section-heading" },
|
|
105
|
-
slots: { default: "Title" },
|
|
106
|
-
});
|
|
117
|
+
const wrapper = await mountHeader({ id: "section-heading" });
|
|
107
118
|
expect(wrapper.attributes("id")).toBe("section-heading");
|
|
108
119
|
});
|
|
109
120
|
|
|
110
121
|
// ─── styleClassPassthrough ───────────────────────────────────────────────
|
|
111
122
|
|
|
112
123
|
it("applies styleClassPassthrough classes", async () => {
|
|
113
|
-
const wrapper = await
|
|
114
|
-
props: { styleClassPassthrough: "hero-title" },
|
|
115
|
-
slots: { default: "Title" },
|
|
116
|
-
});
|
|
124
|
+
const wrapper = await mountHeader({ styleClassPassthrough: "hero-title" });
|
|
117
125
|
expect(wrapper.classes()).toContain("hero-title");
|
|
118
126
|
});
|
|
127
|
+
|
|
128
|
+
it("updates classes when styleClassPassthrough changes after mount", async () => {
|
|
129
|
+
const wrapper = await mountHeader({ styleClassPassthrough: ["original"] });
|
|
130
|
+
await wrapper.setProps({ styleClassPassthrough: ["updated"] });
|
|
131
|
+
expect(wrapper.classes()).not.toContain("original");
|
|
132
|
+
expect(wrapper.classes()).toContain("updated");
|
|
133
|
+
expect(wrapper.classes()).toContain("header-block");
|
|
134
|
+
});
|
|
119
135
|
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
|
2
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>"`;
|
|
3
|
+
exports[`HeaderBlock > renders correct HTML structure with all props set 1`] = `"<h3 id="section-heading" class="header-block page-heading-5 custom-class">Custom heading</h3>"`;
|
|
4
4
|
|
|
5
|
-
exports[`HeaderBlock > renders correct HTML structure with default props 1`] = `"<h1 class="page-heading-1">Default heading</h1>"`;
|
|
5
|
+
exports[`HeaderBlock > renders correct HTML structure with default props 1`] = `"<h1 class="header-block page-heading-1">Default heading</h1>"`;
|
|
@@ -18,7 +18,6 @@
|
|
|
18
18
|
|---|---|---|
|
|
19
19
|
| `--hero-text-font-family` | `"Playfair Display"` | Font family of the whole heading |
|
|
20
20
|
| `--hero-text-margin` | `0` | Margin on the root |
|
|
21
|
-
| `--hero-text-horizontal-gap` | `0.5ch` | Gap between segments, `axis="horizontal"` |
|
|
22
21
|
| `--hero-text-vertical-gap` | `0.4em` | Gap between segments, `axis="vertical"` |
|
|
23
22
|
| `--hero-text-bg-img` | `linear-gradient(135deg, #c2a770, #b4747e, #d1bd94)` | Fill of `accent` segments (`background-clip: text`) |
|
|
24
23
|
| `--hero-text-accent-offset` | `0.2em` | Extra bottom padding (and matching negative margin) on `accent` segments so descenders aren't clipped |
|
|
@@ -39,12 +38,20 @@ Private (not public API): `--_hero-text-accent-offset`.
|
|
|
39
38
|
|
|
40
39
|
| Hook | When |
|
|
41
40
|
|---|---|
|
|
42
|
-
|
|
|
43
|
-
|
|
|
44
|
-
| `.accent` | A segment with `styleClass: "accent"` |
|
|
41
|
+
| `[data-font-size="display" \| "title" \| "heading" \| "subheading" \| "label"]` | The `font-size` prop |
|
|
42
|
+
| `[data-axis="horizontal" \| "vertical"]` | The `axis` prop (vertical is a flex column) |
|
|
43
|
+
| `.hero-text-segment.accent` | A segment with `styleClass: "accent"` |
|
|
45
44
|
|
|
46
|
-
Inner elements: `.hero-text__icon
|
|
47
|
-
own `styleClass
|
|
45
|
+
Inner elements: `.hero-text__icon` (decorative, `aria-hidden`), and one
|
|
46
|
+
`<span class="hero-text-segment">` per segment, plus its own `styleClass`. Segments with blank or
|
|
47
|
+
missing `text` are dropped. Target one segment with `.hero-text-segment:nth-of-type(n)`.
|
|
48
|
+
|
|
49
|
+
> Changed 2026-10-19: size and axis were bare classes on the root (`.title`, `.label`,
|
|
50
|
+
> `.axis-vertical`, ...) and segments were `.text-block-{n}`, names a consumer's own CSS could
|
|
51
|
+
> collide with. Replace `.hero-text.title` with `.hero-text[data-font-size="title"]`,
|
|
52
|
+
> `.hero-text.axis-vertical` with `.hero-text[data-axis="vertical"]`, and `.text-block-1` with
|
|
53
|
+
> `.hero-text-segment:nth-of-type(2)`. `--hero-text-horizontal-gap` is gone: it never applied (the
|
|
54
|
+
> horizontal axis isn't a flex container; segments are joined by a space).
|
|
48
55
|
|
|
49
56
|
---
|
|
50
57
|
|
|
@@ -72,3 +79,12 @@ Set the tokens above on an element you own (a page or section class, or a class
|
|
|
72
79
|
## Class passthrough
|
|
73
80
|
|
|
74
81
|
`style-class-passthrough` adds classes to the root heading element. Reactive after mount.
|
|
82
|
+
|
|
83
|
+
---
|
|
84
|
+
|
|
85
|
+
## Notes
|
|
86
|
+
|
|
87
|
+
- Every segment wraps (`overflow-wrap: anywhere`), so an unbroken word or URL stays inside its
|
|
88
|
+
column. There is no line-clamp token: clamping needs `overflow: hidden`, which clips the accent
|
|
89
|
+
descender offset and italic overhang. `ServiceDetail` clamps its title itself, with an allowance
|
|
90
|
+
for both.
|
|
@@ -1,7 +1,19 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<component
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
<component
|
|
3
|
+
:is="tag"
|
|
4
|
+
:id="id"
|
|
5
|
+
class="hero-text"
|
|
6
|
+
:class="[elementClasses]"
|
|
7
|
+
:data-font-size="fontSize"
|
|
8
|
+
:data-axis="axis"
|
|
9
|
+
>
|
|
10
|
+
<Icon v-if="icon" :name="icon" class="hero-text__icon" aria-hidden="true" />
|
|
11
|
+
<span
|
|
12
|
+
v-for="(item, index) in normalisedContent"
|
|
13
|
+
:key="index"
|
|
14
|
+
class="hero-text-segment"
|
|
15
|
+
:class="item.styleClass"
|
|
16
|
+
>
|
|
5
17
|
{{ item.text }}
|
|
6
18
|
</span>
|
|
7
19
|
</component>
|
|
@@ -27,10 +39,6 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
27
39
|
styleClassPassthrough: () => [],
|
|
28
40
|
});
|
|
29
41
|
|
|
30
|
-
const componentClasses = computed(() => {
|
|
31
|
-
return [props.fontSize, `axis-${props.axis}`];
|
|
32
|
-
});
|
|
33
|
-
|
|
34
42
|
const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
|
|
35
43
|
|
|
36
44
|
watch(
|
|
@@ -38,12 +46,16 @@ watch(
|
|
|
38
46
|
() => resetElementClasses(props.styleClassPassthrough)
|
|
39
47
|
);
|
|
40
48
|
|
|
41
|
-
|
|
42
|
-
|
|
49
|
+
// CMS data can hold segments with missing or blank text; drop them so no stray spaces render.
|
|
50
|
+
const normalisedContent = computed(() => {
|
|
51
|
+
const segments = (props.textContent ?? [])
|
|
52
|
+
.map((item) => ({ ...item, text: String(item?.text ?? "").trim() }))
|
|
53
|
+
.filter((item) => item.text !== "");
|
|
54
|
+
return segments.map((item, index) => ({
|
|
43
55
|
...item,
|
|
44
|
-
text: item.text
|
|
45
|
-
}))
|
|
46
|
-
);
|
|
56
|
+
text: item.text + (index < segments.length - 1 ? " " : ""),
|
|
57
|
+
}));
|
|
58
|
+
});
|
|
47
59
|
</script>
|
|
48
60
|
|
|
49
61
|
<style lang="css">
|
|
@@ -55,14 +67,11 @@ const normalisedContent = computed(() =>
|
|
|
55
67
|
"wght" 400,
|
|
56
68
|
"ital" 1;
|
|
57
69
|
line-height: 1;
|
|
70
|
+
overflow-wrap: anywhere;
|
|
58
71
|
|
|
59
72
|
margin: var(--hero-text-margin, 0);
|
|
60
73
|
|
|
61
|
-
|
|
62
|
-
flex-direction: row;
|
|
63
|
-
gap: var(--hero-text-horizontal-gap, 0.5ch);
|
|
64
|
-
}
|
|
65
|
-
&.axis-vertical {
|
|
74
|
+
&[data-axis="vertical"] {
|
|
66
75
|
display: flex;
|
|
67
76
|
gap: var(--hero-text-vertical-gap, 0.4em);
|
|
68
77
|
flex-direction: column;
|
|
@@ -73,19 +82,19 @@ const normalisedContent = computed(() =>
|
|
|
73
82
|
color: var(--hero-text-icon-colour, var(--colour-text-accent));
|
|
74
83
|
}
|
|
75
84
|
|
|
76
|
-
|
|
85
|
+
&[data-font-size="display"] {
|
|
77
86
|
font-size: var(--hero-text-display, clamp(4.8rem, 4vw + 2rem, 9.6rem));
|
|
78
87
|
}
|
|
79
88
|
|
|
80
|
-
|
|
89
|
+
&[data-font-size="title"] {
|
|
81
90
|
font-size: var(--hero-text-title, clamp(3.6rem, 4vw + 2rem, 4.8rem));
|
|
82
91
|
}
|
|
83
92
|
|
|
84
|
-
|
|
93
|
+
&[data-font-size="heading"] {
|
|
85
94
|
font-size: var(--hero-text-heading, clamp(2.8rem, 4vw + 2rem, 3rem));
|
|
86
95
|
}
|
|
87
96
|
|
|
88
|
-
|
|
97
|
+
&[data-font-size="subheading"] {
|
|
89
98
|
font-size: var(--hero-text-subheading, 2.4rem);
|
|
90
99
|
|
|
91
100
|
.hero-text__icon {
|
|
@@ -93,11 +102,11 @@ const normalisedContent = computed(() =>
|
|
|
93
102
|
}
|
|
94
103
|
}
|
|
95
104
|
|
|
96
|
-
|
|
105
|
+
&[data-font-size="label"] {
|
|
97
106
|
font-size: var(--hero-text-label, 1.75rem);
|
|
98
107
|
}
|
|
99
108
|
|
|
100
|
-
.accent {
|
|
109
|
+
.hero-text-segment.accent {
|
|
101
110
|
--_hero-text-accent-offset: var(--hero-text-accent-offset, 0.2em);
|
|
102
111
|
|
|
103
112
|
background-clip: text;
|
|
@@ -2,7 +2,7 @@ import { test, expect, type Page, type Locator } from "@playwright/test";
|
|
|
2
2
|
|
|
3
3
|
// --- Config ---
|
|
4
4
|
const STORYBOOK_URL = "http://127.0.0.1:6006";
|
|
5
|
-
const STORY_BASE = "atoms-text-blocks-
|
|
5
|
+
const STORY_BASE = "atoms-text-blocks-hero-text";
|
|
6
6
|
const HEADING_TIMEOUT = 20_000;
|
|
7
7
|
|
|
8
8
|
// --- Types ---
|