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
@@ -7,134 +7,131 @@ const meta: Meta<typeof LinkText> = {
7
7
  argTypes: {
8
8
  to: {
9
9
  control: "text",
10
- description: "Link destination (internal path or external URL)",
10
+ description: "Link destination (internal path or external URL).",
11
+ table: { category: "Basic" },
11
12
  },
12
13
  linkText: {
13
14
  control: "text",
14
- description: "Visible link label",
15
+ description: "Visible link label. It should name the destination, so it's never clamped.",
16
+ table: { category: "Content" },
15
17
  },
16
18
  external: {
17
19
  control: "boolean",
18
- description: "Force external link handling",
19
- defaultValue: false,
20
+ description: "Force external link handling on an internal-looking path.",
21
+ table: { category: "Basic" },
20
22
  },
21
23
  target: {
22
24
  control: { type: "select" },
23
25
  options: [undefined, "_blank", "_self"],
24
- description: "Link target attribute",
25
- },
26
- styleClassPassthrough: {
27
- control: "object",
28
- description: "Additional classes",
29
- defaultValue: [],
26
+ description: "Link `target`. NuxtLink adds `rel=\"noopener noreferrer\"` for an external `_blank`.",
27
+ table: { category: "Basic" },
30
28
  },
29
+ styleClassPassthrough: { table: { disable: true } },
31
30
  },
32
- };
33
-
34
- export default meta;
35
- type Story = StoryObj<typeof LinkText>;
36
-
37
- export const Default: Story = {
38
31
  args: {
39
32
  to: "/",
40
33
  linkText: "Read More",
41
34
  external: false,
42
- styleClassPassthrough: [],
43
35
  },
44
- render: (args) => ({
45
- components: { LinkText },
46
- setup() {
47
- return { args };
36
+ parameters: {
37
+ docs: {
38
+ description: {
39
+ component:
40
+ "A `NuxtLink` styled as a standalone text link. `to` and `linkText` are required; `left` and `right` slots take icons, which stay centred on the label. Colours, underline and gap come from `--link-text-*` tokens.",
41
+ },
48
42
  },
49
- template: `<LinkText v-bind="args" />`,
50
- }),
51
- };
52
-
53
- export const WithLeftIcon: Story = {
54
- args: {
55
- to: "/",
56
- linkText: "Go Back",
57
- styleClassPassthrough: [],
58
43
  },
59
- render: (args) => ({
60
- components: { LinkText },
61
- setup() {
62
- return { args };
63
- },
64
- template: `
65
- <LinkText v-bind="args">
66
- <template #left>
67
- <Icon name="lucide:arrow-left" />
68
- </template>
69
- </LinkText>
70
- `,
71
- }),
72
44
  };
73
45
 
74
- export const WithRightIcon: Story = {
75
- args: {
76
- to: "/",
77
- linkText: "Learn More",
78
- styleClassPassthrough: [],
79
- },
80
- render: (args) => ({
81
- components: { LinkText },
82
- setup() {
83
- return { args };
84
- },
85
- template: `
86
- <LinkText v-bind="args">
87
- <template #right>
88
- <Icon name="lucide:arrow-right" />
89
- </template>
90
- </LinkText>
91
- `,
92
- }),
93
- };
46
+ export default meta;
47
+ type Story = StoryObj<typeof LinkText>;
94
48
 
95
- export const WithBothIcons: Story = {
96
- args: {
97
- to: "/",
98
- linkText: "Explore",
99
- styleClassPassthrough: [],
100
- },
101
- render: (args) => ({
49
+ const withIcons = (left: string | null, right: string | null) => `
50
+ <LinkText v-bind="args">
51
+ ${left ? `<template #left><Icon name="${left}" /></template>` : ""}
52
+ ${right ? `<template #right><Icon name="${right}" /></template>` : ""}
53
+ </LinkText>
54
+ `;
55
+
56
+ const story = (template: string, description: string, args: Story["args"] = {}): Story => ({
57
+ args,
58
+ render: (storyArgs) => ({
102
59
  components: { LinkText },
103
60
  setup() {
104
- return { args };
61
+ return { args: storyArgs };
105
62
  },
106
- template: `
107
- <LinkText v-bind="args">
108
- <template #left>
109
- <Icon name="lucide:sparkles" />
110
- </template>
111
- <template #right>
112
- <Icon name="lucide:arrow-right" />
113
- </template>
114
- </LinkText>
115
- `,
63
+ template,
116
64
  }),
117
- };
65
+ parameters: { docs: { description: { story: description } } },
66
+ });
118
67
 
119
- export const ExternalLink: Story = {
120
- args: {
121
- to: "https://nuxt.com",
122
- linkText: "Nuxt Docs",
123
- external: true,
124
- target: "_blank",
125
- styleClassPassthrough: [],
126
- },
127
- render: (args) => ({
68
+ export const Default = story(withIcons(null, null), "A plain link. Use the Controls panel to change the destination, label and target.");
69
+
70
+ export const WithLeftIcon = story(withIcons("lucide:arrow-left", null), "A `left` slot icon for a back or previous link.", {
71
+ linkText: "Go Back",
72
+ });
73
+
74
+ export const WithRightIcon = story(withIcons(null, "lucide:arrow-right"), "A `right` slot icon for a forward or more link.", {
75
+ linkText: "Learn More",
76
+ });
77
+
78
+ export const WithBothIcons = story(withIcons("lucide:sparkles", "lucide:arrow-right"), "Both slots. Left icon, label, then right icon.", {
79
+ linkText: "Explore",
80
+ });
81
+
82
+ export const ExternalLink = story(
83
+ withIcons(null, "lucide:external-link"),
84
+ "An external link opening in a new tab, with an icon that says so.",
85
+ { to: "https://nuxt.com", linkText: "Nuxt Docs", external: true, target: "_blank" }
86
+ );
87
+
88
+ export const StressTest: Story = {
89
+ name: "Stress Test (Worst-Case Data)",
90
+ render: () => ({
128
91
  components: { LinkText },
129
- setup() {
130
- return { args };
131
- },
132
92
  template: `
133
- <LinkText v-bind="args">
134
- <template #right>
135
- <Icon name="lucide:external-link" />
136
- </template>
137
- </LinkText>
93
+ <div style="display: grid; gap: 2.4rem; padding: 2.4rem; font-size: 1.6rem;">
94
+ <p style="margin: 0; font-size: 1.4rem;">Long German label, an unbroken label and a long URL as the label, each with icons, in an 18rem column.</p>
95
+ <div style="display: grid; gap: 1.6rem; justify-items: start; inline-size: 18rem; outline: 0.1rem dashed currentColor;">
96
+ <LinkText to="/" link-text="Alle Lieferbedingungen und Rückgaberichtlinien ansehen">
97
+ <template #right><Icon name="lucide:arrow-right" /></template>
98
+ </LinkText>
99
+ <LinkText to="/" link-text="Lieferbedingungenundrückgaberichtlinienansehen">
100
+ <template #left><Icon name="lucide:arrow-left" /></template>
101
+ <template #right><Icon name="lucide:arrow-right" /></template>
102
+ </LinkText>
103
+ <LinkText to="https://example.invalid/a/very/long/unbroken/url" link-text="https://example.invalid/a/very/long/unbroken/url" external target="_blank">
104
+ <template #right><Icon name="lucide:external-link" /></template>
105
+ </LinkText>
106
+ </div>
107
+
108
+ <p style="margin: 0; font-size: 1.4rem;">Emoji first, HTML-like text (shown as text), right-to-left with a right icon, and a single character.</p>
109
+ <div style="display: flex; flex-wrap: wrap; gap: 2.4rem; align-items: center;">
110
+ <LinkText to="/" link-text="😀 Emoji first" />
111
+ <LinkText to="/" link-text="<b>not bold</b>" />
112
+ <LinkText to="/" link-text="اقرأ المزيد" dir="rtl">
113
+ <template #right><Icon name="lucide:arrow-left" /></template>
114
+ </LinkText>
115
+ <LinkText to="/" link-text="A" />
116
+ </div>
117
+
118
+ <p style="margin: 0; font-size: 1.4rem;">Icon-only link: empty <code>link-text</code>, named with <code>aria-label</code>. Tab to it to check the focus ring.</p>
119
+ <LinkText to="/" link-text="" aria-label="Back to home">
120
+ <template #left><Icon name="lucide:house" /></template>
121
+ </LinkText>
122
+
123
+ <p style="margin: 0; font-size: 1.4rem;">Inside a sentence: the link sits on the text line.</p>
124
+ <p style="margin: 0;">Our full price list is on the <LinkText to="/" link-text="services page" /> and updated monthly.</p>
125
+ </div>
138
126
  `,
139
127
  }),
128
+ parameters: {
129
+ controls: { disable: true },
130
+ docs: {
131
+ description: {
132
+ story:
133
+ "Worst-case labels. Check: long and unbroken labels and URLs wrap inside the dashed 18rem column with their icons centred on the wrapped text; emoji and HTML-like text render as text; right-to-left puts the right slot on the inline end; an icon-only link keeps a visible focus ring; an inline link sits on the line of text.",
134
+ },
135
+ },
136
+ },
140
137
  };
@@ -1,177 +1,168 @@
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 LinkText from "../LinkText.vue";
4
4
 
5
- describe("LinkText", () => {
6
- // ─── Mount ───────────────────────────────────────────────────────────────
5
+ type LinkTextProps = InstanceType<typeof LinkText>["$props"];
6
+
7
+ const mounted: Awaited<ReturnType<typeof mountSuspended>>[] = [];
8
+
9
+ const mountLink = async (props: Partial<LinkTextProps> = {}, slots: Record<string, string> = {}) => {
10
+ const wrapper = await mountSuspended(LinkText, {
11
+ props: { to: "/about", linkText: "About Us", ...props } as LinkTextProps,
12
+ slots,
13
+ });
14
+ mounted.push(wrapper);
15
+ return wrapper;
16
+ };
7
17
 
18
+ afterEach(() => {
19
+ mounted.splice(0).forEach((wrapper) => wrapper.unmount());
20
+ vi.restoreAllMocks();
21
+ });
22
+
23
+ describe("LinkText", () => {
8
24
  it("mounts without error", async () => {
9
- const wrapper = await mountSuspended(LinkText, {
10
- props: { to: "/about", linkText: "About Us" },
11
- });
25
+ const wrapper = await mountLink();
12
26
  expect(wrapper.vm).toBeTruthy();
13
27
  });
14
28
 
15
29
  // ─── Snapshots ───────────────────────────────────────────────────────────
16
30
 
17
31
  it("renders correct HTML structure with default props", async () => {
18
- const wrapper = await mountSuspended(LinkText, {
19
- props: { to: "/about", linkText: "About Us" },
20
- });
32
+ const wrapper = await mountLink();
21
33
  expect(wrapper.html()).toMatchSnapshot();
22
34
  });
23
35
 
24
36
  it("renders correct HTML structure with left slot", async () => {
25
- const wrapper = await mountSuspended(LinkText, {
26
- props: { to: "/about", linkText: "About Us" },
27
- slots: { left: "<svg data-testid='icon-left' />" },
28
- });
37
+ const wrapper = await mountLink({}, { left: "<svg data-testid='icon-left' />" });
29
38
  expect(wrapper.html()).toMatchSnapshot();
30
39
  });
31
40
 
32
41
  it("renders correct HTML structure with right slot", async () => {
33
- const wrapper = await mountSuspended(LinkText, {
34
- props: { to: "/about", linkText: "About Us" },
35
- slots: { right: "<svg data-testid='icon-right' />" },
36
- });
42
+ const wrapper = await mountLink({}, { right: "<svg data-testid='icon-right' />" });
37
43
  expect(wrapper.html()).toMatchSnapshot();
38
44
  });
39
45
 
40
46
  it("renders correct HTML structure with both slots", async () => {
41
- const wrapper = await mountSuspended(LinkText, {
42
- props: { to: "/about", linkText: "About Us" },
43
- slots: {
44
- left: "<svg data-testid='icon-left' />",
45
- right: "<svg data-testid='icon-right' />",
46
- },
47
- });
47
+ const wrapper = await mountLink(
48
+ {},
49
+ { left: "<svg data-testid='icon-left' />", right: "<svg data-testid='icon-right' />" }
50
+ );
48
51
  expect(wrapper.html()).toMatchSnapshot();
49
52
  });
50
53
 
51
54
  it("renders correct HTML structure with all props set", async () => {
52
- const wrapper = await mountSuspended(LinkText, {
53
- props: {
54
- to: "https://example.com",
55
- linkText: "Visit Us",
56
- external: true,
57
- target: "_blank",
58
- styleClassPassthrough: ["custom-class"],
59
- },
55
+ const wrapper = await mountLink({
56
+ to: "https://example.com",
57
+ linkText: "Visit Us",
58
+ external: true,
59
+ target: "_blank",
60
+ styleClassPassthrough: ["custom-class"],
60
61
  });
61
62
  expect(wrapper.html()).toMatchSnapshot();
62
63
  });
63
64
 
64
- // ─── Base class ──────────────────────────────────────────────────────────
65
+ // ─── Root and label ──────────────────────────────────────────────────────
65
66
 
66
67
  it("always has the link-text class", async () => {
67
- const wrapper = await mountSuspended(LinkText, {
68
- props: { to: "/", linkText: "Home" },
69
- });
68
+ const wrapper = await mountLink({ to: "/", linkText: "Home" });
70
69
  expect(wrapper.classes()).toContain("link-text");
71
70
  });
72
71
 
73
- // ─── linkText ────────────────────────────────────────────────────────────
74
-
75
72
  it("renders the linkText in a label span", async () => {
76
- const wrapper = await mountSuspended(LinkText, {
77
- props: { to: "/", linkText: "Read More" },
78
- });
73
+ const wrapper = await mountLink({ linkText: "Read More" });
79
74
  expect(wrapper.find(".link-text__label").text()).toBe("Read More");
80
75
  });
81
76
 
82
77
  it("renders linkText with special characters", async () => {
83
- const wrapper = await mountSuspended(LinkText, {
84
- props: { to: "/", linkText: "Édition & Co." },
85
- });
78
+ const wrapper = await mountLink({ linkText: "Édition & Co." });
86
79
  expect(wrapper.find(".link-text__label").text()).toBe("Édition & Co.");
87
80
  });
88
81
 
82
+ it("renders HTML-like linkText as text", async () => {
83
+ const wrapper = await mountLink({ linkText: "<b>not bold</b>" });
84
+ expect(wrapper.find("b").exists()).toBe(false);
85
+ expect(wrapper.find(".link-text__label").text()).toBe("<b>not bold</b>");
86
+ });
87
+
88
+ it("updates the label when linkText changes after mount", async () => {
89
+ const wrapper = await mountLink({ linkText: "Before" });
90
+ await wrapper.setProps({ linkText: "After" });
91
+ expect(wrapper.find(".link-text__label").text()).toBe("After");
92
+ });
93
+
89
94
  // ─── Slots ───────────────────────────────────────────────────────────────
90
95
 
91
96
  it("renders no icon spans when no slots provided", async () => {
92
- const wrapper = await mountSuspended(LinkText, {
93
- props: { to: "/", linkText: "No Icons" },
94
- });
97
+ const wrapper = await mountLink();
95
98
  expect(wrapper.find(".link-text__icon--left").exists()).toBe(false);
96
99
  expect(wrapper.find(".link-text__icon--right").exists()).toBe(false);
97
100
  });
98
101
 
99
102
  it("renders left icon span when left slot is provided", async () => {
100
- const wrapper = await mountSuspended(LinkText, {
101
- props: { to: "/", linkText: "With Left Icon" },
102
- slots: { left: "<svg />" },
103
- });
103
+ const wrapper = await mountLink({}, { left: "<svg />" });
104
104
  expect(wrapper.find(".link-text__icon--left").exists()).toBe(true);
105
105
  expect(wrapper.find(".link-text__icon--right").exists()).toBe(false);
106
106
  });
107
107
 
108
108
  it("renders right icon span when right slot is provided", async () => {
109
- const wrapper = await mountSuspended(LinkText, {
110
- props: { to: "/", linkText: "With Right Icon" },
111
- slots: { right: "<svg />" },
112
- });
109
+ const wrapper = await mountLink({}, { right: "<svg />" });
113
110
  expect(wrapper.find(".link-text__icon--left").exists()).toBe(false);
114
111
  expect(wrapper.find(".link-text__icon--right").exists()).toBe(true);
115
112
  });
116
113
 
117
- it("renders both icon spans when both slots are provided", async () => {
118
- const wrapper = await mountSuspended(LinkText, {
119
- props: { to: "/", linkText: "Both Icons" },
120
- slots: { left: "<svg />", right: "<svg />" },
121
- });
122
- expect(wrapper.find(".link-text__icon--left").exists()).toBe(true);
123
- expect(wrapper.find(".link-text__icon--right").exists()).toBe(true);
114
+ it("renders left icon before label and right icon after label", async () => {
115
+ const wrapper = await mountLink({}, { left: "<span class='l' />", right: "<span class='r' />" });
116
+ const children = Array.from((wrapper.element as HTMLElement).children);
117
+ expect(children.map((child) => child.classList[1] ?? child.classList[0])).toEqual([
118
+ "link-text__icon--left",
119
+ "link-text__label",
120
+ "link-text__icon--right",
121
+ ]);
124
122
  });
125
123
 
126
- // ─── Props ───────────────────────────────────────────────────────────────
124
+ // ─── Link attributes ─────────────────────────────────────────────────────
125
+
126
+ it("renders the to prop as href", async () => {
127
+ const wrapper = await mountLink({ to: "/services" });
128
+ expect(wrapper.attributes("href")).toBe("/services");
129
+ });
127
130
 
128
131
  it("passes target prop to NuxtLink", async () => {
132
+ const wrapper = await mountLink({ to: "https://example.com", target: "_blank" });
133
+ expect(wrapper.attributes("target")).toBe("_blank");
134
+ });
135
+
136
+ it("adds rel noopener to an external link opening in a new tab", async () => {
137
+ const wrapper = await mountLink({ to: "https://example.com", external: true, target: "_blank" });
138
+ expect(wrapper.attributes("rel")).toContain("noopener");
139
+ });
140
+
141
+ it("forwards an aria-label to the link for an icon-only link", async () => {
129
142
  const wrapper = await mountSuspended(LinkText, {
130
- props: { to: "https://example.com", linkText: "External", target: "_blank" },
143
+ props: { to: "/", linkText: "" },
144
+ attrs: { "aria-label": "Home" },
145
+ slots: { left: "<svg />" },
131
146
  });
132
- expect(wrapper.attributes("target")).toBe("_blank");
147
+ mounted.push(wrapper);
148
+ expect(wrapper.attributes("aria-label")).toBe("Home");
133
149
  });
134
150
 
135
151
  // ─── styleClassPassthrough ───────────────────────────────────────────────
136
152
 
137
153
  it("applies a styleClassPassthrough array", async () => {
138
- const wrapper = await mountSuspended(LinkText, {
139
- props: {
140
- to: "/",
141
- linkText: "Styled",
142
- styleClassPassthrough: ["class-a", "class-b"],
143
- },
144
- });
154
+ const wrapper = await mountLink({ styleClassPassthrough: ["class-a", "class-b"] });
145
155
  expect(wrapper.classes()).toContain("class-a");
146
156
  expect(wrapper.classes()).toContain("class-b");
147
157
  });
148
158
 
149
159
  it("applies a single styleClassPassthrough string", async () => {
150
- const wrapper = await mountSuspended(LinkText, {
151
- props: { to: "/", linkText: "Styled", styleClassPassthrough: "solo-class" },
152
- });
160
+ const wrapper = await mountLink({ styleClassPassthrough: "solo-class" });
153
161
  expect(wrapper.classes()).toContain("solo-class");
154
162
  });
155
163
 
156
- // ─── DOM order ───────────────────────────────────────────────────────────
157
-
158
- it("renders left icon before label and right icon after label", async () => {
159
- const wrapper = await mountSuspended(LinkText, {
160
- props: { to: "/", linkText: "Ordered" },
161
- slots: { left: "<span class='l' />", right: "<span class='r' />" },
162
- });
163
- const children = wrapper.element.children;
164
- expect(children[0].classList.contains("link-text__icon--left")).toBe(true);
165
- expect(children[1].classList.contains("link-text__label")).toBe(true);
166
- expect(children[2].classList.contains("link-text__icon--right")).toBe(true);
167
- });
168
- });
169
-
170
- describe("LinkText styleClassPassthrough reactivity", () => {
171
164
  it("updates classes when styleClassPassthrough changes after mount", async () => {
172
- const wrapper = await mountSuspended(LinkText, {
173
- props: { to: "/about", linkText: "About", styleClassPassthrough: ["original"] },
174
- });
165
+ const wrapper = await mountLink({ styleClassPassthrough: ["original"] });
175
166
  await wrapper.setProps({ styleClassPassthrough: ["updated"] });
176
167
  expect(wrapper.classes()).not.toContain("original");
177
168
  expect(wrapper.classes()).toContain("updated");
@@ -0,0 +1,110 @@
1
+ # DisplayTooltipRich — Consumer Styling Guide
2
+
3
+ > Renamed 2026-10-18 from `DisplayTooltipDefined`. Every `--display-tooltip-defined-*` token is now
4
+ > `--display-tooltip-rich-*` (same suffixes and defaults), and the `.display-tooltip-defined*`
5
+ > classes are now `.display-tooltip-rich*`. Find and replace `display-tooltip-defined` with
6
+ > `display-tooltip-rich` in your CSS.
7
+
8
+ ## Public token API
9
+
10
+ No private `--_` tokens of its own. This component renders `DisplayTooltip`, so every
11
+ `--display-tooltip-*` token (trigger, popover panel, close button) applies too: see
12
+ `DisplayTooltip`'s CONSUMER-STYLING.md.
13
+
14
+ ### Layout
15
+
16
+ | Token | Default | Controls |
17
+ |---|---|---|
18
+ | `--display-tooltip-rich-content-gap` | `1.2rem` | Gap between title/body/action/close-button |
19
+ | `--display-tooltip-rich-close-button-margin-block` | `1.2rem 0` | Space above the close button, on top of the gap |
20
+
21
+ ### Title (`contentText.tooltipTitle`)
22
+
23
+ | Token | Default | Controls |
24
+ |---|---|---|
25
+ | `--display-tooltip-rich-title-colour` | `inherit` | Text colour |
26
+ | `--display-tooltip-rich-title-margin-block` | `0 0` | Block margin |
27
+ | `--display-tooltip-rich-title-font-size` | `1.6rem` | Font size |
28
+ | `--display-tooltip-rich-title-font-weight` | `700` | Font weight |
29
+ | `--display-tooltip-rich-title-line-height` | `120%` | Line height |
30
+ | `--display-tooltip-rich-title-letter-spacing` | `normal` | Letter spacing |
31
+ | `--display-tooltip-rich-title-line-clamp` | `none` | Line cap with ellipsis (`1` is single-line ellipsis) |
32
+
33
+ ### Body (`contentText.tooltipContent`)
34
+
35
+ | Token | Default | Controls |
36
+ |---|---|---|
37
+ | `--display-tooltip-rich-body-colour` | `inherit` | Text colour |
38
+ | `--display-tooltip-rich-body-margin-block` | `0 0` | Block margin |
39
+ | `--display-tooltip-rich-body-font-size` | `1.3rem` | Font size |
40
+ | `--display-tooltip-rich-body-font-weight` | `inherit` | Font weight |
41
+ | `--display-tooltip-rich-body-line-height` | `140%` | Line height |
42
+ | `--display-tooltip-rich-body-letter-spacing` | `normal` | Letter spacing |
43
+ | `--display-tooltip-rich-body-line-clamp` | `none` | Line cap with ellipsis |
44
+
45
+ ### Action (`contentText.tooltipAction`)
46
+
47
+ | Token | Default | Controls |
48
+ |---|---|---|
49
+ | `--display-tooltip-rich-action-colour` | `inherit` | Text colour |
50
+ | `--display-tooltip-rich-action-margin-block` | `0 0` | Block margin |
51
+ | `--display-tooltip-rich-action-font-size` | `1.4rem` | Font size |
52
+ | `--display-tooltip-rich-action-font-weight` | `700` | Font weight |
53
+ | `--display-tooltip-rich-action-line-height` | `130%` | Line height |
54
+ | `--display-tooltip-rich-action-letter-spacing` | `normal` | Letter spacing |
55
+ | `--display-tooltip-rich-action-line-clamp` | `none` | Line cap with ellipsis |
56
+
57
+ The three `*-colour` tokens default to `inherit`, which resolves to the panel's own
58
+ `--display-tooltip-popover-text-colour`: override that for a uniform colour change, or one of these
59
+ three for a single field. Clamped text stays in the DOM, so screen readers still read all of it;
60
+ the panel has nowhere else to show the rest, which is why the clamps default to `none`.
61
+
62
+ > Changed 2026-10-18: the three `*-line-clamp` tokens are new. Long unbroken text in every field
63
+ > now wraps (`overflow-wrap: anywhere`).
64
+
65
+ ---
66
+
67
+ ## State hooks
68
+
69
+ Inner classes: `.display-tooltip-rich` (on the root, next to `.display-tooltip`),
70
+ `.display-tooltip-rich-content`, `.display-tooltip-rich-title`,
71
+ `.display-tooltip-rich-body`, `.display-tooltip-rich-action`, and the close button
72
+ `.display-tooltip-close-button.input-button`. The panel's open and placement hooks are
73
+ `DisplayTooltip`'s.
74
+
75
+ > Changed 2026-10-18: `.popover-content-defined`, `.tooltip-title`, `.tooltip-body` and
76
+ > `.tooltip-action` are now `.display-tooltip-rich-content`, `-title`, `-body` and `-action`
77
+ > (the old names were generic enough for a consumer's own CSS to match them). The undefined
78
+ > `subtitle-sm`, `body-sm` and `input-value` classes on the fields were removed. The close button is
79
+ > now an `InputButton` (`variant="inline"`) that keeps the `.display-tooltip-close-button` class and
80
+ > its tokens, and its right alignment and top margin now apply: `DisplayTooltip`'s close-button
81
+ > reset used to override them, so the button stretched the full panel width.
82
+
83
+ ---
84
+
85
+ ## Global theming
86
+
87
+ ```css
88
+ :where(html) {
89
+ --display-tooltip-rich-title-colour: var(--brand-accent);
90
+ --display-tooltip-rich-title-font-size: 1.8rem;
91
+ }
92
+ ```
93
+
94
+ ---
95
+
96
+ ## Local overrides
97
+
98
+ Set the tokens above on an element you own (a page or section class, or a class added with
99
+ `:style-class-passthrough`): they inherit down into the component. Keep the block **unlayered** (no
100
+ `@layer` wrapper) so it beats the library's `@layer components`. If your own file uses
101
+ `<style scoped>`, tokens set on your element still work, but selectors that reach inside the component need
102
+ `:deep()`. Patterns and examples: `.claude/skills/component-local-style-override.md`.
103
+
104
+ ---
105
+
106
+ ## Class passthrough
107
+
108
+ `style-class-passthrough` is forwarded to `DisplayTooltip`'s root, alongside the
109
+ `display-tooltip-rich` class this component always adds, so tokens set on a passthrough class
110
+ reach the trigger, the panel and every field.