srcdev-nuxt-components 9.6.11 → 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 (77) 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 +24 -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/form-field.md +15 -0
  11. package/.claude/skills/components/form-fieldset.md +18 -2
  12. package/.claude/skills/components/marquee-scroller.md +51 -14
  13. package/.claude/skills/components/skip-links.md +16 -0
  14. package/.claude/skills/composable-tooltips-guide.md +4 -4
  15. package/.claude/skills/index.md +6 -6
  16. package/.claude/skills/testing-add-unit-test.md +8 -0
  17. package/.vscode/srcdev-component-banner-video.code-snippets +38 -25
  18. package/.vscode/srcdev-component-content-docs.code-snippets +69 -60
  19. package/.vscode/srcdev-component-display-dialog.code-snippets +7 -5
  20. package/.vscode/srcdev-component-display-tooltip-rich.code-snippets +50 -0
  21. package/.vscode/srcdev-component-display-tooltip.code-snippets +31 -0
  22. package/.vscode/srcdev-component-form-field.code-snippets +7 -6
  23. package/.vscode/srcdev-component-form-fieldset.code-snippets +12 -12
  24. package/.vscode/srcdev-component-marquee-scroller.code-snippets +31 -28
  25. package/.vscode/srcdev-component-skip-links.code-snippets +1 -1
  26. package/app/components/01.atoms/animations/marquee-scroller/CONSUMER-STYLING.md +77 -54
  27. package/app/components/01.atoms/animations/marquee-scroller/MarqueeScroller.vue +103 -91
  28. package/app/components/01.atoms/animations/marquee-scroller/stories/MarqueeScroller.stories.ts +132 -3
  29. package/app/components/01.atoms/animations/marquee-scroller/tests/MarqueeScroller.spec.ts +190 -102
  30. package/app/components/01.atoms/animations/marquee-scroller/tests/__snapshots__/MarqueeScroller.spec.ts.snap +17 -13
  31. package/app/components/01.atoms/banner-video/BannerVideo.vue +51 -19
  32. package/app/components/01.atoms/banner-video/CONSUMER-STYLING.md +59 -40
  33. package/app/components/01.atoms/banner-video/stories/BannerVideo.stories.ts +88 -1
  34. package/app/components/01.atoms/banner-video/tests/BannerVideo.spec.ts +127 -78
  35. package/app/components/01.atoms/banner-video/tests/__snapshots__/BannerVideo.spec.ts.snap +12 -4
  36. package/app/components/01.atoms/content-wrappers/docs-pages/CONSUMER-STYLING.md +42 -14
  37. package/app/components/01.atoms/content-wrappers/docs-pages/ContentDocs.vue +86 -112
  38. package/app/components/01.atoms/content-wrappers/docs-pages/stories/ContentDocs.stories.ts +134 -8
  39. package/app/components/01.atoms/content-wrappers/docs-pages/tests/ContentDocs.spec.ts +131 -65
  40. package/app/components/01.atoms/display-dialog/CONSUMER-STYLING.md +28 -3
  41. package/app/components/01.atoms/display-dialog/DisplayDialog.vue +39 -31
  42. package/app/components/01.atoms/display-dialog/stories/DisplayDialog.stories.ts +92 -1
  43. package/app/components/01.atoms/display-dialog/tests/DisplayDialog.spec.ts +87 -41
  44. package/app/components/01.atoms/display-dialog/tests/__snapshots__/DisplayDialog.spec.ts.snap +5 -1
  45. package/app/components/01.atoms/display-tooltip/CONSUMER-STYLING.md +70 -24
  46. package/app/components/01.atoms/display-tooltip/DisplayTooltip.vue +24 -12
  47. package/app/components/01.atoms/display-tooltip/stories/DisplayTooltip.stories.ts +89 -2
  48. package/app/components/01.atoms/display-tooltip/tests/DisplayTooltip.spec.ts +65 -18
  49. package/app/components/01.atoms/display-tooltip/tests/__snapshots__/DisplayTooltip.spec.ts.snap +3 -3
  50. package/app/components/01.atoms/navigation/skip-links/CONSUMER-STYLING.md +65 -19
  51. package/app/components/01.atoms/navigation/skip-links/SkipLinks.vue +20 -14
  52. package/app/components/01.atoms/navigation/skip-links/stories/SkipLinks.stories.ts +89 -20
  53. package/app/components/01.atoms/navigation/skip-links/tests/SkipLinks.spec.ts +70 -10
  54. package/app/components/02.molecules/display-tooltip-rich/CONSUMER-STYLING.md +110 -0
  55. package/app/components/02.molecules/display-tooltip-rich/DisplayTooltipRich.vue +148 -0
  56. package/app/components/02.molecules/display-tooltip-rich/stories/DisplayTooltipRich.stories.ts +254 -0
  57. package/app/components/02.molecules/display-tooltip-rich/tests/DisplayTooltipRich.spec.ts +156 -0
  58. package/app/components/02.molecules/display-tooltip-rich/tests/__snapshots__/DisplayTooltipRich.spec.ts.snap +22 -0
  59. package/app/components/02.molecules/navigation/deep-expanding-menu/DeepExpandingMenu.vue +1 -0
  60. package/app/components/02.molecules/navigation/deep-expanding-menu/tests/__snapshots__/DeepExpandingMenu.spec.ts.snap +1 -1
  61. package/app/components/03.organisms/image-galleries/carousel-flip/CarouselFlip.vue +1 -0
  62. package/app/components/05.forms/form-field/CONSUMER-STYLING.md +7 -0
  63. package/app/components/05.forms/form-field/stories/FormField.stories.ts +116 -2
  64. package/app/components/05.forms/form-fieldset/CONSUMER-STYLING.md +11 -1
  65. package/app/components/05.forms/form-fieldset/FormFieldset.vue +4 -2
  66. package/app/components/05.forms/form-fieldset/stories/FormFieldset.stories.ts +68 -4
  67. package/app/components/05.forms/form-fieldset/tests/FormFieldset.spec.ts +10 -0
  68. package/app/composables/tests/useTooltips.spec.ts +3 -3
  69. package/app/types/components/index.ts +1 -1
  70. package/package.json +1 -1
  71. package/.vscode/srcdev-component-display-tooltip-defined.code-snippets +0 -29
  72. package/app/components/02.molecules/display-tooltip-defined/CONSUMER-STYLING.md +0 -53
  73. package/app/components/02.molecules/display-tooltip-defined/DisplayTooltipDefined.vue +0 -117
  74. package/app/components/02.molecules/display-tooltip-defined/stories/DisplayTooltipDefined.stories.ts +0 -129
  75. package/app/components/02.molecules/display-tooltip-defined/tests/DisplayTooltipDefined.spec.ts +0 -85
  76. package/app/components/02.molecules/display-tooltip-defined/tests/__snapshots__/DisplayTooltipDefined.spec.ts.snap +0 -17
  77. /package/app/types/components/{display-tooltip-defined.d.ts → display-tooltip-rich.d.ts} +0 -0
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## Public token API
4
4
 
5
+ Private tokens (`--_anchor-name`, `--_trigger-icon-size`, `--_trigger-padding`,
6
+ `--_trigger-border-width`, and the script-written `--_anchor-top`, `--_anchor-right`,
7
+ `--_anchor-left-inverse`) are not public API.
8
+
9
+ ### Trigger
10
+
5
11
  | Token | Default | Controls |
6
12
  |---|---|---|
7
13
  | `--display-tooltip-padding-block` | `0.8rem` | Vertical padding around the trigger row |
@@ -14,10 +20,15 @@
14
20
  | `--display-tooltip-trigger-border-radius` | `100vw` | Trigger button corner radius (circular by default) |
15
21
  | `--display-tooltip-trigger-outline-width` | `0.1rem` | Trigger button focus/hover outline width |
16
22
  | `--display-tooltip-trigger-outline-colour-hover` | `var(--theme-ring)` | Trigger button outline colour on hover/focus-visible |
17
- | `--display-tooltip-popover-width` | `30rem` | Popover panel width |
23
+
24
+ ### Popover panel
25
+
26
+ | Token | Default | Controls |
27
+ |---|---|---|
28
+ | `--display-tooltip-popover-width` | `30rem` | Popover panel width (capped at `100vw - 3.2rem`) |
18
29
  | `--display-tooltip-popover-padding` | `1.2rem` | Popover panel content padding |
19
30
  | `--display-tooltip-popover-content-gap` | `1.2rem` | Gap between stacked children in the `tooltipContent` slot |
20
- | `--display-tooltip-popover-font-size` | `1.4rem` | Popover text size (`DisplayTooltipDefined` sets its own per-element sizes) |
31
+ | `--display-tooltip-popover-font-size` | `1.4rem` | Popover text size (`DisplayTooltipRich` sets its own per-element sizes) |
21
32
  | `--display-tooltip-popover-line-height` | `1.4` | Popover text line height |
22
33
  | `--display-tooltip-popover-outline-width` | `0.1rem` | Popover panel outline (border) width |
23
34
  | `--display-tooltip-popover-outline-colour` | `var(--slate-02)` | Popover panel outline (border) colour |
@@ -27,29 +38,61 @@
27
38
  | `--display-tooltip-popover-shadow` | `0 0.4rem 1.6rem rgba(0,0,0,.12)` | Popover panel elevation shadow |
28
39
  | `--display-tooltip-popover-offset` | `0.1rem` | Gap between the trigger and the popover panel |
29
40
  | `--display-tooltip-popover-z-index` | `999999` | Panel stacking order in browsers without CSS anchor positioning. Ignored where the panel renders in the top layer |
30
- | `--display-tooltip-close-button-colour` | `var(--slate-09)` | Close button text colour (used by `DisplayTooltipDefined`) |
41
+
42
+ The panel uses the neutral `--slate-*` scale rather than `--theme-*`, like `PopOver`: a tooltip
43
+ reads as a neutral overlay whatever the surrounding theme.
44
+
45
+ ### Close button (`.display-tooltip-close-button`)
46
+
47
+ | Token | Default | Controls |
48
+ |---|---|---|
49
+ | `--display-tooltip-close-button-colour` | `var(--slate-09)` | Close button text colour |
31
50
  | `--display-tooltip-close-button-border-width` | `0.1rem` | Close button border width |
32
51
  | `--display-tooltip-close-button-border-colour` | `var(--slate-03)` | Close button border colour |
33
52
  | `--display-tooltip-close-button-outline-width` | `0.1rem` | Close button outline width |
34
53
  | `--display-tooltip-close-button-outline-colour` | `transparent` | Close button outline colour at rest (no permanent ring) |
35
- | `--display-tooltip-close-button-border-colour-hover` | `var(--slate-06)` | Close button border colour on hover/focus |
36
- | `--display-tooltip-close-button-outline-colour-hover` | `var(--slate-06)` | Close button outline colour on hover/focus |
54
+ | `--display-tooltip-close-button-border-colour-hover` | `var(--slate-06)` | Close button border colour on hover/focus-visible |
55
+ | `--display-tooltip-close-button-outline-colour-hover` | `var(--slate-06)` | Close button outline colour on hover/focus-visible |
37
56
  | `--display-tooltip-close-button-padding` | `0.4rem 1rem` | Close button padding |
38
57
 
58
+ `DisplayTooltipRich` renders this button. A plain `DisplayTooltip` gets the same styling by
59
+ giving its own close button the `display-tooltip-close-button` class inside `tooltipContent`.
60
+
61
+ > Changed 2026-10-18: `--display-tooltip-close-button-outline-colour` was documented but never read
62
+ > (the resting outline was hardcoded `transparent`); it now works. The hover styles apply on
63
+ > `:focus-visible` instead of `:focus`, so a mouse click no longer leaves the ring on.
64
+
65
+ ---
66
+
67
+ ## State hooks
68
+
69
+ | Hook | Element | When |
70
+ |---|---|---|
71
+ | `[data-hidden]` | `.display-tooltip-trigger-button` | `hide-trigger` is set: the button collapses to zero width, leaves the tab order and is `aria-hidden` |
72
+ | `:popover-open` | `.display-tooltip-popover` | Panel open (Popover API) |
73
+ | `.display-tooltip-popover-open` | `.display-tooltip-popover` | Panel open without the Popover API (Safari 16) |
74
+ | `[data-placement="left"]` | `.display-tooltip-popover` | Fallback placement flipped to the left of the trigger |
75
+
76
+ Inner classes: `.display-tooltip` (root), `.display-tooltip-trigger-wrapper`,
77
+ `.display-tooltip-trigger-button`, `.display-tooltip-trigger-icon`, `.display-tooltip-popover`,
78
+ `.display-tooltip-popover-content`.
79
+
80
+ > Changed 2026-10-18: the root class was `.display-tooltip-core` and is now `.display-tooltip`, and
81
+ > the hidden trigger's `.hide` class is now `[data-hidden]` (a bare `.hide` collided with consumers'
82
+ > utility classes). Update overrides written against the old names. Token names are unchanged.
83
+
84
+ ---
85
+
86
+ ## Global theming
87
+
39
88
  ```css
40
- .my-page {
89
+ :where(html) {
41
90
  --display-tooltip-popover-background-colour: #1a1a1a;
42
91
  --display-tooltip-popover-text-colour: white;
43
92
  --display-tooltip-popover-outline-colour: transparent;
44
93
  }
45
94
  ```
46
95
 
47
- Or scope to a single instance via `styleClassPassthrough`:
48
-
49
- ```vue
50
- <DisplayTooltip style-class-passthrough="promo-tooltip">...</DisplayTooltip>
51
- ```
52
-
53
96
  ---
54
97
 
55
98
  ## Local overrides
@@ -62,12 +105,24 @@ Set the tokens above on an element you own (a page or section class, or a class
62
105
 
63
106
  ---
64
107
 
108
+ ## Class passthrough
109
+
110
+ `style-class-passthrough` adds classes to the root `.display-tooltip`, so tokens set on a
111
+ passthrough class reach the trigger and the panel:
112
+
113
+ ```vue
114
+ <DisplayTooltip style-class-passthrough="promo-tooltip">...</DisplayTooltip>
115
+ ```
116
+
117
+ ---
118
+
65
119
  ## Notes
66
120
 
67
121
  - Direct children of the `tooltipContent` slot have their block margins reset to `0` (inside
68
122
  `:where()`, so any margin you set on them still wins): the gap token is the only spacing between
69
123
  them. Before 2026-10-05 a slotted `<p>` kept its browser `1em` margins on top of the padding.
70
-
124
+ - Long unbroken text wraps (`overflow-wrap: anywhere`) in both the trigger row and the panel, and
125
+ the trigger button never shrinks, so a long label stays beside it in a narrow column.
71
126
  - The trigger button is vertically centred on the first line of `triggerContent` text, using
72
127
  `--display-tooltip-trigger-icon-box-size`, `-padding` and `-border-width` to work out its height.
73
128
  Give `--display-tooltip-trigger-padding` a single length (e.g. `0.4rem`): a two-value shorthand
@@ -80,15 +135,6 @@ Set the tokens above on an element you own (a page or section class, or a class
80
135
  containing `:popover-open` is dropped whole where unsupported), isn't in the top layer (so the
81
136
  z-index token applies), and an ancestor with `transform`, `filter` or `contain` can misplace it.
82
137
  The trigger's `aria-expanded` tracks the open state in every browser.
83
- - The `.display-tooltip-close-button` styled here is rendered by `DisplayTooltipDefined`, not this
84
- component directly — `DisplayTooltip` only provides the `tooltipContent` slot, so a plain
85
- `DisplayTooltip` usage supplies its own close affordance if it wants one (give it
86
- `style="align-self: flex-end;"` or a class to right-align it within the content's flex column,
87
- same as `DisplayTooltipDefined` does internally).
88
138
  - 2026-09-07: default colours switched from raw `light-dark(black, white)` to the library's
89
- neutral `--slate-*` scale, and `.display-tooltip-popover-content` gained default `padding`,
90
- `display: flex; flex-direction: column;`, and `gap` (previously unstyled, meaning content sat
91
- flush against the panel edges) plus the popover gained a default elevation `box-shadow`
92
- (previously none) — the out-of-the-box look was flat and under-styled before this. All of these
93
- remain overridable via the tokens above.
94
-
139
+ neutral `--slate-*` scale, and the panel gained default padding, a flex column with a gap, and a
140
+ default elevation shadow.
@@ -1,23 +1,26 @@
1
1
  <template>
2
2
  <div
3
3
  ref="rootRef"
4
- class="display-tooltip-core"
4
+ class="display-tooltip"
5
5
  :class="[elementClasses]"
6
6
  :style="{ '--_anchor-name': tooltipAnchorName }"
7
7
  >
8
- <div class="display-tooltip-trigger-wrapper body-md">
8
+ <div class="display-tooltip-trigger-wrapper">
9
9
  <slot v-if="$slots.triggerContent" name="triggerContent"></slot>
10
10
  <button
11
11
  ref="triggerRef"
12
12
  :popovertarget="tooltipId"
13
13
  popovertargetaction="toggle"
14
+ type="button"
14
15
  class="display-tooltip-trigger-button"
15
- :class="{ hide: hideTrigger }"
16
+ :data-hidden="hideTrigger ? '' : undefined"
17
+ :tabindex="hideTrigger ? -1 : undefined"
18
+ :aria-hidden="hideTrigger ? 'true' : undefined"
16
19
  :aria-label="triggerAriaLabel"
17
20
  :aria-expanded="isOpen"
18
21
  @click="handleTriggerClick"
19
22
  >
20
- <Icon name="fa7-solid:circle-question" class="display-tooltip-trigger-icon" aria-hidden="true" />
23
+ <Icon :name="triggerIcon" class="display-tooltip-trigger-icon" aria-hidden="true" />
21
24
  </button>
22
25
  </div>
23
26
  <div
@@ -44,6 +47,8 @@ interface Props {
44
47
  hideTrigger?: boolean;
45
48
  /** aria-label on the trigger button — override for localisation. */
46
49
  triggerAriaLabel?: string;
50
+ /** Iconify name for the trigger button icon. */
51
+ triggerIcon?: string;
47
52
  styleClassPassthrough?: string | string[];
48
53
  }
49
54
 
@@ -51,10 +56,12 @@ const props = withDefaults(defineProps<Props>(), {
51
56
  tooltipId: "",
52
57
  hideTrigger: false,
53
58
  triggerAriaLabel: "Toggle the popover",
59
+ triggerIcon: "fa7-solid:circle-question",
54
60
  styleClassPassthrough: () => [],
55
61
  });
56
62
 
57
- const tooltipId = computed(() => (props.tooltipId.length ? props.tooltipId : `nuxt-tooltip-${useId()}`));
63
+ const generatedTooltipId = `nuxt-tooltip-${useId()}`;
64
+ const tooltipId = computed(() => props.tooltipId || generatedTooltipId);
58
65
  const tooltipAnchorName = `--tooltip-anchor-${useId()}`;
59
66
 
60
67
  const rootRef = ref<HTMLElement | null>(null);
@@ -84,18 +91,22 @@ watch(
84
91
 
85
92
  <style lang="css">
86
93
  @layer components {
87
- .display-tooltip-core {
94
+ .display-tooltip {
88
95
  padding-block: var(--display-tooltip-padding-block, 0.8rem);
89
96
  position: relative;
90
97
 
91
98
  .display-tooltip-trigger-wrapper {
92
99
  display: inline-flex;
93
100
  align-items: flex-start;
101
+ max-inline-size: 100%;
102
+ overflow-wrap: anywhere;
94
103
 
95
104
  .display-tooltip-trigger-button {
96
- margin-left: var(--display-tooltip-trigger-gap, 0.8rem);
105
+ flex: none;
106
+ margin-inline-start: var(--display-tooltip-trigger-gap, 0.8rem);
97
107
 
98
- &.hide {
108
+ &[data-hidden] {
109
+ margin-inline-start: 0;
99
110
  width: 0;
100
111
  padding: 0;
101
112
  border-width: 0;
@@ -132,7 +143,6 @@ watch(
132
143
 
133
144
  &:hover {
134
145
  cursor: pointer;
135
- text-decoration: underline;
136
146
  }
137
147
 
138
148
  &:hover,
@@ -146,7 +156,8 @@ watch(
146
156
  display: none;
147
157
  position: absolute;
148
158
  border: none;
149
- width: var(--display-tooltip-popover-width, 30rem);
159
+ width: min(var(--display-tooltip-popover-width, 30rem), calc(100vw - 3.2rem));
160
+ overflow-wrap: anywhere;
150
161
 
151
162
  outline: var(--display-tooltip-popover-outline-width, 0.1rem) solid
152
163
  var(--display-tooltip-popover-outline-colour, var(--slate-02));
@@ -188,13 +199,14 @@ watch(
188
199
  color: var(--display-tooltip-close-button-colour, var(--slate-09));
189
200
  border: var(--display-tooltip-close-button-border-width, 0.1rem) solid
190
201
  var(--display-tooltip-close-button-border-colour, var(--slate-03));
191
- outline: var(--display-tooltip-close-button-outline-width, 0.1rem) solid transparent;
202
+ outline: var(--display-tooltip-close-button-outline-width, 0.1rem) solid
203
+ var(--display-tooltip-close-button-outline-colour, transparent);
192
204
 
193
205
  font-weight: 600;
194
206
  padding: var(--display-tooltip-close-button-padding, 0.4rem 1rem);
195
207
 
196
208
  &:hover,
197
- &:focus {
209
+ &:focus-visible {
198
210
  text-decoration: underline;
199
211
  border-color: var(--display-tooltip-close-button-border-colour-hover, var(--slate-06));
200
212
  outline-color: var(--display-tooltip-close-button-outline-colour-hover, var(--slate-06));
@@ -12,19 +12,31 @@ const meta: Meta<typeof DisplayTooltip> = {
12
12
  },
13
13
  hideTrigger: {
14
14
  control: "boolean",
15
- description: "Visually hides the trigger button (kept in the DOM) — e.g. while an equivalent trigger is provided via the triggerContent slot.",
15
+ description: "Visually hides the trigger button and removes it from the tab order and accessibility tree. It stays in the DOM as the panel's anchor, for a panel opened by your own `popovertarget` button.",
16
+ table: { category: "Basic" },
17
+ },
18
+ triggerAriaLabel: {
19
+ control: "text",
20
+ description: "aria-label on the trigger button (override for localisation)",
21
+ table: { category: "Accessibility" },
22
+ },
23
+ triggerIcon: {
24
+ control: "text",
25
+ description: "Iconify name for the trigger button icon",
16
26
  table: { category: "Basic" },
17
27
  },
18
28
  styleClassPassthrough: { table: { disable: true } },
19
29
  },
20
30
  args: {
21
31
  hideTrigger: false,
32
+ triggerAriaLabel: "Toggle the popover",
33
+ triggerIcon: "fa7-solid:circle-question",
22
34
  },
23
35
  parameters: {
24
36
  docs: {
25
37
  description: {
26
38
  component:
27
- "A trigger button that opens an anchor-positioned popover panel, built on the native Popover API and CSS anchor-positioning. See DisplayTooltipDefined for a structured title/body/action content variant.",
39
+ "A trigger button that opens an anchor-positioned popover panel, built on the native Popover API and CSS anchor positioning (with fallbacks for Safari 16-18). Slots: `triggerContent` (a label before the button) and `tooltipContent` (the panel, with a scoped `close()`). Props set the trigger's icon (`triggerIcon`) and accessible name (`triggerAriaLabel`). DisplayTooltipRich builds on this with a structured title, body and close button: see its stories.",
28
40
  },
29
41
  },
30
42
  },
@@ -92,3 +104,78 @@ export const CustomColours: Story = {
92
104
  `,
93
105
  }),
94
106
  };
107
+
108
+ export const StressTest: Story = {
109
+ name: "Stress Test (Worst-Case Data)",
110
+ args: {
111
+ triggerAriaLabel: "Weitereinformationenzudieserangabeanzeigenundausblenden 😀",
112
+ },
113
+ render: (args) => ({
114
+ components: { DisplayTooltip },
115
+ setup() {
116
+ return { args };
117
+ },
118
+ template: `
119
+ <div style="display: grid; gap: 2.4rem; padding: 2.4rem; font-size: 1.4rem;">
120
+ <p style="margin: 0;">A long unbroken label in a narrow column: the label wraps and the button stays beside its first line.</p>
121
+ <div style="inline-size: 16rem; outline: 0.1rem dashed currentColor;">
122
+ <DisplayTooltip v-bind="args">
123
+ <template #triggerContent>
124
+ <span>Lieferkostenundversandbedingungenfürauslandsbestellungen</span>
125
+ </template>
126
+ <template #tooltipContent>
127
+ <p>Short content.</p>
128
+ </template>
129
+ </DisplayTooltip>
130
+ </div>
131
+
132
+ <p style="margin: 0;">Long unbroken popover content with emoji, HTML-like text and right-to-left text: the panel stays within the viewport width and wraps.</p>
133
+ <DisplayTooltip v-bind="args">
134
+ <template #triggerContent>
135
+ <span>😀 &lt;b&gt;not bold&lt;/b&gt;</span>
136
+ </template>
137
+ <template #tooltipContent>
138
+ <p>https://example.invalid/a/very/long/unbroken/url/that/never/offers/a/break/opportunity/anywhere</p>
139
+ <p>Kostenloselieferungbeibestellungenübereinenbestimmtenmindestbestellwert 😀</p>
140
+ <p>&lt;script&gt;alert("x")&lt;/script&gt; renders as text.</p>
141
+ <p dir="rtl">التوصيل مجاني للطلبات التي تزيد عن خمسين جنيهًا.</p>
142
+ </template>
143
+ </DisplayTooltip>
144
+
145
+ <p style="margin: 0;">Many paragraphs: the panel grows to its content's height.</p>
146
+ <DisplayTooltip v-bind="args">
147
+ <template #tooltipContent>
148
+ <p v-for="n in 8" :key="n">Paragraph {{ n }}: Lorem ipsum odor amet, consectetuer adipiscing elit.</p>
149
+ </template>
150
+ </DisplayTooltip>
151
+
152
+ <p style="margin: 0;">Empty <code>tooltipContent</code> and a single-character label: the panel is just its padding.</p>
153
+ <DisplayTooltip v-bind="args">
154
+ <template #triggerContent><span>x</span></template>
155
+ <template #tooltipContent></template>
156
+ </DisplayTooltip>
157
+
158
+ <p style="margin: 0;">A broken <code>triggerIcon</code> name: the button keeps its size and stays focusable.</p>
159
+ <DisplayTooltip v-bind="args" trigger-icon="not-a-set:not-an-icon">
160
+ <template #tooltipContent><p>Opened from an empty button.</p></template>
161
+ </DisplayTooltip>
162
+
163
+ <p style="margin: 0;">Right-aligned at the edge of the canvas: the panel flips to the left of the trigger.</p>
164
+ <div style="display: flex; justify-content: flex-end;">
165
+ <DisplayTooltip v-bind="args">
166
+ <template #tooltipContent><p>Flipped to fit.</p></template>
167
+ </DisplayTooltip>
168
+ </div>
169
+ </div>
170
+ `,
171
+ }),
172
+ parameters: {
173
+ docs: {
174
+ story: { inline: false, height: "900px" },
175
+ description: {
176
+ story:
177
+ "Worst-case content. Open each tooltip and check: long unbroken labels wrap without pushing the button out of a narrow column, panel content wraps and the panel never gets wider than the viewport, HTML-like text renders as text, an empty panel collapses to its padding, a broken icon name leaves an empty but focusable button, and a trigger at the right edge opens its panel to the left. Tab through: every trigger is reachable and shows a focus ring. The long `triggerAriaLabel` is only the buttons' accessible name, so it never shows on screen.",
178
+ },
179
+ },
180
+ },
181
+ };
@@ -1,69 +1,116 @@
1
- import { describe, it, expect, beforeEach, afterEach } from "vitest";
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
2
  import { mountSuspended } from "@nuxt/test-utils/runtime";
3
3
  import DisplayTooltip from "../DisplayTooltip.vue";
4
4
 
5
+ const mounted: { unmount: () => void }[] = [];
6
+
7
+ const mountTooltip = async (options: Parameters<typeof mountSuspended<typeof DisplayTooltip>>[1] = {}) => {
8
+ const wrapper = await mountSuspended(DisplayTooltip, options);
9
+ mounted.push(wrapper);
10
+ return wrapper;
11
+ };
12
+
5
13
  describe("DisplayTooltip", () => {
14
+ afterEach(() => {
15
+ mounted.splice(0).forEach((wrapper) => wrapper.unmount());
16
+ vi.restoreAllMocks();
17
+ });
18
+
6
19
  it("mounts without error", async () => {
7
- const wrapper = await mountSuspended(DisplayTooltip);
20
+ const wrapper = await mountTooltip();
8
21
  expect(wrapper.vm).toBeTruthy();
9
22
  });
10
23
 
11
24
  it("renders correct HTML structure", async () => {
12
- const wrapper = await mountSuspended(DisplayTooltip, { props: { tooltipId: "fixed-id" } });
25
+ const wrapper = await mountTooltip({ props: { tooltipId: "fixed-id" } });
13
26
  expect(wrapper.html()).toMatchSnapshot();
14
27
  });
15
28
 
16
29
  it("generates a tooltip id when none is provided", async () => {
17
- const wrapper = await mountSuspended(DisplayTooltip);
30
+ const wrapper = await mountTooltip();
18
31
  const button = wrapper.find(".display-tooltip-trigger-button");
19
32
  expect(button.attributes("popovertarget")).toMatch(/^nuxt-tooltip-/);
20
33
  });
21
34
 
22
35
  it("uses the given tooltipId", async () => {
23
- const wrapper = await mountSuspended(DisplayTooltip, { props: { tooltipId: "my-tooltip" } });
36
+ const wrapper = await mountTooltip({ props: { tooltipId: "my-tooltip" } });
24
37
  const button = wrapper.find(".display-tooltip-trigger-button");
25
38
  expect(button.attributes("popovertarget")).toBe("my-tooltip");
26
39
  expect(wrapper.find(".display-tooltip-popover").attributes("id")).toBe("my-tooltip");
27
40
  });
28
41
 
29
42
  it("does not render triggerContent slot content when unused", async () => {
30
- const wrapper = await mountSuspended(DisplayTooltip);
43
+ const wrapper = await mountTooltip();
31
44
  expect(wrapper.find(".slot-trigger").exists()).toBe(false);
32
45
  });
33
46
 
34
47
  it("renders the triggerContent slot", async () => {
35
- const wrapper = await mountSuspended(DisplayTooltip, {
48
+ const wrapper = await mountTooltip({
36
49
  slots: { triggerContent: "<span class='slot-trigger'>Label</span>" },
37
50
  });
38
51
  expect(wrapper.find(".slot-trigger").text()).toBe("Label");
39
52
  });
40
53
 
41
54
  it("renders the tooltipContent slot", async () => {
42
- const wrapper = await mountSuspended(DisplayTooltip, {
55
+ const wrapper = await mountTooltip({
43
56
  slots: { tooltipContent: "<p class='slot-content'>Helpful info</p>" },
44
57
  });
45
58
  expect(wrapper.find(".display-tooltip-popover .slot-content").text()).toBe("Helpful info");
46
59
  });
47
60
 
48
- it("applies the hide class to the trigger button when hideTrigger is true", async () => {
49
- const wrapper = await mountSuspended(DisplayTooltip, { props: { hideTrigger: true } });
50
- expect(wrapper.find(".display-tooltip-trigger-button").classes()).toContain("hide");
61
+ it("hides the trigger from view, the tab order and the accessibility tree when hideTrigger is true", async () => {
62
+ const wrapper = await mountTooltip({ props: { hideTrigger: true } });
63
+ const button = wrapper.find(".display-tooltip-trigger-button");
64
+ expect(button.attributes("data-hidden")).toBeDefined();
65
+ expect(button.attributes("tabindex")).toBe("-1");
66
+ expect(button.attributes("aria-hidden")).toBe("true");
51
67
  });
52
68
 
53
- it("does not apply the hide class by default", async () => {
54
- const wrapper = await mountSuspended(DisplayTooltip);
55
- expect(wrapper.find(".display-tooltip-trigger-button").classes()).not.toContain("hide");
69
+ it("leaves the trigger visible and focusable by default", async () => {
70
+ const wrapper = await mountTooltip();
71
+ const button = wrapper.find(".display-tooltip-trigger-button");
72
+ expect(button.attributes("data-hidden")).toBeUndefined();
73
+ expect(button.attributes("tabindex")).toBeUndefined();
74
+ expect(button.attributes("aria-hidden")).toBeUndefined();
75
+ });
76
+
77
+ it("renders the trigger as type=\"button\" so it never submits a surrounding form", async () => {
78
+ const wrapper = await mountTooltip();
79
+ expect(wrapper.find(".display-tooltip-trigger-button").attributes("type")).toBe("button");
80
+ });
81
+
82
+ it("uses the triggerIcon prop for the trigger icon", async () => {
83
+ const wrapper = await mountTooltip({ props: { triggerIcon: "lucide:info" } });
84
+ expect(wrapper.find(".display-tooltip-trigger-icon").classes().join(" ")).toContain("lucide:info");
85
+ });
86
+
87
+ it("uses triggerAriaLabel for the trigger's accessible name", async () => {
88
+ const wrapper = await mountTooltip({ props: { triggerAriaLabel: "Plus d'informations" } });
89
+ expect(wrapper.find(".display-tooltip-trigger-button").attributes("aria-label")).toBe("Plus d'informations");
90
+ });
91
+
92
+ it("keeps the generated id stable and follows tooltipId changes", async () => {
93
+ const wrapper = await mountTooltip();
94
+ const button = wrapper.find(".display-tooltip-trigger-button");
95
+ const generated = button.attributes("popovertarget");
96
+
97
+ await wrapper.setProps({ tooltipId: "custom" });
98
+ expect(button.attributes("popovertarget")).toBe("custom");
99
+ expect(wrapper.find(".display-tooltip-popover").attributes("id")).toBe("custom");
100
+
101
+ await wrapper.setProps({ tooltipId: "" });
102
+ expect(button.attributes("popovertarget")).toBe(generated);
56
103
  });
57
104
 
58
105
  it("applies styleClassPassthrough classes", async () => {
59
- const wrapper = await mountSuspended(DisplayTooltip, {
106
+ const wrapper = await mountTooltip({
60
107
  props: { styleClassPassthrough: ["custom-class"] },
61
108
  });
62
109
  expect(wrapper.classes()).toContain("custom-class");
63
110
  });
64
111
 
65
112
  it("resets classes when styleClassPassthrough prop changes", async () => {
66
- const wrapper = await mountSuspended(DisplayTooltip, {
113
+ const wrapper = await mountTooltip({
67
114
  props: { styleClassPassthrough: ["initial-class"] },
68
115
  });
69
116
  expect(wrapper.classes()).toContain("initial-class");
@@ -83,7 +130,7 @@ describe("DisplayTooltip", () => {
83
130
  });
84
131
 
85
132
  it("opens and closes from the trigger", async () => {
86
- const wrapper = await mountSuspended(DisplayTooltip);
133
+ const wrapper = await mountTooltip();
87
134
  const trigger = wrapper.find(".display-tooltip-trigger-button");
88
135
  const popover = wrapper.find(".display-tooltip-popover");
89
136
 
@@ -97,7 +144,7 @@ describe("DisplayTooltip", () => {
97
144
  });
98
145
 
99
146
  it("passes a close function to the tooltipContent slot", async () => {
100
- const wrapper = await mountSuspended(DisplayTooltip, {
147
+ const wrapper = await mountTooltip({
101
148
  slots: {
102
149
  tooltipContent: `<template #tooltipContent="{ close }"><button class="custom-close" @click="close">Done</button></template>`,
103
150
  },
@@ -1,9 +1,9 @@
1
1
  // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
2
 
3
3
  exports[`DisplayTooltip > renders correct HTML structure 1`] = `
4
- "<div class="display-tooltip-core" style="--_anchor-name: --tooltip-anchor-v-0-0;">
5
- <div class="display-tooltip-trigger-wrapper body-md">
6
- <!--v-if--><button popovertarget="fixed-id" popovertargetaction="toggle" class="display-tooltip-trigger-button" aria-label="Toggle the popover" aria-expanded="false"><span class="iconify i-fa7-solid:circle-question display-tooltip-trigger-icon" aria-hidden="true"></span></button>
4
+ "<div class="display-tooltip" style="--_anchor-name: --tooltip-anchor-v-0-1;">
5
+ <div class="display-tooltip-trigger-wrapper">
6
+ <!--v-if--><button popovertarget="fixed-id" popovertargetaction="toggle" type="button" class="display-tooltip-trigger-button" aria-label="Toggle the popover" aria-expanded="false"><span class="iconify i-fa7-solid:circle-question display-tooltip-trigger-icon" aria-hidden="true"></span></button>
7
7
  </div>
8
8
  <div id="fixed-id" popover="" class="display-tooltip-popover">
9
9
  <div class="display-tooltip-popover-content"></div>
@@ -2,23 +2,63 @@
2
2
 
3
3
  ## Public token API
4
4
 
5
+ No private `--_` tokens.
6
+
7
+ ### Panel (`.skip-links__nav`)
8
+
5
9
  | Token | Default | Controls |
6
10
  |---|---|---|
7
- | `--skip-links-z-index` | `1000` | Stacking context of the revealed skip-nav panel |
11
+ | `--skip-links-z-index` | `999999` | Stacking order of the revealed panel |
8
12
  | `--skip-links-gap` | `0.2rem` | Gap between stacked skip links |
9
- | `--skip-links-background-colour` | `black` | Skip-nav panel background |
10
- | `--skip-links-border-colour` | `white` | Skip-nav panel border colour |
11
- | `--skip-links-border-width` | `1px` | Skip-nav panel border width |
12
- | `--skip-links-padding` | `0.2rem` | Skip-nav panel padding |
13
- | `--skip-links-text-colour` | `white` | Skip link text colour |
14
- | `--skip-links-link-padding-block` | `0.8rem` | Skip link vertical padding |
15
- | `--skip-links-link-padding-inline` | `1.2rem` | Skip link horizontal padding |
16
- | `--skip-links-transition-duration` | `0.3s` | Duration of the reveal transition when a link is focused |
17
- | `--skip-links-focus-outline-width` | `2px` | Focus outline width on a skip link |
18
- | `--skip-links-focus-outline-colour` | `white` | Focus outline colour on a skip link |
13
+ | `--skip-links-background-colour` | `var(--theme-surface-inverted)` | Panel background |
14
+ | `--skip-links-border-colour` | `var(--theme-on-surface)` | Panel border colour |
15
+ | `--skip-links-border-width` | `1px` | Panel border width |
16
+ | `--skip-links-padding` | `0.2rem` | Panel padding |
17
+ | `--skip-links-transition-duration` | `0.3s` | Duration of the reveal transition (off under `prefers-reduced-motion`) |
18
+
19
+ ### Links (`.skip-links__link`)
20
+
21
+ | Token | Default | Controls |
22
+ |---|---|---|
23
+ | `--skip-links-text-colour` | `var(--theme-on-surface)` | Link text colour |
24
+ | `--skip-links-link-padding-block` | `0.8rem` | Link vertical padding |
25
+ | `--skip-links-link-padding-inline` | `1.2rem` | Link horizontal padding |
26
+ | `--skip-links-focus-outline-width` | `2px` | Focus outline width |
27
+ | `--skip-links-focus-outline-colour` | `var(--theme-on-surface)` | Focus outline colour |
28
+
29
+ > Changed 2026-10-18: the colours used to be hardcoded `black` (background) and `white` (text,
30
+ > border, focus ring). They now default to the darkest theme step (`--theme-surface-inverted`) and
31
+ > `--theme-on-surface`, keeping the high contrast while following the site's `data-theme` palette.
32
+ > Set the four colour tokens to `black`/`white` to get the old look back. `--skip-links-z-index`
33
+ > went from `1000` to `999999`, the library's overlay default, so the panel shows above sticky
34
+ > headers.
35
+
36
+ ---
37
+
38
+ ## State hooks
39
+
40
+ | Hook | Element | When |
41
+ |---|---|---|
42
+ | `:focus-within` | `.skip-links__nav` | A skip link has focus: the panel is revealed |
43
+
44
+ Inner classes: `.skip-links` (root), `.skip-links__home` (only when the `homeLink` slot is used),
45
+ `.skip-links__nav`, `.skip-links__link`.
46
+
47
+ The panel is hidden with `opacity`, `transform` and `pointer-events`, not `display` or
48
+ `visibility`, so the links stay in the tab order. It's positioned below the root
49
+ (`top: 100%; inset-inline-start: 0`), so in a right-to-left page it drops from the right edge. It
50
+ sizes to its longest link but never exceeds the viewport width (`100vw - 3.2rem`); longer labels
51
+ wrap. The `<nav>` isn't rendered at all when no link has both an `href` and a `label`.
52
+
53
+ > Changed 2026-10-18: links used to be `white-space: nowrap`, so a long label ran off-screen, and
54
+ > the panel was anchored with a physical `left: 0`.
55
+
56
+ ---
57
+
58
+ ## Global theming
19
59
 
20
60
  ```css
21
- .my-page {
61
+ :where(html) {
22
62
  --skip-links-background-colour: #111;
23
63
  --skip-links-border-colour: #ffd700;
24
64
  --skip-links-focus-outline-colour: #ffd700;
@@ -27,13 +67,6 @@
27
67
 
28
68
  ---
29
69
 
30
- ## Links are prop-driven
31
-
32
- The set of skip links is provided via the `links` prop (`{ href, label }[]`), not slots — see the
33
- skill doc for the default value and localisation notes.
34
-
35
- ---
36
-
37
70
  ## Local overrides
38
71
 
39
72
  Set the tokens above on an element you own (a page or section class, or a class added with
@@ -42,3 +75,16 @@ Set the tokens above on an element you own (a page or section class, or a class
42
75
  `<style scoped>`, tokens set on your element still work, but selectors that reach inside the component need
43
76
  `:deep()`. Patterns and examples: `.claude/skills/component-local-style-override.md`.
44
77
 
78
+ ---
79
+
80
+ ## Class passthrough
81
+
82
+ `style-class-passthrough` adds classes to the root `.skip-links`, so tokens set on a passthrough
83
+ class reach the panel and the links.
84
+
85
+ ---
86
+
87
+ ## Notes
88
+
89
+ - The links are prop-driven (`links: { href, label }[]`), not slots: see the skill doc for the
90
+ default value and localisation.