srcdev-nuxt-components 9.6.12 → 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 (111) hide show
  1. package/.claude/rules/consumer/ids-from-useid.md +2 -1
  2. package/.claude/rules/consumer/story-register-components.md +1 -1
  3. package/.claude/rules/consumer/vfor-keys-from-data.md +14 -0
  4. package/.claude/skills/component-compliance-checklist.md +24 -1
  5. package/.claude/skills/component-naming.md +1 -0
  6. package/.claude/skills/components/banner-video.md +24 -30
  7. package/.claude/skills/components/content-docs.md +18 -5
  8. package/.claude/skills/components/display-dialog.md +16 -6
  9. package/.claude/skills/components/display-qr-code.md +22 -4
  10. package/.claude/skills/components/{display-tooltip-defined.md → display-tooltip-rich.md} +40 -42
  11. package/.claude/skills/components/display-tooltip.md +17 -6
  12. package/.claude/skills/components/eyebrow-text.md +9 -5
  13. package/.claude/skills/components/header-block.md +13 -5
  14. package/.claude/skills/components/hero-text.md +13 -9
  15. package/.claude/skills/components/link-text.md +8 -5
  16. package/.claude/skills/components/pop-over.md +17 -3
  17. package/.claude/skills/components/skip-links.md +16 -0
  18. package/.claude/skills/composable-tooltips-guide.md +4 -4
  19. package/.claude/skills/index.md +6 -6
  20. package/.claude/skills/storybook-add-story.md +5 -2
  21. package/.claude/skills/testing-add-playwright.md +12 -7
  22. package/.claude/skills/testing-add-unit-test.md +8 -0
  23. package/.vscode/srcdev-component-banner-video.code-snippets +38 -25
  24. package/.vscode/srcdev-component-content-docs.code-snippets +69 -60
  25. package/.vscode/srcdev-component-display-dialog.code-snippets +7 -5
  26. package/.vscode/srcdev-component-display-qr-code.code-snippets +12 -1
  27. package/.vscode/srcdev-component-display-tooltip-rich.code-snippets +50 -0
  28. package/.vscode/srcdev-component-display-tooltip.code-snippets +31 -0
  29. package/.vscode/srcdev-component-hero-text.code-snippets +21 -5
  30. package/.vscode/srcdev-component-link-text.code-snippets +3 -3
  31. package/.vscode/srcdev-component-pop-over.code-snippets +28 -0
  32. package/.vscode/srcdev-component-skip-links.code-snippets +1 -1
  33. package/app/components/01.atoms/banner-video/BannerVideo.vue +51 -19
  34. package/app/components/01.atoms/banner-video/CONSUMER-STYLING.md +59 -40
  35. package/app/components/01.atoms/banner-video/stories/BannerVideo.stories.ts +88 -1
  36. package/app/components/01.atoms/banner-video/tests/BannerVideo.spec.ts +127 -78
  37. package/app/components/01.atoms/banner-video/tests/__snapshots__/BannerVideo.spec.ts.snap +12 -4
  38. package/app/components/01.atoms/content-wrappers/docs-pages/CONSUMER-STYLING.md +42 -14
  39. package/app/components/01.atoms/content-wrappers/docs-pages/ContentDocs.vue +86 -112
  40. package/app/components/01.atoms/content-wrappers/docs-pages/stories/ContentDocs.stories.ts +134 -8
  41. package/app/components/01.atoms/content-wrappers/docs-pages/tests/ContentDocs.spec.ts +131 -65
  42. package/app/components/01.atoms/display-dialog/CONSUMER-STYLING.md +28 -3
  43. package/app/components/01.atoms/display-dialog/DisplayDialog.vue +39 -31
  44. package/app/components/01.atoms/display-dialog/stories/DisplayDialog.stories.ts +92 -1
  45. package/app/components/01.atoms/display-dialog/tests/DisplayDialog.spec.ts +87 -41
  46. package/app/components/01.atoms/display-dialog/tests/__snapshots__/DisplayDialog.spec.ts.snap +5 -1
  47. package/app/components/01.atoms/display-tooltip/CONSUMER-STYLING.md +70 -24
  48. package/app/components/01.atoms/display-tooltip/DisplayTooltip.vue +24 -12
  49. package/app/components/01.atoms/display-tooltip/stories/DisplayTooltip.stories.ts +89 -2
  50. package/app/components/01.atoms/display-tooltip/tests/DisplayTooltip.spec.ts +65 -18
  51. package/app/components/01.atoms/display-tooltip/tests/__snapshots__/DisplayTooltip.spec.ts.snap +3 -3
  52. package/app/components/01.atoms/navigation/skip-links/CONSUMER-STYLING.md +65 -19
  53. package/app/components/01.atoms/navigation/skip-links/SkipLinks.vue +20 -14
  54. package/app/components/01.atoms/navigation/skip-links/stories/SkipLinks.stories.ts +89 -20
  55. package/app/components/01.atoms/navigation/skip-links/tests/SkipLinks.spec.ts +70 -10
  56. package/app/components/01.atoms/pop-over/CONSUMER-STYLING.md +42 -9
  57. package/app/components/01.atoms/pop-over/PopOver.vue +28 -12
  58. package/app/components/01.atoms/pop-over/stories/PopOver.stories.ts +114 -2
  59. package/app/components/01.atoms/pop-over/tests/PopOver.spec.ts +35 -0
  60. package/app/components/01.atoms/pop-over/tests/__snapshots__/PopOver.spec.ts.snap +5 -1
  61. package/app/components/01.atoms/qr-code/CONSUMER-STYLING.md +16 -3
  62. package/app/components/01.atoms/qr-code/DisplayQrCode.vue +31 -0
  63. package/app/components/01.atoms/qr-code/stories/DisplayQrCode.stories.ts +190 -157
  64. package/app/components/01.atoms/qr-code/tests/DisplayQrCode.spec.ts +58 -0
  65. package/app/components/01.atoms/text-blocks/eyebrow-text/CONSUMER-STYLING.md +9 -1
  66. package/app/components/01.atoms/text-blocks/eyebrow-text/EyebrowText.vue +5 -4
  67. package/app/components/01.atoms/text-blocks/eyebrow-text/playwright/eyebrow-text.playwright.ts +1 -1
  68. package/app/components/01.atoms/text-blocks/eyebrow-text/stories/EyebrowText.stories.ts +104 -17
  69. package/app/components/01.atoms/text-blocks/eyebrow-text/tests/EyebrowText.spec.ts +68 -118
  70. package/app/components/01.atoms/text-blocks/eyebrow-text/tests/__snapshots__/EyebrowText.spec.ts.snap +8 -8
  71. package/app/components/01.atoms/text-blocks/header-block/CONSUMER-STYLING.md +49 -24
  72. package/app/components/01.atoms/text-blocks/header-block/HeaderBlock.vue +9 -1
  73. package/app/components/01.atoms/text-blocks/header-block/stories/HeaderBlock.stories.ts +96 -26
  74. package/app/components/01.atoms/text-blocks/header-block/tests/HeaderBlock.spec.ts +64 -48
  75. package/app/components/01.atoms/text-blocks/header-block/tests/__snapshots__/HeaderBlock.spec.ts.snap +2 -2
  76. package/app/components/01.atoms/text-blocks/hero-text/CONSUMER-STYLING.md +22 -6
  77. package/app/components/01.atoms/text-blocks/hero-text/HeroText.vue +32 -23
  78. package/app/components/01.atoms/text-blocks/hero-text/playwright/hero-text.playwright.ts +1 -1
  79. package/app/components/01.atoms/text-blocks/hero-text/stories/HeroText.stories.ts +176 -23
  80. package/app/components/01.atoms/text-blocks/hero-text/tests/HeroText.spec.ts +129 -123
  81. package/app/components/01.atoms/text-blocks/hero-text/tests/__snapshots__/HeroText.spec.ts.snap +2 -2
  82. package/app/components/01.atoms/text-blocks/link-text/CONSUMER-STYLING.md +17 -0
  83. package/app/components/01.atoms/text-blocks/link-text/LinkText.vue +9 -3
  84. package/app/components/01.atoms/text-blocks/link-text/stories/LinkText.stories.ts +96 -99
  85. package/app/components/01.atoms/text-blocks/link-text/tests/LinkText.spec.ts +84 -93
  86. package/app/components/02.molecules/display-tooltip-rich/CONSUMER-STYLING.md +110 -0
  87. package/app/components/02.molecules/display-tooltip-rich/DisplayTooltipRich.vue +148 -0
  88. package/app/components/02.molecules/display-tooltip-rich/stories/DisplayTooltipRich.stories.ts +254 -0
  89. package/app/components/02.molecules/display-tooltip-rich/tests/DisplayTooltipRich.spec.ts +156 -0
  90. package/app/components/02.molecules/display-tooltip-rich/tests/__snapshots__/DisplayTooltipRich.spec.ts.snap +22 -0
  91. package/app/components/02.molecules/navigation/deep-expanding-menu/DeepExpandingMenu.vue +1 -0
  92. package/app/components/02.molecules/navigation/deep-expanding-menu/tests/__snapshots__/DeepExpandingMenu.spec.ts.snap +1 -1
  93. package/app/components/02.molecules/price-list/tests/__snapshots__/PriceList.spec.ts.snap +8 -8
  94. package/app/components/03.organisms/image-galleries/carousel-flip/CarouselFlip.vue +1 -0
  95. package/app/components/03.organisms/services/service-detail/tests/__snapshots__/ServiceDetail.spec.ts.snap +15 -15
  96. package/app/components/03.organisms/services/service-summary/tests/__snapshots__/ServiceSummary.spec.ts.snap +3 -3
  97. package/app/components/03.organisms/services/services-card/tests/ServicesCard.spec.ts +3 -3
  98. package/app/components/03.organisms/services/services-card/tests/__snapshots__/ServicesCard.spec.ts.snap +3 -3
  99. package/app/components/03.organisms/services/services-grids/tests/ServicesCardGrid.spec.ts +1 -1
  100. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServiceSummaryGrid.spec.ts.snap +9 -9
  101. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesCardGrid.spec.ts.snap +9 -9
  102. package/app/composables/tests/useTooltips.spec.ts +3 -3
  103. package/app/types/components/index.ts +1 -1
  104. package/package.json +1 -1
  105. package/.vscode/srcdev-component-display-tooltip-defined.code-snippets +0 -29
  106. package/app/components/02.molecules/display-tooltip-defined/CONSUMER-STYLING.md +0 -53
  107. package/app/components/02.molecules/display-tooltip-defined/DisplayTooltipDefined.vue +0 -117
  108. package/app/components/02.molecules/display-tooltip-defined/stories/DisplayTooltipDefined.stories.ts +0 -129
  109. package/app/components/02.molecules/display-tooltip-defined/tests/DisplayTooltipDefined.spec.ts +0 -85
  110. package/app/components/02.molecules/display-tooltip-defined/tests/__snapshots__/DisplayTooltipDefined.spec.ts.snap +0 -17
  111. /package/app/types/components/{display-tooltip-defined.d.ts → display-tooltip-rich.d.ts} +0 -0
@@ -1,5 +1,6 @@
1
1
  import HeroText from "../HeroText.vue";
2
2
  import type { Meta, StoryObj } from "@nuxtjs/storybook";
3
+ import type { TextConfig } from "~/types/components";
3
4
 
4
5
  const meta: Meta<typeof HeroText> = {
5
6
  title: "Atoms/Text Blocks/Hero Text",
@@ -8,46 +9,40 @@ const meta: Meta<typeof HeroText> = {
8
9
  tag: {
9
10
  control: { type: "select" },
10
11
  options: ["h1", "h2", "h3", "h4", "h5", "h6"],
11
- description: "HTML tag for the element",
12
- defaultValue: "h1",
12
+ description: "Heading element to render.",
13
+ table: { category: "Basic" },
13
14
  },
14
15
  axis: {
15
16
  control: { type: "select" },
16
17
  options: ["horizontal", "vertical"],
17
- description: "Layout axis",
18
- defaultValue: "horizontal",
18
+ description: "`horizontal` flows the segments as one line of text; `vertical` stacks them in a column. Set as `data-axis`.",
19
+ table: { category: "Basic" },
19
20
  },
20
21
  fontSize: {
21
22
  control: { type: "select" },
22
23
  options: ["display", "title", "heading", "subheading", "label"],
23
- description: "Semantic font size",
24
- defaultValue: "title",
24
+ description: "Size step, set as `data-font-size` and sized by `--hero-text-{size}`.",
25
+ table: { category: "Basic" },
25
26
  },
26
27
  textContent: {
27
28
  control: "object",
28
- description: "Array of text segments with styleClass",
29
- defaultValue: [
30
- { text: "Designing", styleClass: "normal" },
31
- { text: "Artistry", styleClass: "accent" },
32
- { text: "at Home", styleClass: "normal" },
33
- ],
29
+ description:
30
+ "Segments: `{ text, styleClass?: \"normal\" | \"accent\" }`. Text is trimmed, a space joins segments, and blank segments are dropped.",
31
+ table: { category: "Content" },
34
32
  },
35
33
  icon: {
36
34
  control: { type: "select" },
37
35
  options: [undefined, "lucide:sparkles"],
38
- description: "Icon name",
36
+ description: "Iconify name for a decorative icon before the text.",
37
+ table: { category: "Basic" },
39
38
  },
40
- styleClassPassthrough: {
41
- control: "object",
42
- description: "Additional classes",
43
- defaultValue: [],
39
+ id: {
40
+ control: "text",
41
+ description: "Rendered as `id`, for a section's `aria-labelledby`.",
42
+ table: { category: "Accessibility" },
44
43
  },
44
+ styleClassPassthrough: { table: { disable: true } },
45
45
  },
46
- };
47
-
48
- export default meta;
49
- type Story = StoryObj<typeof HeroText>;
50
- export const Default: Story = {
51
46
  args: {
52
47
  tag: "h1",
53
48
  axis: "horizontal",
@@ -58,8 +53,75 @@ export const Default: Story = {
58
53
  { text: "at Home", styleClass: "normal" },
59
54
  ],
60
55
  icon: "lucide:sparkles",
61
- styleClassPassthrough: [],
62
56
  },
57
+ parameters: {
58
+ docs: {
59
+ description: {
60
+ component:
61
+ "A heading built from text segments (`textContent`), each `normal` or `accent` (italic, filled with the `--hero-text-bg-img` gradient). `fontSize` picks one of five size steps, `axis` flows the segments inline or stacks them, and `icon` adds a decorative icon. Rendered as the title in `ServicesCard`, `ServiceSummary`, `ServiceDetail` and as `PriceList`'s group headings.",
62
+ },
63
+ },
64
+ },
65
+ };
66
+
67
+ export default meta;
68
+ type Story = StoryObj<typeof HeroText>;
69
+
70
+ // Links in the canvas open in the Storybook manager (target="_top"), not inside the preview iframe.
71
+ const storyNoteStyle = "margin: 0 0 1.6rem; font-size: 1.4rem;";
72
+ const parentNote = `
73
+ <p style="${storyNoteStyle}">
74
+ In use: the title in
75
+ <a href="/?path=/story/organisms-services-services-card--config-overrides" target="_top">ServicesCard</a>
76
+ (size and tag set through <code>heroConfig</code>),
77
+ <a href="/?path=/story/organisms-services-service-summary--default" target="_top">ServiceSummary</a>,
78
+ <a href="/?path=/story/organisms-services-service-detail--default" target="_top">ServiceDetail</a>, and the group headings in
79
+ <a href="/?path=/story/molecules-pricelist--default" target="_top">PriceList</a>.
80
+ </p>
81
+ `;
82
+ const parentDocs =
83
+ "In use: the title in [ServicesCard](?path=/story/organisms-services-services-card--config-overrides) (sized through `heroConfig`), [ServiceSummary](?path=/story/organisms-services-service-summary--default), [ServiceDetail](?path=/story/organisms-services-service-detail--default) and the group headings in [PriceList](?path=/story/molecules-pricelist--default).";
84
+
85
+ export const Default: Story = {
86
+ render: (args) => ({
87
+ components: { HeroText },
88
+ setup() {
89
+ return { args };
90
+ },
91
+ template: `<div>${parentNote}<HeroText v-bind="args" /></div>`,
92
+ }),
93
+ parameters: {
94
+ docs: {
95
+ description: {
96
+ story: `Three segments with the middle one accented. Use the Controls panel to change the tag, axis, size, icon and segments. ${parentDocs}`,
97
+ },
98
+ },
99
+ },
100
+ };
101
+
102
+ export const Sizes: Story = {
103
+ render: (args) => ({
104
+ components: { HeroText },
105
+ setup() {
106
+ return { args, sizes: ["display", "title", "heading", "subheading", "label"] };
107
+ },
108
+ template: `
109
+ <div style="display: grid; gap: 2.4rem;">
110
+ <HeroText v-for="size in sizes" :key="size" v-bind="args" :font-size="size" />
111
+ </div>
112
+ `,
113
+ }),
114
+ parameters: {
115
+ docs: {
116
+ description: {
117
+ story: "Every `fontSize` step, largest first. The icon shrinks to `0.75em` at `subheading`.",
118
+ },
119
+ },
120
+ },
121
+ };
122
+
123
+ export const Vertical: Story = {
124
+ args: { axis: "vertical", fontSize: "display", icon: undefined },
63
125
  render: (args) => ({
64
126
  components: { HeroText },
65
127
  setup() {
@@ -67,4 +129,95 @@ export const Default: Story = {
67
129
  },
68
130
  template: `<HeroText v-bind="args" />`,
69
131
  }),
132
+ parameters: {
133
+ docs: {
134
+ description: {
135
+ story: "`axis=\"vertical\"` stacks each segment on its own line, spaced by `--hero-text-vertical-gap`.",
136
+ },
137
+ },
138
+ },
139
+ };
140
+
141
+ const longGerman = "Freihändige Balayage-Farbtechnik für natürlich wirkende Übergänge";
142
+
143
+ const stressCases: { note: string; textContent: TextConfig[]; fontSize?: string }[] = [
144
+ {
145
+ note: "Unbroken word and long URL at display size",
146
+ fontSize: "display",
147
+ textContent: [
148
+ { text: "Kostenloselieferungfürallebestellungen", styleClass: "normal" },
149
+ { text: "https://example.invalid/a/very/long/url", styleClass: "accent" },
150
+ ],
151
+ },
152
+ {
153
+ note: "Long German copy across an accent segment",
154
+ textContent: [
155
+ { text: longGerman, styleClass: "normal" },
156
+ { text: longGerman, styleClass: "accent" },
157
+ ],
158
+ },
159
+ {
160
+ note: "Emoji first and HTML-like text (shown as text)",
161
+ textContent: [
162
+ { text: "😀 Neue", styleClass: "normal" },
163
+ { text: "<b>not bold</b>", styleClass: "accent" },
164
+ ],
165
+ },
166
+ {
167
+ note: "Blank and missing segments are dropped: only “Kept” and “Too” render, one space apart",
168
+ textContent: [
169
+ { text: "Kept", styleClass: "normal" },
170
+ { text: "", styleClass: "accent" },
171
+ { text: " ", styleClass: "normal" },
172
+ { text: undefined, styleClass: "accent" } as unknown as TextConfig,
173
+ { text: "Too", styleClass: "accent" },
174
+ ],
175
+ },
176
+ {
177
+ note: "A single character",
178
+ textContent: [{ text: "A", styleClass: "accent" }],
179
+ },
180
+ ];
181
+
182
+ export const StressTest: Story = {
183
+ name: "Stress Test (Worst-Case Data)",
184
+ render: () => ({
185
+ components: { HeroText },
186
+ setup() {
187
+ return { stressCases };
188
+ },
189
+ template: `
190
+ <div style="display: grid; gap: 2.4rem; padding: 2.4rem;">
191
+ <template v-for="(item, index) in stressCases" :key="index">
192
+ <p style="margin: 0; font-size: 1.4rem;">{{ item.note }}, in a 24rem column.</p>
193
+ <div style="inline-size: 24rem; outline: 0.1rem dashed currentColor;">
194
+ <HeroText tag="h2" :font-size="item.fontSize ?? 'title'" :text-content="item.textContent" icon="lucide:sparkles" />
195
+ </div>
196
+ </template>
197
+
198
+ <p style="margin: 0; font-size: 1.4rem;">Right-to-left text, vertical axis.</p>
199
+ <HeroText
200
+ tag="h2"
201
+ axis="vertical"
202
+ dir="rtl"
203
+ :text-content="[{ text: 'تسريحات شعر', styleClass: 'normal' }, { text: 'لموسم الخريف', styleClass: 'accent' }]"
204
+ />
205
+
206
+ <p style="margin: 0; font-size: 1.4rem;">Empty <code>textContent</code>: an empty heading, no stray space.</p>
207
+ <div style="outline: 0.1rem dashed currentColor;">
208
+ <HeroText tag="h2" :text-content="[]" />
209
+ </div>
210
+ </div>
211
+ `,
212
+ }),
213
+ parameters: {
214
+ controls: { disable: true },
215
+ docs: {
216
+ story: { inline: false, height: "1300px" },
217
+ description: {
218
+ story:
219
+ "Worst-case segments. Check: unbroken words and URLs wrap inside each dashed 24rem column, including at `display` size; long copy wraps across the accent segment with its gradient intact and its descenders unclipped; emoji and HTML-like text render as text; blank or missing segments leave no double spaces; right-to-left text stacks correctly on the vertical axis; an empty `textContent` renders an empty heading.",
220
+ },
221
+ },
222
+ },
70
223
  };
@@ -1,157 +1,163 @@
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 HeroText from "../HeroText.vue";
4
+ import type { TextConfig } from "~/types/components";
5
+
6
+ type HeroTextProps = InstanceType<typeof HeroText>["$props"];
7
+
8
+ const mounted: Awaited<ReturnType<typeof mountSuspended>>[] = [];
9
+
10
+ const mountHero = async (props: Partial<HeroTextProps> = {}) => {
11
+ const wrapper = await mountSuspended(HeroText, {
12
+ props: { tag: "h1", textContent: [{ text: "Hello", styleClass: "normal" }], ...props } as HeroTextProps,
13
+ });
14
+ mounted.push(wrapper);
15
+ return wrapper;
16
+ };
17
+
18
+ const segmentTexts = (wrapper: Awaited<ReturnType<typeof mountHero>>) =>
19
+ wrapper.findAll(".hero-text-segment").map((span) => span.element.textContent);
20
+
21
+ afterEach(() => {
22
+ mounted.splice(0).forEach((wrapper) => wrapper.unmount());
23
+ vi.restoreAllMocks();
24
+ });
4
25
 
5
26
  describe("HeroText", () => {
6
27
  it("mounts without error", async () => {
7
- const wrapper = await mountSuspended(HeroText, {
8
- props: {
9
- tag: "h1",
10
- axis: "horizontal",
11
- fontSize: "title",
12
- textContent: [
13
- { text: "Designing", styleClass: "normal" },
14
- { text: "Artistry", styleClass: "accent" },
15
- { text: "at Home", styleClass: "normal" },
16
- ],
17
- styleClassPassthrough: [],
18
- },
19
- });
28
+ const wrapper = await mountHero();
20
29
  expect(wrapper.vm).toBeTruthy();
21
30
  });
22
31
 
23
32
  it("renders correct HTML structure", async () => {
24
- const wrapper = await mountSuspended(HeroText, {
25
- props: {
26
- tag: "h2",
27
- axis: "vertical",
28
- fontSize: "display",
29
- textContent: [
30
- { text: "Hello", styleClass: "normal" },
31
- { text: "World", styleClass: "accent" },
32
- ],
33
- styleClassPassthrough: ["custom-class"],
34
- },
33
+ const wrapper = await mountHero({
34
+ tag: "h2",
35
+ axis: "vertical",
36
+ fontSize: "display",
37
+ textContent: [
38
+ { text: "Hello", styleClass: "normal" },
39
+ { text: "World", styleClass: "accent" },
40
+ ],
41
+ styleClassPassthrough: ["custom-class"],
35
42
  });
36
43
  expect(wrapper.html()).toMatchSnapshot();
37
44
  });
38
45
 
39
- it("applies axis and fontSize classes", async () => {
40
- const wrapper = await mountSuspended(HeroText, {
41
- props: {
42
- tag: "h3",
43
- axis: "vertical",
44
- fontSize: "heading",
45
- textContent: [{ text: "A", styleClass: "normal" }],
46
- styleClassPassthrough: [],
47
- },
48
- });
49
- const el = wrapper.find(".hero-text");
50
- expect(el.classes()).toContain("axis-vertical");
51
- expect(el.classes()).toContain("heading");
52
- });
53
-
54
- it("renders all textContent items as spans", async () => {
55
- const wrapper = await mountSuspended(HeroText, {
56
- props: {
57
- tag: "h4",
58
- textContent: [
59
- { text: "One", styleClass: "normal" },
60
- { text: "Two", styleClass: "accent" },
61
- { text: "Three", styleClass: "normal" },
62
- ],
63
- },
64
- });
65
- const spans = wrapper.findAll("span");
66
- expect(spans.length).toBe(3);
67
- expect(spans[0]?.text()).toBe("One");
68
- expect(spans[1]?.text()).toBe("Two");
69
- expect(spans[2]?.text()).toBe("Three");
46
+ // ─── Size and axis ───────────────────────────────────────────────────────
47
+
48
+ it("defaults to data-font-size='title' and data-axis='horizontal'", async () => {
49
+ const wrapper = await mountHero();
50
+ expect(wrapper.attributes("data-font-size")).toBe("title");
51
+ expect(wrapper.attributes("data-axis")).toBe("horizontal");
70
52
  });
71
53
 
72
- it("applies styleClassPassthrough classes", async () => {
73
- const wrapper = await mountSuspended(HeroText, {
74
- props: {
75
- tag: "h5",
76
- textContent: [{ text: "Styled", styleClass: "accent" }],
77
- styleClassPassthrough: ["extra-class", "another-class"],
78
- },
54
+ it("sets data-axis and data-font-size from the props", async () => {
55
+ const wrapper = await mountHero({ tag: "h3", axis: "vertical", fontSize: "heading" });
56
+ expect(wrapper.attributes("data-axis")).toBe("vertical");
57
+ expect(wrapper.attributes("data-font-size")).toBe("heading");
58
+ });
59
+
60
+ it("puts no bare size or axis class on the root", async () => {
61
+ const wrapper = await mountHero({ axis: "vertical", fontSize: "label" });
62
+ expect(wrapper.classes()).toEqual(["hero-text"]);
63
+ });
64
+
65
+ it("updates the attributes when the props change after mount", async () => {
66
+ const wrapper = await mountHero({ fontSize: "label" });
67
+ await wrapper.setProps({ fontSize: "display", axis: "vertical" });
68
+ expect(wrapper.attributes("data-font-size")).toBe("display");
69
+ expect(wrapper.attributes("data-axis")).toBe("vertical");
70
+ });
71
+
72
+ it("renders the tag from the tag prop", async () => {
73
+ const wrapper = await mountHero({ tag: "h4" });
74
+ expect(wrapper.element.tagName).toBe("H4");
75
+ });
76
+
77
+ it("renders the id when given", async () => {
78
+ const wrapper = await mountHero({ id: "hero-heading" });
79
+ expect(wrapper.attributes("id")).toBe("hero-heading");
80
+ });
81
+
82
+ // ─── Segments ────────────────────────────────────────────────────────────
83
+
84
+ it("renders every textContent item as a hero-text-segment span with its styleClass", async () => {
85
+ const wrapper = await mountHero({
86
+ tag: "h4",
87
+ textContent: [
88
+ { text: "One", styleClass: "normal" },
89
+ { text: "Two", styleClass: "accent" },
90
+ { text: "Three" },
91
+ ],
79
92
  });
80
- const el = wrapper.find(".hero-text");
81
- expect(el.classes()).toContain("extra-class");
82
- expect(el.classes()).toContain("another-class");
93
+ const spans = wrapper.findAll(".hero-text-segment");
94
+ expect(spans).toHaveLength(3);
95
+ expect(spans[0]?.classes()).toContain("normal");
96
+ expect(spans[1]?.classes()).toContain("accent");
97
+ expect(spans[2]?.classes()).toEqual(["hero-text-segment"]);
83
98
  });
84
99
 
85
- // -------------------------
86
- // normalisedContent
87
- // -------------------------
88
100
  it("trims leading and trailing whitespace from text segments", async () => {
89
- const wrapper = await mountSuspended(HeroText, {
90
- props: {
91
- tag: "h1",
92
- textContent: [{ text: " Hello ", styleClass: "normal" }],
93
- },
94
- });
95
- expect(wrapper.find("span").element.textContent).toBe("Hello");
101
+ const wrapper = await mountHero({ textContent: [{ text: " Hello ", styleClass: "normal" }] });
102
+ expect(segmentTexts(wrapper)).toEqual(["Hello"]);
96
103
  });
97
104
 
98
105
  it("adds a trailing space to all segments except the last", async () => {
99
- const wrapper = await mountSuspended(HeroText, {
100
- props: {
101
- tag: "h1",
102
- textContent: [
103
- { text: "One", styleClass: "normal" },
104
- { text: "Two", styleClass: "normal" },
105
- { text: "Three", styleClass: "normal" },
106
- ],
107
- },
106
+ const wrapper = await mountHero({
107
+ textContent: [{ text: "One" }, { text: "Two" }, { text: "Three" }],
108
108
  });
109
- const spans = wrapper.findAll("span");
110
- expect(spans[0]!.element.textContent).toBe("One ");
111
- expect(spans[1]!.element.textContent).toBe("Two ");
112
- expect(spans[2]!.element.textContent).toBe("Three");
113
- });
114
-
115
- it("does not add a trailing space when there is only one segment", async () => {
116
- const wrapper = await mountSuspended(HeroText, {
117
- props: {
118
- tag: "h1",
119
- textContent: [{ text: "Solo", styleClass: "normal" }],
120
- },
121
- });
122
- expect(wrapper.find("span").element.textContent).toBe("Solo");
123
- });
124
-
125
- // -------------------------
126
- // Icon
127
- // -------------------------
128
- it("renders an icon when the icon prop is provided", async () => {
129
- const wrapper = await mountSuspended(HeroText, {
130
- props: {
131
- tag: "h1",
132
- textContent: [{ text: "Hello", styleClass: "normal" }],
133
- icon: "lucide:sparkles",
134
- },
109
+ expect(segmentTexts(wrapper)).toEqual(["One ", "Two ", "Three"]);
110
+ });
111
+
112
+ it("drops segments with empty, blank or missing text, and spaces only the remaining ones", async () => {
113
+ const wrapper = await mountHero({
114
+ textContent: [
115
+ { text: "One" },
116
+ { text: "" },
117
+ { text: " " },
118
+ { text: undefined } as unknown as TextConfig,
119
+ null as unknown as TextConfig,
120
+ { text: "Two" },
121
+ ],
135
122
  });
136
- expect(wrapper.find(".hero-text__icon").exists()).toBe(true);
123
+ expect(segmentTexts(wrapper)).toEqual(["One ", "Two"]);
124
+ });
125
+
126
+ it("renders nothing but the heading for an empty textContent", async () => {
127
+ const wrapper = await mountHero({ textContent: [] });
128
+ expect(wrapper.findAll(".hero-text-segment")).toHaveLength(0);
129
+ });
130
+
131
+ it("renders HTML-like text as text", async () => {
132
+ const wrapper = await mountHero({ textContent: [{ text: "<b>not bold</b>" }] });
133
+ expect(wrapper.find("b").exists()).toBe(false);
134
+ expect(wrapper.text()).toBe("<b>not bold</b>");
135
+ });
136
+
137
+ // ─── Icon ────────────────────────────────────────────────────────────────
138
+
139
+ it("renders a decorative icon when the icon prop is provided", async () => {
140
+ const wrapper = await mountHero({ icon: "lucide:sparkles" });
141
+ const icon = wrapper.find(".hero-text__icon");
142
+ expect(icon.exists()).toBe(true);
143
+ expect(icon.attributes("aria-hidden")).toBe("true");
137
144
  });
138
145
 
139
146
  it("does not render an icon when the icon prop is omitted", async () => {
140
- const wrapper = await mountSuspended(HeroText, {
141
- props: {
142
- tag: "h1",
143
- textContent: [{ text: "Hello", styleClass: "normal" }],
144
- },
145
- });
147
+ const wrapper = await mountHero();
146
148
  expect(wrapper.find(".hero-text__icon").exists()).toBe(false);
147
149
  });
148
- });
149
150
 
150
- describe("HeroText styleClassPassthrough reactivity", () => {
151
+ // ─── styleClassPassthrough ───────────────────────────────────────────────
152
+
153
+ it("applies styleClassPassthrough classes", async () => {
154
+ const wrapper = await mountHero({ styleClassPassthrough: ["extra-class", "another-class"] });
155
+ expect(wrapper.classes()).toContain("extra-class");
156
+ expect(wrapper.classes()).toContain("another-class");
157
+ });
158
+
151
159
  it("updates classes when styleClassPassthrough changes after mount", async () => {
152
- const wrapper = await mountSuspended(HeroText, {
153
- props: { tag: "h2", textContent: [{ text: "Hero" }], styleClassPassthrough: ["original"] },
154
- });
160
+ const wrapper = await mountHero({ styleClassPassthrough: ["original"] });
155
161
  await wrapper.setProps({ styleClassPassthrough: ["updated"] });
156
162
  expect(wrapper.classes()).not.toContain("original");
157
163
  expect(wrapper.classes()).toContain("updated");
@@ -1,7 +1,7 @@
1
1
  // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
2
 
3
3
  exports[`HeroText > renders correct HTML structure 1`] = `
4
- "<h2 class="hero-text custom-class display axis-vertical">
5
- <!--v-if--><span class="text-block-0 normal">Hello </span><span class="text-block-1 accent">World</span>
4
+ "<h2 class="hero-text custom-class" data-font-size="display" data-axis="vertical">
5
+ <!--v-if--><span class="hero-text-segment normal">Hello </span><span class="hero-text-segment accent">World</span>
6
6
  </h2>"
7
7
  `;
@@ -25,6 +25,9 @@ Private (not public API): `--_colour` (the resolved resting colour).
25
25
 
26
26
  ## State hooks
27
27
 
28
+ `:hover` and `:focus-visible` switch to the `-hover` tokens. `:focus-visible` also draws a
29
+ `0.2rem` outline in the resting colour, `0.3rem` out.
30
+
28
31
  Inner classes: `.link-text__label`, `.link-text__icon` with `.link-text__icon--left` /
29
32
  `.link-text__icon--right` (only rendered when the `left`/`right` slot is used).
30
33
 
@@ -43,3 +46,17 @@ Set the tokens above on an element you own (a page or section class, or a class
43
46
  ## Class passthrough
44
47
 
45
48
  `style-class-passthrough` adds classes to the root link element. Reactive after mount.
49
+
50
+ ---
51
+
52
+ ## Notes
53
+
54
+ - The root is an `inline-grid` capped at `max-inline-size: 100%`, and the label wraps
55
+ (`overflow-wrap: anywhere`), so a long or unbroken label or URL stays inside its column with the
56
+ icons centred on the wrapped text. Being a grid, it wraps as a block rather than flowing across
57
+ lines like a plain inline link.
58
+ - No line-clamp token: link text names its destination and must stay readable.
59
+
60
+ > Changed 2026-10-19: the label wraps (it used to overflow its column when unbroken) and the
61
+ > focus ring is sized in rem (`2px`/`3px` became `0.2rem`/`0.3rem`, the same at the library's
62
+ > 10px rem).
@@ -43,6 +43,7 @@ watch(
43
43
  display: inline-grid;
44
44
  grid-auto-flow: column;
45
45
  align-items: center;
46
+ max-inline-size: 100%;
46
47
  --_colour: var(--link-text-colour, currentColor);
47
48
 
48
49
  gap: var(--link-text-gap, 0.4em);
@@ -59,9 +60,14 @@ watch(
59
60
  }
60
61
 
61
62
  &:focus-visible {
62
- outline: 2px solid var(--_colour);
63
- outline-offset: 3px;
64
- border-radius: 2px;
63
+ outline: 0.2rem solid var(--_colour);
64
+ outline-offset: 0.3rem;
65
+ border-radius: 0.2rem;
66
+ }
67
+
68
+ .link-text__label {
69
+ min-inline-size: 0;
70
+ overflow-wrap: anywhere;
65
71
  }
66
72
 
67
73
  .link-text__icon {