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,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: "
|
|
12
|
-
|
|
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: "
|
|
18
|
-
|
|
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: "
|
|
24
|
-
|
|
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:
|
|
29
|
-
|
|
30
|
-
|
|
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: "
|
|
36
|
+
description: "Iconify name for a decorative icon before the text.",
|
|
37
|
+
table: { category: "Basic" },
|
|
39
38
|
},
|
|
40
|
-
|
|
41
|
-
control: "
|
|
42
|
-
description: "
|
|
43
|
-
|
|
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
|
|
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
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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("
|
|
73
|
-
const wrapper = await
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
|
81
|
-
expect(
|
|
82
|
-
expect(
|
|
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
|
|
90
|
-
|
|
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
|
|
100
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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");
|
package/app/components/01.atoms/text-blocks/hero-text/tests/__snapshots__/HeroText.spec.ts.snap
CHANGED
|
@@ -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
|
|
5
|
-
<!--v-if--><span class="text-
|
|
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:
|
|
63
|
-
outline-offset:
|
|
64
|
-
border-radius:
|
|
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 {
|