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.
Files changed (53) hide show
  1. package/.claude/rules/consumer/story-register-components.md +1 -1
  2. package/.claude/skills/component-compliance-checklist.md +5 -0
  3. package/.claude/skills/components/display-qr-code.md +22 -4
  4. package/.claude/skills/components/eyebrow-text.md +9 -5
  5. package/.claude/skills/components/header-block.md +13 -5
  6. package/.claude/skills/components/hero-text.md +13 -9
  7. package/.claude/skills/components/link-text.md +8 -5
  8. package/.claude/skills/components/pop-over.md +17 -3
  9. package/.claude/skills/storybook-add-story.md +5 -2
  10. package/.claude/skills/testing-add-playwright.md +12 -7
  11. package/.vscode/srcdev-component-display-qr-code.code-snippets +12 -1
  12. package/.vscode/srcdev-component-hero-text.code-snippets +21 -5
  13. package/.vscode/srcdev-component-link-text.code-snippets +3 -3
  14. package/.vscode/srcdev-component-pop-over.code-snippets +28 -0
  15. package/app/components/01.atoms/pop-over/CONSUMER-STYLING.md +42 -9
  16. package/app/components/01.atoms/pop-over/PopOver.vue +28 -12
  17. package/app/components/01.atoms/pop-over/stories/PopOver.stories.ts +114 -2
  18. package/app/components/01.atoms/pop-over/tests/PopOver.spec.ts +35 -0
  19. package/app/components/01.atoms/pop-over/tests/__snapshots__/PopOver.spec.ts.snap +5 -1
  20. package/app/components/01.atoms/qr-code/CONSUMER-STYLING.md +16 -3
  21. package/app/components/01.atoms/qr-code/DisplayQrCode.vue +31 -0
  22. package/app/components/01.atoms/qr-code/stories/DisplayQrCode.stories.ts +190 -157
  23. package/app/components/01.atoms/qr-code/tests/DisplayQrCode.spec.ts +58 -0
  24. package/app/components/01.atoms/text-blocks/eyebrow-text/CONSUMER-STYLING.md +9 -1
  25. package/app/components/01.atoms/text-blocks/eyebrow-text/EyebrowText.vue +5 -4
  26. package/app/components/01.atoms/text-blocks/eyebrow-text/playwright/eyebrow-text.playwright.ts +1 -1
  27. package/app/components/01.atoms/text-blocks/eyebrow-text/stories/EyebrowText.stories.ts +104 -17
  28. package/app/components/01.atoms/text-blocks/eyebrow-text/tests/EyebrowText.spec.ts +68 -118
  29. package/app/components/01.atoms/text-blocks/eyebrow-text/tests/__snapshots__/EyebrowText.spec.ts.snap +8 -8
  30. package/app/components/01.atoms/text-blocks/header-block/CONSUMER-STYLING.md +49 -24
  31. package/app/components/01.atoms/text-blocks/header-block/HeaderBlock.vue +9 -1
  32. package/app/components/01.atoms/text-blocks/header-block/stories/HeaderBlock.stories.ts +96 -26
  33. package/app/components/01.atoms/text-blocks/header-block/tests/HeaderBlock.spec.ts +64 -48
  34. package/app/components/01.atoms/text-blocks/header-block/tests/__snapshots__/HeaderBlock.spec.ts.snap +2 -2
  35. package/app/components/01.atoms/text-blocks/hero-text/CONSUMER-STYLING.md +22 -6
  36. package/app/components/01.atoms/text-blocks/hero-text/HeroText.vue +32 -23
  37. package/app/components/01.atoms/text-blocks/hero-text/playwright/hero-text.playwright.ts +1 -1
  38. package/app/components/01.atoms/text-blocks/hero-text/stories/HeroText.stories.ts +176 -23
  39. package/app/components/01.atoms/text-blocks/hero-text/tests/HeroText.spec.ts +129 -123
  40. package/app/components/01.atoms/text-blocks/hero-text/tests/__snapshots__/HeroText.spec.ts.snap +2 -2
  41. package/app/components/01.atoms/text-blocks/link-text/CONSUMER-STYLING.md +17 -0
  42. package/app/components/01.atoms/text-blocks/link-text/LinkText.vue +9 -3
  43. package/app/components/01.atoms/text-blocks/link-text/stories/LinkText.stories.ts +96 -99
  44. package/app/components/01.atoms/text-blocks/link-text/tests/LinkText.spec.ts +84 -93
  45. package/app/components/02.molecules/price-list/tests/__snapshots__/PriceList.spec.ts.snap +8 -8
  46. package/app/components/03.organisms/services/service-detail/tests/__snapshots__/ServiceDetail.spec.ts.snap +15 -15
  47. package/app/components/03.organisms/services/service-summary/tests/__snapshots__/ServiceSummary.spec.ts.snap +3 -3
  48. package/app/components/03.organisms/services/services-card/tests/ServicesCard.spec.ts +3 -3
  49. package/app/components/03.organisms/services/services-card/tests/__snapshots__/ServicesCard.spec.ts.snap +3 -3
  50. package/app/components/03.organisms/services/services-grids/tests/ServicesCardGrid.spec.ts +1 -1
  51. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServiceSummaryGrid.spec.ts.snap +9 -9
  52. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesCardGrid.spec.ts.snap +9 -9
  53. 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
- const meta: Meta<typeof HeaderBlock> = {
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 — which <h1>-<h6> tag is rendered",
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 — which .page-heading-N utility class is applied, independent of tagLevel",
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: "Bind to a wrapping section's aria-labelledby target (e.g. PageRow's heading-id slot prop)",
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
- styleClassPassthrough: {
23
- control: "object",
24
- description: "Additional classes applied to the root element",
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
- styleClassPassthrough: [],
46
+ headingText: "Page title",
31
47
  },
32
48
  parameters: {
33
49
  docs: {
34
50
  description: {
35
51
  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.",
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<typeof HeaderBlock>;
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: (args) => ({
47
- components: { HeaderBlock },
48
- setup() {
49
- return { args };
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
- template: `<HeaderBlock v-bind="args">Page title</HeaderBlock>`,
52
- }),
81
+ },
53
82
  };
54
83
 
55
84
  export const DecoupledLevels: Story = {
56
85
  name: "Semantic vs Visual Level Decoupled",
57
- args: { tagLevel: 2, classLevel: 1 },
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=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.",
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 — pass the same id both places (or, inside this library, the heading-id slot prop that PageRow/other section wrappers expose via useAriaLabelledById).",
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
- describe("HeaderBlock", () => {
6
- // ─── Mount ───────────────────────────────────────────────────────────────
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 mountSuspended(HeaderBlock, {
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 mountSuspended(HeaderBlock, {
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 mountSuspended(HeaderBlock, {
26
- props: { tagLevel: 3, classLevel: 5, id: "section-heading", styleClassPassthrough: ["custom-class"] },
27
- slots: { default: "Custom heading" },
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 mountSuspended(HeaderBlock, { slots: { default: "Title" } });
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 mountSuspended(HeaderBlock, {
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 mountSuspended(HeaderBlock, {
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 mountSuspended(HeaderBlock, {
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
- 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
- });
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
- 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
- });
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 mountSuspended(HeaderBlock, {
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 mountSuspended(HeaderBlock, { slots: { default: "Title" } });
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 mountSuspended(HeaderBlock, {
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 mountSuspended(HeaderBlock, {
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
- | `.hero-text.display`, `.title`, `.heading`, `.subheading`, `.label` | The `font-size` prop |
43
- | `.axis-horizontal`, `.axis-vertical` | The `axis` prop (vertical is a flex column) |
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`, and one `<span class="text-block-{n}">` per segment (plus its
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 :is="tag" :id="id" class="hero-text" :class="[elementClasses, ...componentClasses]">
3
- <Icon v-if="props.icon" :name="props.icon" class="hero-text__icon" />
4
- <span v-for="(item, index) in normalisedContent" :key="index" :class="['text-block-' + index, item.styleClass]">
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
- const normalisedContent = computed(() =>
42
- props.textContent.map((item, index) => ({
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.trim() + (index < props.textContent.length - 1 ? " " : ""),
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
- &.axis-horizontal {
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
- &.display {
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
- &.title {
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
- &.heading {
93
+ &[data-font-size="heading"] {
85
94
  font-size: var(--hero-text-heading, clamp(2.8rem, 4vw + 2rem, 3rem));
86
95
  }
87
96
 
88
- &.subheading {
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
- &.label {
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-herotext";
5
+ const STORY_BASE = "atoms-text-blocks-hero-text";
6
6
  const HEADING_TIMEOUT = 20_000;
7
7
 
8
8
  // --- Types ---