srcdev-nuxt-components 9.6.12 → 9.6.13

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 (60) hide show
  1. package/.claude/rules/consumer/ids-from-useid.md +2 -1
  2. package/.claude/rules/consumer/vfor-keys-from-data.md +14 -0
  3. package/.claude/skills/component-compliance-checklist.md +19 -1
  4. package/.claude/skills/component-naming.md +1 -0
  5. package/.claude/skills/components/banner-video.md +24 -30
  6. package/.claude/skills/components/content-docs.md +18 -5
  7. package/.claude/skills/components/display-dialog.md +16 -6
  8. package/.claude/skills/components/{display-tooltip-defined.md → display-tooltip-rich.md} +40 -42
  9. package/.claude/skills/components/display-tooltip.md +17 -6
  10. package/.claude/skills/components/skip-links.md +16 -0
  11. package/.claude/skills/composable-tooltips-guide.md +4 -4
  12. package/.claude/skills/index.md +6 -6
  13. package/.claude/skills/testing-add-unit-test.md +8 -0
  14. package/.vscode/srcdev-component-banner-video.code-snippets +38 -25
  15. package/.vscode/srcdev-component-content-docs.code-snippets +69 -60
  16. package/.vscode/srcdev-component-display-dialog.code-snippets +7 -5
  17. package/.vscode/srcdev-component-display-tooltip-rich.code-snippets +50 -0
  18. package/.vscode/srcdev-component-display-tooltip.code-snippets +31 -0
  19. package/.vscode/srcdev-component-skip-links.code-snippets +1 -1
  20. package/app/components/01.atoms/banner-video/BannerVideo.vue +51 -19
  21. package/app/components/01.atoms/banner-video/CONSUMER-STYLING.md +59 -40
  22. package/app/components/01.atoms/banner-video/stories/BannerVideo.stories.ts +88 -1
  23. package/app/components/01.atoms/banner-video/tests/BannerVideo.spec.ts +127 -78
  24. package/app/components/01.atoms/banner-video/tests/__snapshots__/BannerVideo.spec.ts.snap +12 -4
  25. package/app/components/01.atoms/content-wrappers/docs-pages/CONSUMER-STYLING.md +42 -14
  26. package/app/components/01.atoms/content-wrappers/docs-pages/ContentDocs.vue +86 -112
  27. package/app/components/01.atoms/content-wrappers/docs-pages/stories/ContentDocs.stories.ts +134 -8
  28. package/app/components/01.atoms/content-wrappers/docs-pages/tests/ContentDocs.spec.ts +131 -65
  29. package/app/components/01.atoms/display-dialog/CONSUMER-STYLING.md +28 -3
  30. package/app/components/01.atoms/display-dialog/DisplayDialog.vue +39 -31
  31. package/app/components/01.atoms/display-dialog/stories/DisplayDialog.stories.ts +92 -1
  32. package/app/components/01.atoms/display-dialog/tests/DisplayDialog.spec.ts +87 -41
  33. package/app/components/01.atoms/display-dialog/tests/__snapshots__/DisplayDialog.spec.ts.snap +5 -1
  34. package/app/components/01.atoms/display-tooltip/CONSUMER-STYLING.md +70 -24
  35. package/app/components/01.atoms/display-tooltip/DisplayTooltip.vue +24 -12
  36. package/app/components/01.atoms/display-tooltip/stories/DisplayTooltip.stories.ts +89 -2
  37. package/app/components/01.atoms/display-tooltip/tests/DisplayTooltip.spec.ts +65 -18
  38. package/app/components/01.atoms/display-tooltip/tests/__snapshots__/DisplayTooltip.spec.ts.snap +3 -3
  39. package/app/components/01.atoms/navigation/skip-links/CONSUMER-STYLING.md +65 -19
  40. package/app/components/01.atoms/navigation/skip-links/SkipLinks.vue +20 -14
  41. package/app/components/01.atoms/navigation/skip-links/stories/SkipLinks.stories.ts +89 -20
  42. package/app/components/01.atoms/navigation/skip-links/tests/SkipLinks.spec.ts +70 -10
  43. package/app/components/02.molecules/display-tooltip-rich/CONSUMER-STYLING.md +110 -0
  44. package/app/components/02.molecules/display-tooltip-rich/DisplayTooltipRich.vue +148 -0
  45. package/app/components/02.molecules/display-tooltip-rich/stories/DisplayTooltipRich.stories.ts +254 -0
  46. package/app/components/02.molecules/display-tooltip-rich/tests/DisplayTooltipRich.spec.ts +156 -0
  47. package/app/components/02.molecules/display-tooltip-rich/tests/__snapshots__/DisplayTooltipRich.spec.ts.snap +22 -0
  48. package/app/components/02.molecules/navigation/deep-expanding-menu/DeepExpandingMenu.vue +1 -0
  49. package/app/components/02.molecules/navigation/deep-expanding-menu/tests/__snapshots__/DeepExpandingMenu.spec.ts.snap +1 -1
  50. package/app/components/03.organisms/image-galleries/carousel-flip/CarouselFlip.vue +1 -0
  51. package/app/composables/tests/useTooltips.spec.ts +3 -3
  52. package/app/types/components/index.ts +1 -1
  53. package/package.json +1 -1
  54. package/.vscode/srcdev-component-display-tooltip-defined.code-snippets +0 -29
  55. package/app/components/02.molecules/display-tooltip-defined/CONSUMER-STYLING.md +0 -53
  56. package/app/components/02.molecules/display-tooltip-defined/DisplayTooltipDefined.vue +0 -117
  57. package/app/components/02.molecules/display-tooltip-defined/stories/DisplayTooltipDefined.stories.ts +0 -129
  58. package/app/components/02.molecules/display-tooltip-defined/tests/DisplayTooltipDefined.spec.ts +0 -85
  59. package/app/components/02.molecules/display-tooltip-defined/tests/__snapshots__/DisplayTooltipDefined.spec.ts.snap +0 -17
  60. /package/app/types/components/{display-tooltip-defined.d.ts → display-tooltip-rich.d.ts} +0 -0
@@ -3,8 +3,13 @@
3
3
  <div v-if="$slots.homeLink" class="skip-links__home">
4
4
  <slot name="homeLink"></slot>
5
5
  </div>
6
- <nav class="skip-links__nav" :aria-label="ariaLabel">
7
- <a v-for="link in links" :key="link.href" :href="link.href" class="skip-links__link">{{ link.label }}</a>
6
+ <nav v-if="validLinks.length" class="skip-links__nav" :aria-label="ariaLabel">
7
+ <a
8
+ v-for="(link, index) in validLinks"
9
+ :key="`${index}-${link.href}`"
10
+ :href="link.href"
11
+ class="skip-links__link"
12
+ >{{ link.label }}</a>
8
13
  </nav>
9
14
  </div>
10
15
  </template>
@@ -28,6 +33,8 @@ const props = withDefaults(defineProps<Props>(), {
28
33
  styleClassPassthrough: () => [],
29
34
  })
30
35
 
36
+ const validLinks = computed(() => props.links.filter((link) => link.href?.trim() && link.label?.trim()))
37
+
31
38
  const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough)
32
39
 
33
40
  watch(
@@ -46,22 +53,21 @@ watch(
46
53
  .skip-links__nav {
47
54
  position: absolute;
48
55
  top: 100%;
49
- left: 0;
50
- z-index: var(--skip-links-z-index, 1000);
56
+ inset-inline-start: 0;
57
+ z-index: var(--skip-links-z-index, 999999);
51
58
 
52
59
  display: flex;
53
60
  flex-direction: column;
54
61
  gap: var(--skip-links-gap, 0.2rem);
55
- background-color: var(--skip-links-background-colour, black);
56
- border: var(--skip-links-border-width, 1px) solid var(--skip-links-border-colour, white);
62
+ inline-size: max-content;
63
+ max-inline-size: calc(100vw - 3.2rem);
64
+ background-color: var(--skip-links-background-colour, var(--theme-surface-inverted));
65
+ border: var(--skip-links-border-width, 1px) solid var(--skip-links-border-colour, var(--theme-on-surface));
57
66
  padding: var(--skip-links-padding, 0.2rem);
58
67
  margin: 0;
59
68
 
60
- /* Hidden off-screen via transform — does NOT break keyboard focusability. pointer-events is
61
- off in this state too: translateY(-100%) puts this at the same position as .skip-links__home
62
- (the homeLink slot), and opacity:0 alone doesn't stop it from catching hover/clicks meant
63
- for whatever sits underneath it. Keyboard tab focus doesn't need pointer-events, so this
64
- doesn't affect focusability. */
69
+ /* Hidden by transform, not display, so the links stay focusable; pointer-events stops the
70
+ invisible panel catching clicks meant for the homeLink slot it overlaps. */
65
71
  opacity: 0;
66
72
  pointer-events: none;
67
73
  transform: translateY(-100%);
@@ -78,13 +84,13 @@ watch(
78
84
 
79
85
  .skip-links__link {
80
86
  display: block;
81
- color: var(--skip-links-text-colour, white);
87
+ color: var(--skip-links-text-colour, var(--theme-on-surface));
82
88
  text-decoration: none;
83
89
  padding: var(--skip-links-link-padding-block, 0.8rem) var(--skip-links-link-padding-inline, 1.2rem);
84
- white-space: nowrap;
90
+ overflow-wrap: anywhere;
85
91
 
86
92
  &:focus-visible {
87
- outline: var(--skip-links-focus-outline-width, 2px) solid var(--skip-links-focus-outline-colour, white);
93
+ outline: var(--skip-links-focus-outline-width, 2px) solid var(--skip-links-focus-outline-colour, var(--theme-on-surface));
88
94
  }
89
95
  }
90
96
  }
@@ -8,11 +8,11 @@ const meta: Meta<typeof SkipLinks> = {
8
8
  argTypes: {
9
9
  links: {
10
10
  control: "object",
11
- description: "Ordered list of { href, label } skip targets",
11
+ description: "Ordered list of { href, label } skip targets. Entries with an empty href or label are skipped.",
12
12
  },
13
13
  ariaLabel: {
14
14
  control: "text",
15
- description: "aria-label on the nav landmark — override for localisation",
15
+ description: "aria-label on the nav landmark (override for localisation)",
16
16
  },
17
17
  styleClassPassthrough: {
18
18
  control: "object",
@@ -27,7 +27,7 @@ const meta: Meta<typeof SkipLinks> = {
27
27
  docs: {
28
28
  description: {
29
29
  component:
30
- "An accessibility utility that renders a set of visually-hidden links revealed only on keyboard focus, letting keyboard/screen-reader users jump past repeated navigation straight to main content, the footer, or any other in-page target. The links are placed first in the DOM — consumers should place `<SkipLinks>` at the very top of their page layout.",
30
+ "Keyboard skip links: a `<nav>` of in-page links that stays hidden until one of them takes focus, letting keyboard and screen-reader users jump past repeated navigation. Place `<SkipLinks>` first in the page layout. Links come from the `links` prop (`{ href, label }[]`, default main content and footer); the optional `homeLink` slot renders a visible item (usually the logo link) that the panel drops down beneath. `ariaLabel` names the landmark. SiteHeader renders this as its branding row: see the [Site Header](?path=/story/organisms-site-header--default) story.",
31
31
  },
32
32
  },
33
33
  },
@@ -36,7 +36,24 @@ const meta: Meta<typeof SkipLinks> = {
36
36
  export default meta;
37
37
  type Story = StoryObj<typeof SkipLinks>;
38
38
 
39
- // ─── Stories ──────────────────────────────────────────────────────────────────
39
+ const storyNoteStyle = "margin: 0 0 1.6rem; font-size: 1.4rem;";
40
+ const siteHeaderNote = `
41
+ <p style="${storyNoteStyle}">
42
+ SiteHeader puts its branding in the <code>homeLink</code> slot and these links beneath it: open the
43
+ <a href="/?path=/story/organisms-site-header--default" target="_top">Site Header</a> story to see it in a real header.
44
+ </p>
45
+ `;
46
+
47
+ const forceOpenStyle = `
48
+ <style>
49
+ .skip-links-force-open .skip-links__nav {
50
+ opacity: 1 !important;
51
+ pointer-events: auto !important;
52
+ transform: translateY(0) !important;
53
+ position: static !important;
54
+ }
55
+ </style>
56
+ `;
40
57
 
41
58
  export const Default: Story = {
42
59
  render: (args) => ({
@@ -45,10 +62,10 @@ export const Default: Story = {
45
62
  return { args };
46
63
  },
47
64
  template: `
48
- <div>
49
- <p style="padding: 2rem; text-align: center;">
50
- The skip links below are visually hidden until focused. Click into this preview, then
51
- press <kbd>Tab</kbd> to reveal them.
65
+ <div style="padding: 2rem;">
66
+ ${siteHeaderNote}
67
+ <p style="${storyNoteStyle}">
68
+ The skip links are hidden until focused. Click into this preview, then press <kbd>Tab</kbd> to reveal them.
52
69
  </p>
53
70
  <SkipLinks v-bind="args" />
54
71
  </div>
@@ -71,8 +88,9 @@ export const CustomLinks: Story = {
71
88
  return { args };
72
89
  },
73
90
  template: `
74
- <div>
75
- <p style="padding: 2rem; text-align: center;">
91
+ <div style="padding: 2rem;">
92
+ ${siteHeaderNote}
93
+ <p style="${storyNoteStyle}">
76
94
  Press <kbd>Tab</kbd> after clicking into this preview to reveal three custom skip links.
77
95
  </p>
78
96
  <SkipLinks v-bind="args" />
@@ -86,7 +104,8 @@ export const AlwaysVisible: Story = {
86
104
  parameters: {
87
105
  docs: {
88
106
  description: {
89
- story: "Forces the skip-nav panel open via `:focus-within` emulation so it can be inspected without tabbing — for visual review only, not representative of real usage.",
107
+ story:
108
+ "Forces the panel open with story-only CSS so it can be inspected without tabbing. For visual review only: in real use it only shows while a link has focus.",
90
109
  },
91
110
  },
92
111
  },
@@ -96,18 +115,68 @@ export const AlwaysVisible: Story = {
96
115
  return { args };
97
116
  },
98
117
  template: `
99
- <div>
100
- <style>
101
- .always-visible-demo .skip-links__nav {
102
- opacity: 1 !important;
103
- transform: translateY(0) !important;
104
- position: static !important;
105
- }
106
- </style>
107
- <div class="always-visible-demo">
118
+ <div style="padding: 2rem;">
119
+ ${forceOpenStyle}
120
+ ${siteHeaderNote}
121
+ <div class="skip-links-force-open">
108
122
  <SkipLinks v-bind="args" />
109
123
  </div>
110
124
  </div>
111
125
  `,
112
126
  }),
113
127
  };
128
+
129
+ const stressLinks: SkipLink[] = [
130
+ { href: "#main-content", label: "Zumhauptinhaltspringenundnavigationüberspringen" },
131
+ { href: "#main-content", label: "😀 Same target, second label" },
132
+ { href: "#search", label: "<b>not bold</b> renders as text" },
133
+ { href: "#footer-content", label: "انتقل إلى التذييل" },
134
+ { href: "#x", label: "X" },
135
+ { href: "", label: "Dropped: empty href" },
136
+ { href: "#dropped", label: " " },
137
+ ...Array.from({ length: 6 }, (_, index) => ({ href: `#section-${index + 1}`, label: `Skip to section ${index + 1}` })),
138
+ ];
139
+
140
+ export const StressTest: Story = {
141
+ name: "Stress Test (Worst-Case Data)",
142
+ args: {
143
+ links: stressLinks,
144
+ ariaLabel: "Navigationsübersprunglinksfürtastaturnutzerinnenundnutzer 😀",
145
+ },
146
+ render: (args) => ({
147
+ components: { SkipLinks },
148
+ setup() {
149
+ return { args };
150
+ },
151
+ template: `
152
+ <div style="display: grid; gap: 2.4rem; padding: 2rem; font-size: 1.4rem;">
153
+ ${forceOpenStyle}
154
+ <p style="margin: 0;">Forced open in a narrow column: long unbroken, emoji, HTML-like and right-to-left labels wrap inside the panel, two links share a target, and the two entries with an empty href or label are dropped (11 links show, not 13).</p>
155
+ <div class="skip-links-force-open" style="inline-size: 18rem; outline: 0.1rem dashed currentColor;">
156
+ <SkipLinks v-bind="args" />
157
+ </div>
158
+
159
+ <p style="margin: 0;">The real behaviour with the same data: press <kbd>Tab</kbd> from here. The panel drops down under the logo, stays inside the viewport and shows each link's focus ring.</p>
160
+ <SkipLinks v-bind="args">
161
+ <template #homeLink>
162
+ <a href="#" style="display: inline-block; padding: 0.8rem;">Logo</a>
163
+ </template>
164
+ </SkipLinks>
165
+
166
+ <p style="margin: 0;">No usable links (an empty array): nothing renders, not even an empty <code>&lt;nav&gt;</code> landmark.</p>
167
+ <div class="skip-links-force-open" style="outline: 0.1rem dashed currentColor; min-block-size: 2rem;">
168
+ <SkipLinks :links="[]" />
169
+ </div>
170
+ </div>
171
+ `,
172
+ }),
173
+ parameters: {
174
+ docs: {
175
+ story: { inline: false, height: "900px" },
176
+ description: {
177
+ story:
178
+ "Worst-case `links`. Check: labels wrap instead of running off-screen, the panel is never wider than the viewport, links that share an href both render, entries with an empty href or label are dropped, HTML-like text renders as text, and an empty list leaves no landmark behind. Tab through the second instance to check the reveal and focus rings. The long `ariaLabel` only names the landmark, so it never shows on screen.",
179
+ },
180
+ },
181
+ },
182
+ };
@@ -1,21 +1,34 @@
1
- import { describe, it, expect } from "vitest";
1
+ import { describe, it, expect, vi, afterEach } from "vitest";
2
2
  import { mountSuspended } from "@nuxt/test-utils/runtime";
3
3
  import SkipLinks from "../SkipLinks.vue";
4
4
  import type { SkipLink } from "~/types/components";
5
5
 
6
+ const mounted: { unmount: () => void }[] = [];
7
+
8
+ const mountSkipLinks = async (options: Parameters<typeof mountSuspended<typeof SkipLinks>>[1] = {}) => {
9
+ const wrapper = await mountSuspended(SkipLinks, options);
10
+ mounted.push(wrapper);
11
+ return wrapper;
12
+ };
13
+
6
14
  describe("SkipLinks", () => {
15
+ afterEach(() => {
16
+ mounted.splice(0).forEach((wrapper) => wrapper.unmount());
17
+ vi.restoreAllMocks();
18
+ });
19
+
7
20
  it("mounts without error", async () => {
8
- const wrapper = await mountSuspended(SkipLinks);
21
+ const wrapper = await mountSkipLinks();
9
22
  expect(wrapper.vm).toBeTruthy();
10
23
  });
11
24
 
12
25
  it("renders correct HTML structure (default props)", async () => {
13
- const wrapper = await mountSuspended(SkipLinks);
26
+ const wrapper = await mountSkipLinks();
14
27
  expect(wrapper.html()).toMatchSnapshot();
15
28
  });
16
29
 
17
30
  it("renders correct HTML structure (all props set)", async () => {
18
- const wrapper = await mountSuspended(SkipLinks, {
31
+ const wrapper = await mountSkipLinks({
19
32
  props: {
20
33
  links: [{ href: "#content", label: "Skip to content" }],
21
34
  ariaLabel: "Custom skip nav",
@@ -25,7 +38,7 @@ describe("SkipLinks", () => {
25
38
  });
26
39
 
27
40
  it("renders the default main-content and footer links", async () => {
28
- const wrapper = await mountSuspended(SkipLinks);
41
+ const wrapper = await mountSkipLinks();
29
42
  const links = wrapper.findAll(".skip-links__link");
30
43
  expect(links).toHaveLength(2);
31
44
  expect(links[0]!.attributes("href")).toBe("#main-content");
@@ -40,29 +53,76 @@ describe("SkipLinks", () => {
40
53
  { href: "#search", label: "Skip to search" },
41
54
  { href: "#content", label: "Skip to content" },
42
55
  ];
43
- const wrapper = await mountSuspended(SkipLinks, { props: { links: customLinks } });
56
+ const wrapper = await mountSkipLinks({ props: { links: customLinks } });
44
57
  const links = wrapper.findAll(".skip-links__link");
45
58
  expect(links).toHaveLength(3);
46
59
  expect(links.map((link) => link.text())).toEqual(["Skip to navigation", "Skip to search", "Skip to content"]);
47
60
  });
48
61
 
62
+ it("drops links with an empty or whitespace-only href or label", async () => {
63
+ const wrapper = await mountSkipLinks({
64
+ props: {
65
+ links: [
66
+ { href: "#a", label: "Keep" },
67
+ { href: "", label: "No target" },
68
+ { href: "#b", label: " " },
69
+ { href: "#c", label: "Also keep" },
70
+ ],
71
+ },
72
+ });
73
+ expect(wrapper.findAll(".skip-links__link").map((link) => link.text())).toEqual(["Keep", "Also keep"]);
74
+ });
75
+
76
+ it("keeps the right links when two share an href and the list is reordered", async () => {
77
+ const wrapper = await mountSkipLinks({
78
+ props: {
79
+ links: [
80
+ { href: "#nav", label: "Skip to navigation" },
81
+ { href: "#main", label: "Skip to content" },
82
+ { href: "#main", label: "Skip to article" },
83
+ { href: "#footer", label: "Skip to footer" },
84
+ ],
85
+ },
86
+ });
87
+ expect(wrapper.findAll(".skip-links__link")).toHaveLength(4);
88
+ await wrapper.setProps({
89
+ links: [
90
+ { href: "#footer", label: "Skip to footer" },
91
+ { href: "#main", label: "Skip to content" },
92
+ { href: "#main", label: "Skip to article" },
93
+ { href: "#nav", label: "Skip to navigation" },
94
+ ],
95
+ });
96
+ expect(wrapper.findAll(".skip-links__link").map((link) => link.text())).toEqual([
97
+ "Skip to footer",
98
+ "Skip to content",
99
+ "Skip to article",
100
+ "Skip to navigation",
101
+ ]);
102
+ });
103
+
104
+ it("omits the nav landmark entirely when there are no usable links", async () => {
105
+ const wrapper = await mountSkipLinks({ props: { links: [{ href: "", label: "" }] } });
106
+ expect(wrapper.find("nav").exists()).toBe(false);
107
+ });
108
+
49
109
  it("defaults the nav aria-label to 'Skip navigation'", async () => {
50
- const wrapper = await mountSuspended(SkipLinks);
110
+ const wrapper = await mountSkipLinks();
51
111
  expect(wrapper.find(".skip-links__nav").attributes("aria-label")).toBe("Skip navigation");
52
112
  });
53
113
 
54
114
  it("uses a custom ariaLabel when provided", async () => {
55
- const wrapper = await mountSuspended(SkipLinks, { props: { ariaLabel: "Custom skip nav" } });
115
+ const wrapper = await mountSkipLinks({ props: { ariaLabel: "Custom skip nav" } });
56
116
  expect(wrapper.find(".skip-links__nav").attributes("aria-label")).toBe("Custom skip nav");
57
117
  });
58
118
 
59
119
  it("does not render the home-link slot wrapper when the slot is unused", async () => {
60
- const wrapper = await mountSuspended(SkipLinks);
120
+ const wrapper = await mountSkipLinks();
61
121
  expect(wrapper.find(".skip-links__home").exists()).toBe(false);
62
122
  });
63
123
 
64
124
  it("renders the homeLink slot content when provided", async () => {
65
- const wrapper = await mountSuspended(SkipLinks, {
125
+ const wrapper = await mountSkipLinks({
66
126
  slots: { homeLink: "<a href='/' class='logo-link'>Home</a>" },
67
127
  });
68
128
  expect(wrapper.find(".skip-links__home").exists()).toBe(true);
@@ -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.
@@ -0,0 +1,148 @@
1
+ <template>
2
+ <DisplayTooltip
3
+ :tooltip-id="tooltipId"
4
+ :trigger-aria-label="triggerAriaLabel"
5
+ :trigger-icon="triggerIcon"
6
+ :style-class-passthrough="rootClasses"
7
+ >
8
+ <template v-if="$slots.triggerContent" #triggerContent>
9
+ <slot name="triggerContent"></slot>
10
+ </template>
11
+ <template #tooltipContent="{ close }">
12
+ <div class="display-tooltip-rich-content">
13
+ <component
14
+ :is="tagOf(contentText.tooltipTitle, 'p')"
15
+ v-if="contentText.tooltipTitle"
16
+ class="display-tooltip-rich-title"
17
+ >
18
+ {{ contentText.tooltipTitle.text }}
19
+ </component>
20
+ <component
21
+ :is="tagOf(contentText.tooltipContent, 'p')"
22
+ v-if="contentText.tooltipContent"
23
+ class="display-tooltip-rich-body"
24
+ >
25
+ {{ contentText.tooltipContent.text }}
26
+ </component>
27
+ <component
28
+ :is="tagOf(contentText.tooltipAction, 'span')"
29
+ v-if="contentText.tooltipAction"
30
+ class="display-tooltip-rich-action"
31
+ >
32
+ {{ contentText.tooltipAction.text }}
33
+ </component>
34
+ <InputButton
35
+ type="button"
36
+ variant="inline"
37
+ :popovertarget="tooltipId"
38
+ popovertargetaction="hide"
39
+ :button-text="closeButtonText"
40
+ :aria-label="closeButtonAriaLabel"
41
+ style-class-passthrough="display-tooltip-close-button"
42
+ @click="close"
43
+ />
44
+ </div>
45
+ </template>
46
+ </DisplayTooltip>
47
+ </template>
48
+
49
+ <script setup lang="ts">
50
+ import type { TooltipContentText, TooltipContentTextItem } from "~/types/components";
51
+
52
+ interface Props {
53
+ tooltipId?: string;
54
+ contentText?: TooltipContentText;
55
+ /** aria-label on the trigger button — override for localisation. */
56
+ triggerAriaLabel?: string;
57
+ /** Iconify name for the trigger button icon. */
58
+ triggerIcon?: string;
59
+ /** Visible text on the close button — override for localisation. */
60
+ closeButtonText?: string;
61
+ /** aria-label on the close button — override for localisation. */
62
+ closeButtonAriaLabel?: string;
63
+ styleClassPassthrough?: string | string[];
64
+ }
65
+
66
+ const props = withDefaults(defineProps<Props>(), {
67
+ tooltipId: "",
68
+ contentText: () => ({}),
69
+ triggerAriaLabel: "Toggle the popover",
70
+ triggerIcon: undefined,
71
+ closeButtonText: "Close",
72
+ closeButtonAriaLabel: "Close tool tip",
73
+ styleClassPassthrough: () => [],
74
+ });
75
+
76
+ const generatedTooltipId = `nuxt-tooltip-${useId()}`;
77
+ const tooltipId = computed(() => (props.tooltipId ? `nuxt-tooltip-${props.tooltipId}` : generatedTooltipId));
78
+
79
+ const rootClasses = computed(() => ["display-tooltip-rich", ...[props.styleClassPassthrough].flat()]);
80
+
81
+ const tagOf = (item: TooltipContentTextItem | undefined, fallback: string) => item?.tag?.trim() || fallback;
82
+ </script>
83
+
84
+ <style lang="css">
85
+ @layer components {
86
+ .display-tooltip.display-tooltip-rich {
87
+ .display-tooltip-rich-content {
88
+ display: flex;
89
+ flex-direction: column;
90
+ gap: var(--display-tooltip-rich-content-gap, 1.2rem);
91
+
92
+ .display-tooltip-rich-title,
93
+ .display-tooltip-rich-body,
94
+ .display-tooltip-rich-action {
95
+ display: -webkit-box;
96
+ -webkit-box-orient: vertical;
97
+ overflow: hidden;
98
+ overflow-wrap: anywhere;
99
+ }
100
+
101
+ /* margin-block resets the UA margin of whatever tag contentText.*.tag picks, so gap is the only spacing. */
102
+ .display-tooltip-rich-title {
103
+ color: var(--display-tooltip-rich-title-colour, inherit);
104
+ margin-block: var(--display-tooltip-rich-title-margin-block, 0 0);
105
+ font-size: var(--display-tooltip-rich-title-font-size, 1.6rem);
106
+ font-weight: var(--display-tooltip-rich-title-font-weight, 700);
107
+ line-height: var(--display-tooltip-rich-title-line-height, 120%);
108
+ letter-spacing: var(--display-tooltip-rich-title-letter-spacing, normal);
109
+ -webkit-line-clamp: var(--display-tooltip-rich-title-line-clamp, none);
110
+ line-clamp: var(--display-tooltip-rich-title-line-clamp, none);
111
+ }
112
+
113
+ .display-tooltip-rich-body {
114
+ color: var(--display-tooltip-rich-body-colour, inherit);
115
+ margin-block: var(--display-tooltip-rich-body-margin-block, 0 0);
116
+ font-size: var(--display-tooltip-rich-body-font-size, 1.3rem);
117
+ font-weight: var(--display-tooltip-rich-body-font-weight, inherit);
118
+ line-height: var(--display-tooltip-rich-body-line-height, 140%);
119
+ letter-spacing: var(--display-tooltip-rich-body-letter-spacing, normal);
120
+ -webkit-line-clamp: var(--display-tooltip-rich-body-line-clamp, none);
121
+ line-clamp: var(--display-tooltip-rich-body-line-clamp, none);
122
+ }
123
+
124
+ .display-tooltip-rich-action {
125
+ color: var(--display-tooltip-rich-action-colour, inherit);
126
+ margin-block: var(--display-tooltip-rich-action-margin-block, 0 0);
127
+ font-size: var(--display-tooltip-rich-action-font-size, 1.4rem);
128
+ font-weight: var(--display-tooltip-rich-action-font-weight, 700);
129
+ line-height: var(--display-tooltip-rich-action-line-height, 130%);
130
+ letter-spacing: var(--display-tooltip-rich-action-letter-spacing, normal);
131
+ -webkit-line-clamp: var(--display-tooltip-rich-action-line-clamp, none);
132
+ line-clamp: var(--display-tooltip-rich-action-line-clamp, none);
133
+ }
134
+
135
+ .display-tooltip-close-button.input-button {
136
+ align-self: flex-end;
137
+ margin-block: var(--display-tooltip-rich-close-button-margin-block, 1.2rem 0);
138
+ overflow-wrap: anywhere;
139
+
140
+ .input-button-text {
141
+ font-size: inherit;
142
+ text-transform: none;
143
+ }
144
+ }
145
+ }
146
+ }
147
+ }
148
+ </style>