srcdev-nuxt-components 9.5.4 → 9.5.6

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 (55) hide show
  1. package/.claude/skills/components/action-menu.md +21 -15
  2. package/.claude/skills/components/deep-expanding-menu.md +18 -6
  3. package/.claude/skills/components/display-tooltip-defined.md +2 -1
  4. package/.claude/skills/components/display-tooltip.md +10 -4
  5. package/.claude/skills/components/pop-over.md +11 -8
  6. package/.claude/skills/components/select-menu.md +28 -8
  7. package/.claude/skills/components/triple-toggle-switch.md +7 -1
  8. package/.claude/skills/components/ui-block-decorated.md +7 -0
  9. package/.claude/skills/composable-anchored-popover.md +108 -0
  10. package/.claude/skills/composable-tooltips-guide.md +3 -1
  11. package/.claude/skills/index.md +1 -0
  12. package/app/assets/styles/setup/06.utility-classes/animations/_animation-scroller-x.css +5 -3
  13. package/app/assets/styles/setup/06.utility-classes/animations/_auto-rotate.css +5 -3
  14. package/app/assets/styles/setup/06.utility-classes/animations/_entry-exit-blur.css +5 -3
  15. package/app/assets/styles/setup/06.utility-classes/animations/_entry-slide-in.css +5 -3
  16. package/app/assets/styles/setup/06.utility-classes/animations/_entry-zoom-reveal.css +5 -3
  17. package/app/components/01.atoms/animations/entry/CONSUMER-STYLING.md +6 -2
  18. package/app/components/01.atoms/display-tooltip/CONSUMER-STYLING.md +15 -3
  19. package/app/components/01.atoms/display-tooltip/DisplayTooltip.vue +62 -3
  20. package/app/components/01.atoms/display-tooltip/tests/DisplayTooltip.spec.ts +36 -1
  21. package/app/components/01.atoms/display-tooltip/tests/__snapshots__/DisplayTooltip.spec.ts.snap +1 -1
  22. package/app/components/01.atoms/navigation/skip-links/SkipLinks.vue +1 -1
  23. package/app/components/01.atoms/pop-over/CONSUMER-STYLING.md +9 -3
  24. package/app/components/01.atoms/pop-over/PopOver.vue +70 -9
  25. package/app/components/01.atoms/pop-over/tests/PopOver.spec.ts +53 -0
  26. package/app/components/01.atoms/pop-over/tests/__snapshots__/PopOver.spec.ts.snap +1 -1
  27. package/app/components/01.atoms/ui-block-decorated/CONSUMER-STYLING.md +12 -4
  28. package/app/components/01.atoms/ui-block-decorated/UiBlockDecorated.vue +20 -16
  29. package/app/components/01.atoms/ui-block-decorated/stories/UiBlockDecorated.stories.ts +124 -96
  30. package/app/components/02.molecules/action-menu/ActionMenu.vue +41 -16
  31. package/app/components/02.molecules/action-menu/CONSUMER-STYLING.md +16 -3
  32. package/app/components/02.molecules/action-menu/tests/ActionMenu.spec.ts +28 -0
  33. package/app/components/02.molecules/action-menu/tests/__snapshots__/ActionMenu.spec.ts.snap +3 -3
  34. package/app/components/02.molecules/display-tooltip-defined/DisplayTooltipDefined.vue +2 -1
  35. package/app/components/02.molecules/display-tooltip-defined/stories/DisplayTooltipDefined.stories.ts +7 -4
  36. package/app/components/02.molecules/display-tooltip-defined/tests/DisplayTooltipDefined.spec.ts +20 -1
  37. package/app/components/02.molecules/display-tooltip-defined/tests/__snapshots__/DisplayTooltipDefined.spec.ts.snap +1 -1
  38. package/app/components/02.molecules/navigation/deep-expanding-menu/CONSUMER-STYLING.md +18 -4
  39. package/app/components/02.molecules/navigation/deep-expanding-menu/DeepExpandingMenu.vue +87 -16
  40. package/app/components/02.molecules/navigation/deep-expanding-menu/tests/DeepExpandingMenu.spec.ts +83 -1
  41. package/app/components/02.molecules/navigation/deep-expanding-menu/tests/__snapshots__/DeepExpandingMenu.spec.ts.snap +2 -2
  42. package/app/components/02.molecules/navigation/site-navigation/SiteNavigation.vue +1 -1
  43. package/app/components/02.molecules/navigation/tab-navigation/TabNavigation.vue +1 -1
  44. package/app/components/02.molecules/select-menu/CONSUMER-STYLING.md +26 -6
  45. package/app/components/02.molecules/select-menu/SelectMenu.vue +90 -18
  46. package/app/components/02.molecules/select-menu/tests/SelectMenu.spec.ts +36 -0
  47. package/app/components/02.molecules/select-menu/tests/__snapshots__/SelectMenu.spec.ts.snap +9 -9
  48. package/app/components/03.organisms/responsive-header/NavigationItems.vue +2 -2
  49. package/app/components/03.organisms/responsive-header/ResponsiveHeader.vue +4 -4
  50. package/app/components/05.forms/triple-toggle-switch/TripleToggleSwitch.vue +11 -3
  51. package/app/composables/tests/useAnchoredPopover.spec.ts +231 -0
  52. package/app/composables/tests/useTooltips.spec.ts +69 -0
  53. package/app/composables/useAnchoredPopover.ts +183 -0
  54. package/app/composables/useTooltips.ts +37 -20
  55. package/package.json +1 -1
@@ -1,7 +1,21 @@
1
+ import type { Meta, StoryFn } from "@nuxtjs/storybook";
2
+ import { computed } from "vue";
1
3
  import UiBlockDecorated from "../UiBlockDecorated.vue";
2
- import type { Meta, StoryObj } from "@nuxtjs/storybook";
3
4
 
4
- const meta: Meta<typeof UiBlockDecorated> = {
5
+ interface UiBlockDecoratedStoryArgs {
6
+ tag: "div" | "p" | "span" | "section" | "article" | "aside" | "header" | "footer" | "main" | "nav" | "ul" | "ol";
7
+ borderStrength: 0 | 1 | 2 | 3 | 4 | 5 | 6;
8
+ shadowStrength: 0 | 1 | 2 | 3 | 4 | 5 | 6;
9
+ innerShadowStrength: 0 | 1 | 2 | 3 | 4;
10
+ label: string;
11
+ borderColour: string;
12
+ shadowColour: string;
13
+ innerShadowColour: string;
14
+ background: string;
15
+ textColour: string;
16
+ }
17
+
18
+ export default {
5
19
  title: "Atoms/UiBlockDecorated",
6
20
  component: UiBlockDecorated,
7
21
  argTypes: {
@@ -29,120 +43,134 @@ const meta: Meta<typeof UiBlockDecorated> = {
29
43
  description: "Inner shadow strength level (0 = none)",
30
44
  table: { category: "Basic" },
31
45
  },
32
- styleClassPassthrough: {
33
- table: { disable: true },
46
+ label: { control: "text", description: "Story only: block text", table: { category: "Story" } },
47
+ borderColour: {
48
+ control: "color",
49
+ description: "--ui-block-decorated-border-colour (empty = var(--theme-border))",
50
+ table: { category: "Tokens" },
51
+ },
52
+ shadowColour: {
53
+ control: "color",
54
+ description: "--ui-block-decorated-shadow-colour (empty = black). Each strength level mixes in its own opacity.",
55
+ table: { category: "Tokens" },
56
+ },
57
+ innerShadowColour: {
58
+ control: "color",
59
+ description: "--ui-block-decorated-inner-shadow-colour (empty = the shadow colour)",
60
+ table: { category: "Tokens" },
61
+ },
62
+ background: {
63
+ control: "color",
64
+ description: "Story only: block background, for checking contrast (empty = none)",
65
+ table: { category: "Story" },
66
+ },
67
+ textColour: {
68
+ control: "color",
69
+ description: "Story only: block text colour (empty = inherited)",
70
+ table: { category: "Story" },
34
71
  },
35
72
  },
73
+ args: {
74
+ tag: "div",
75
+ borderStrength: 0,
76
+ shadowStrength: 0,
77
+ innerShadowStrength: 0,
78
+ label: "No decoration",
79
+ borderColour: "",
80
+ shadowColour: "",
81
+ innerShadowColour: "",
82
+ background: "",
83
+ textColour: "",
84
+ },
36
85
  parameters: {
37
86
  docs: {
38
87
  description: {
39
88
  component:
40
- "A plain block wrapper that applies independent border / drop-shadow / inner-shadow strength levels via CSS custom properties (see CONSUMER-STYLING.md).",
89
+ "A plain block wrapper that applies independent border / drop-shadow / inner-shadow strength levels via CSS custom properties (see CONSUMER-STYLING.md). Use the colour pickers under Tokens to recolour every strength level at once.",
41
90
  },
42
91
  },
43
92
  },
44
- };
93
+ } as Meta<UiBlockDecoratedStoryArgs>;
45
94
 
46
- export default meta;
47
- type Story = StoryObj<typeof UiBlockDecorated>;
95
+ const Template: StoryFn<UiBlockDecoratedStoryArgs> = (args) => ({
96
+ components: { UiBlockDecorated },
97
+ setup() {
98
+ const blockStyle = computed(() => ({
99
+ padding: "2rem",
100
+ ...(args.borderColour ? { "--ui-block-decorated-border-colour": args.borderColour } : {}),
101
+ ...(args.shadowColour ? { "--ui-block-decorated-shadow-colour": args.shadowColour } : {}),
102
+ ...(args.innerShadowColour ? { "--ui-block-decorated-inner-shadow-colour": args.innerShadowColour } : {}),
103
+ ...(args.background ? { background: args.background } : {}),
104
+ ...(args.textColour ? { color: args.textColour } : {}),
105
+ }));
106
+ return { args, blockStyle };
107
+ },
108
+ template: `
109
+ <UiBlockDecorated
110
+ :tag="args.tag"
111
+ :border-strength="args.borderStrength"
112
+ :shadow-strength="args.shadowStrength"
113
+ :inner-shadow-strength="args.innerShadowStrength"
114
+ :style="blockStyle"
115
+ >
116
+ {{ args.label }}
117
+ </UiBlockDecorated>
118
+ `,
119
+ });
48
120
 
49
- const blockStyle = "padding: 2rem; background: var(--theme-surface, #fff);";
121
+ export const Default = Template.bind({});
50
122
 
51
- export const Default: Story = {
52
- args: {
53
- tag: "div",
54
- },
55
- render: (args) => ({
56
- components: { UiBlockDecorated },
57
- setup() {
58
- return { args, blockStyle };
59
- },
60
- template: `<UiBlockDecorated v-bind="args" :style="blockStyle">No decoration</UiBlockDecorated>`,
61
- }),
62
- };
123
+ export const Border = Template.bind({});
124
+ Border.args = { borderStrength: 3, label: "Border strength 3" };
63
125
 
64
- export const Border: Story = {
65
- args: {
66
- tag: "div",
67
- borderStrength: 3,
68
- },
69
- render: (args) => ({
70
- components: { UiBlockDecorated },
71
- setup() {
72
- return { args, blockStyle };
73
- },
74
- template: `<UiBlockDecorated v-bind="args" :style="blockStyle">Border strength 3</UiBlockDecorated>`,
75
- }),
76
- };
126
+ export const Shadow = Template.bind({});
127
+ Shadow.args = { shadowStrength: 4, label: "Shadow strength 4" };
77
128
 
78
- export const Shadow: Story = {
79
- args: {
80
- tag: "div",
81
- shadowStrength: 4,
82
- },
83
- render: (args) => ({
84
- components: { UiBlockDecorated },
85
- setup() {
86
- return { args, blockStyle };
87
- },
88
- template: `<UiBlockDecorated v-bind="args" :style="blockStyle">Shadow strength 4</UiBlockDecorated>`,
89
- }),
90
- };
129
+ export const InnerShadow = Template.bind({});
130
+ InnerShadow.args = { innerShadowStrength: 3, label: "Inner shadow strength 3" };
91
131
 
92
- export const InnerShadow: Story = {
93
- args: {
94
- tag: "div",
95
- innerShadowStrength: 3,
96
- },
97
- render: (args) => ({
98
- components: { UiBlockDecorated },
99
- setup() {
100
- return { args, blockStyle };
101
- },
102
- template: `<UiBlockDecorated v-bind="args" :style="blockStyle">Inner shadow strength 3</UiBlockDecorated>`,
103
- }),
104
- };
132
+ export const AllCombined = Template.bind({});
133
+ AllCombined.args = { borderStrength: 2, shadowStrength: 3, innerShadowStrength: 2, label: "All three combined" };
105
134
 
106
- export const AllCombined: Story = {
107
- args: {
108
- tag: "div",
109
- borderStrength: 2,
110
- shadowStrength: 3,
111
- innerShadowStrength: 2,
112
- },
113
- render: (args) => ({
114
- components: { UiBlockDecorated },
115
- setup() {
116
- return { args, blockStyle };
135
+ export const BrandColours = Template.bind({});
136
+ BrandColours.args = {
137
+ borderStrength: 2,
138
+ shadowStrength: 4,
139
+ innerShadowStrength: 2,
140
+ label: "Brand colours via the colour tokens",
141
+ borderColour: "#7c3aed",
142
+ shadowColour: "#7c3aed",
143
+ background: "#faf5ff",
144
+ textColour: "#3b0764",
145
+ };
146
+ BrandColours.parameters = {
147
+ docs: {
148
+ description: {
149
+ story:
150
+ "`--ui-block-decorated-border-colour` and `--ui-block-decorated-shadow-colour` recolour every strength level; each shadow level keeps its own opacity. Background and text colour are story-only, for checking contrast.",
117
151
  },
118
- template: `<UiBlockDecorated v-bind="args" :style="blockStyle">All three combined</UiBlockDecorated>`,
119
- }),
152
+ },
120
153
  };
121
154
 
122
- export const CustomTokens: Story = {
123
- args: {
124
- tag: "div",
125
- shadowStrength: 3,
155
+ export const CustomTokens: StoryFn<UiBlockDecoratedStoryArgs> = (args) => ({
156
+ components: { UiBlockDecorated },
157
+ setup() {
158
+ return { args };
126
159
  },
127
- render: (args) => ({
128
- components: { UiBlockDecorated },
129
- setup() {
130
- return { args, blockStyle };
131
- },
132
- template: `
133
- <UiBlockDecorated
134
- v-bind="args"
135
- :style="blockStyle + ' --ui-block-decorated-shadow-3: 0 6px 12px rgba(0, 0, 0, 0.25);'"
136
- >
137
- Custom shadow-3 token
138
- </UiBlockDecorated>
139
- `,
140
- }),
141
- parameters: {
142
- docs: {
143
- description: {
144
- story: "Overriding a strength level's token via an inline `--ui-block-decorated-*` custom property.",
145
- },
160
+ template: `
161
+ <UiBlockDecorated
162
+ :shadow-strength="3"
163
+ style="padding: 2rem; --ui-block-decorated-shadow-3: 0 6px 12px rgba(0, 0, 0, 0.25);"
164
+ >
165
+ Custom shadow-3 token
166
+ </UiBlockDecorated>
167
+ `,
168
+ });
169
+ CustomTokens.parameters = {
170
+ controls: { disable: true },
171
+ docs: {
172
+ description: {
173
+ story: "Overriding a whole strength level's token via an inline `--ui-block-decorated-*` custom property.",
146
174
  },
147
175
  },
148
176
  };
@@ -1,5 +1,5 @@
1
1
  <template>
2
- <div class="action-menu" :class="[elementClasses]" :style="`--_anchor-name: ${anchorName}`">
2
+ <div ref="rootRef" class="action-menu" :class="[elementClasses]" :style="`--_anchor-name: ${anchorName}`">
3
3
  <button
4
4
  ref="triggerRef"
5
5
  :popovertarget="menuId"
@@ -8,6 +8,8 @@
8
8
  class="action-menu-trigger"
9
9
  :aria-label="label"
10
10
  aria-haspopup="menu"
11
+ :aria-expanded="isOpen"
12
+ @click="handleTriggerClick"
11
13
  >
12
14
  <Icon :name="triggerIcon" class="action-menu-trigger-icon" aria-hidden="true" />
13
15
  </button>
@@ -17,6 +19,10 @@
17
19
  ref="popoverRef"
18
20
  popover
19
21
  class="action-menu-popover"
22
+ :class="{ 'action-menu-popover-open': usesFallbackPopover && isOpen }"
23
+ :style="positionStyle"
24
+ :data-placement="popoverPlacement"
25
+ @beforetoggle="handleBeforeToggle"
20
26
  @toggle="handleToggle"
21
27
  @keydown="handleKeydown"
22
28
  >
@@ -49,6 +55,7 @@ const id = useId();
49
55
  const menuId = `action-menu-${id}`;
50
56
  const anchorName = `--action-menu-anchor-${id}`;
51
57
 
58
+ const rootRef = ref<HTMLDivElement | null>(null);
52
59
  const triggerRef = ref<HTMLButtonElement | null>(null);
53
60
  const popoverRef = ref<HTMLDivElement | null>(null);
54
61
 
@@ -56,23 +63,23 @@ const popoverRef = ref<HTMLDivElement | null>(null);
56
63
  const getMenuItems = (): HTMLElement[] =>
57
64
  Array.from(popoverRef.value?.querySelectorAll<HTMLElement>('[role="menuitem"]') ?? []);
58
65
 
59
- /**
60
- * Close the menu and return focus to the trigger.
61
- * Called on item click — Escape is handled natively by the Popover API
62
- * (which also restores focus to the trigger automatically).
63
- */
66
+ const {
67
+ isOpen,
68
+ usesFallbackPopover,
69
+ positionStyle,
70
+ popoverPlacement,
71
+ hide,
72
+ handleTriggerClick,
73
+ handleBeforeToggle,
74
+ handleToggle,
75
+ } = useAnchoredPopover({ rootRef, triggerRef, popoverRef, onOpen: () => getMenuItems()[0]?.focus() });
76
+
77
+ /** Close the menu and return focus to the trigger. Called on item click. */
64
78
  const closeMenu = () => {
65
- popoverRef.value?.hidePopover();
79
+ hide();
66
80
  triggerRef.value?.focus();
67
81
  };
68
82
 
69
- const handleToggle = (event: Event) => {
70
- const toggleEvent = event as ToggleEvent;
71
- if (toggleEvent.newState === "open") {
72
- getMenuItems()[0]?.focus();
73
- }
74
- };
75
-
76
83
  /**
77
84
  * Keyboard navigation following the WAI-ARIA menu pattern.
78
85
  *
@@ -81,7 +88,7 @@ const handleToggle = (event: Event) => {
81
88
  * Tab — close the menu; let the browser Tab naturally
82
89
  * (do NOT focus the trigger — Tab should advance
83
90
  * to the next element in the page).
84
- * Escape — handled natively by the Popover API.
91
+ * Escape — native light dismiss, or the fallback listener without the Popover API.
85
92
  */
86
93
  const handleKeydown = (event: KeyboardEvent) => {
87
94
  const items = getMenuItems();
@@ -108,7 +115,7 @@ const handleKeydown = (event: KeyboardEvent) => {
108
115
  break;
109
116
  case "Tab":
110
117
  // Close without stealing focus — Tab exits to the next DOM element naturally.
111
- popoverRef.value?.hidePopover();
118
+ hide();
112
119
  break;
113
120
  }
114
121
  };
@@ -194,6 +201,24 @@ watch(
194
201
  }
195
202
  }
196
203
 
204
+ /* Kept apart from :popover-open, which would invalidate a shared selector list where unsupported. */
205
+ &.action-menu-popover-open {
206
+ display: block;
207
+ opacity: 1;
208
+ }
209
+
210
+ @supports not (anchor-name: --a) {
211
+ position: fixed;
212
+ top: calc(var(--_anchor-bottom, 0px) + var(--action-menu-block-distance, 0.4rem));
213
+ right: var(--_anchor-right-inverse, 0px);
214
+ z-index: var(--action-menu-popover-z-index, 999999);
215
+
216
+ &[data-placement="top"] {
217
+ top: auto;
218
+ bottom: calc(var(--_anchor-top-inverse, 0px) + var(--action-menu-block-distance, 0.4rem));
219
+ }
220
+ }
221
+
197
222
  .action-menu-list {
198
223
  display: grid;
199
224
  grid-template-columns: auto 1fr auto;
@@ -31,6 +31,7 @@ All `--action-menu-*` tokens are the stable override surface. Action menus repea
31
31
  | `--action-menu-popover-min-width` | `20rem` | Minimum menu width |
32
32
  | `--action-menu-popover-shadow` | `0 0.4rem 1.6rem rgba(0, 0, 0, 0.1)` | Menu panel drop shadow |
33
33
  | `--action-menu-popover-transition-duration` | `200ms` | Open/close fade duration |
34
+ | `--action-menu-popover-z-index` | `999999` | Stacking order in browsers without CSS anchor positioning (see Positioning). Ignored where the popover renders in the top layer |
34
35
  | `--action-menu-item-divider` | `0.1rem solid var(--slate-02)` | Divider between list rows |
35
36
 
36
37
  ### Menu items (`ActionMenuItem`, resolved on `.action-menu-item`)
@@ -60,7 +61,10 @@ Private (not public API): `--_trigger-size`, `--_popover-transition-duration`, `
60
61
 
61
62
  | Hook | When |
62
63
  |---|---|
63
- | `.action-menu-popover:popover-open` | Menu is open |
64
+ | `.action-menu-popover:popover-open` | Menu is open, Popover API browsers |
65
+ | `.action-menu-popover-open` | Menu is open, browsers without the Popover API (Safari 16 and older). Style it in a **separate rule** from `:popover-open`: a selector list containing `:popover-open` is dropped whole where it's unsupported |
66
+ | `.action-menu-trigger[aria-expanded="true"]` | Menu is open, every browser |
67
+ | `.action-menu-popover[data-placement="top"]` | Flipped above the trigger, browsers without CSS anchor positioning only |
64
68
 
65
69
  Inner classes: `.action-menu-trigger`, `.action-menu-trigger-icon`, `.action-menu-popover`,
66
70
  `.action-menu-list`, `.action-menu-list-item`; on each item `.action-menu-item`,
@@ -76,8 +80,17 @@ Inner classes: `.action-menu-trigger`, `.action-menu-trigger-icon`, `.action-men
76
80
 
77
81
  The menu uses the Popover API plus CSS anchor positioning: it opens below the trigger,
78
82
  right-aligned, and flips above near the bottom of the viewport (`position-try-fallbacks:
79
- flip-block`). Browsers without anchor positioning still open the popover, but not anchored to the
80
- trigger.
83
+ flip-block`).
84
+
85
+ Fallbacks, via the shared `useAnchoredPopover` composable:
86
+
87
+ - **No anchor positioning** (e.g. Safari 17–18): the popover is placed with `position: fixed` from
88
+ the trigger's measured position, re-measured on scroll and resize, and flipped above when there's
89
+ no room below.
90
+ - **No Popover API** (Safari 16 and older): the menu also opens and closes in JS, with
91
+ outside-click and Escape dismissal. It isn't in the top layer in this mode, so
92
+ `--action-menu-popover-z-index` matters, and an ancestor with `transform`, `filter` or `contain`
93
+ becomes its containing block and can misplace it.
81
94
 
82
95
  ---
83
96
 
@@ -500,4 +500,32 @@ describe("ActionMenu", () => {
500
500
  expect(host.findAll(".action-menu-list-item")).toHaveLength(2);
501
501
  });
502
502
  });
503
+
504
+ // -------------------------
505
+ // Without the Popover API (Safari 16 and older)
506
+ // -------------------------
507
+ describe("Without the Popover API", () => {
508
+ beforeEach(() => {
509
+ Object.defineProperty(HTMLElement.prototype, "showPopover", { value: undefined, writable: true, configurable: true });
510
+ });
511
+
512
+ it("opens from the trigger with the fallback open class and aria-expanded", async () => {
513
+ wrapper = await createWrapper({}, { "item-0": '<button role="menuitem">Edit</button>' });
514
+ const popover = wrapper.find(".action-menu-popover");
515
+ expect(popover.classes()).not.toContain("action-menu-popover-open");
516
+
517
+ await wrapper.find(".action-menu-trigger").trigger("click");
518
+ expect(popover.classes()).toContain("action-menu-popover-open");
519
+ expect(wrapper.find(".action-menu-trigger").attributes("aria-expanded")).toBe("true");
520
+ });
521
+
522
+ it("closes when an item is clicked", async () => {
523
+ wrapper = await createWrapper({}, { "item-0": '<button role="menuitem">Edit</button>' });
524
+ await wrapper.find(".action-menu-trigger").trigger("click");
525
+ await wrapper.find(".action-menu-list-item").trigger("click");
526
+
527
+ expect(wrapper.find(".action-menu-popover").classes()).not.toContain("action-menu-popover-open");
528
+ expect(wrapper.find(".action-menu-trigger").attributes("aria-expanded")).toBe("false");
529
+ });
530
+ });
503
531
  });
@@ -1,7 +1,7 @@
1
1
  // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
2
 
3
3
  exports[`ActionMenu > Snapshots > default (no items) 1`] = `
4
- "<div class="action-menu" style="--_anchor-name: --action-menu-anchor-v-0-0;"><button popovertarget="action-menu-v-0-0" popovertargetaction="toggle" type="button" class="action-menu-trigger" aria-label="Open actions menu" aria-haspopup="menu"><span class="iconify i-lucide:ellipsis action-menu-trigger-icon" aria-hidden="true"></span></button>
4
+ "<div class="action-menu" style="--_anchor-name: --action-menu-anchor-v-0-0;"><button popovertarget="action-menu-v-0-0" popovertargetaction="toggle" type="button" class="action-menu-trigger" aria-label="Open actions menu" aria-haspopup="menu" aria-expanded="false"><span class="iconify i-lucide:ellipsis action-menu-trigger-icon" aria-hidden="true"></span></button>
5
5
  <div id="action-menu-v-0-0" popover="" class="action-menu-popover">
6
6
  <ul class="action-menu-list" role="menu" aria-label="Open actions menu"></ul>
7
7
  </div>
@@ -9,7 +9,7 @@ exports[`ActionMenu > Snapshots > default (no items) 1`] = `
9
9
  `;
10
10
 
11
11
  exports[`ActionMenu > Snapshots > with custom label 1`] = `
12
- "<div class="action-menu" style="--_anchor-name: --action-menu-anchor-v-0-0;"><button popovertarget="action-menu-v-0-0" popovertargetaction="toggle" type="button" class="action-menu-trigger" aria-label="Record actions" aria-haspopup="menu"><span class="iconify i-lucide:ellipsis action-menu-trigger-icon" aria-hidden="true"></span></button>
12
+ "<div class="action-menu" style="--_anchor-name: --action-menu-anchor-v-0-0;"><button popovertarget="action-menu-v-0-0" popovertargetaction="toggle" type="button" class="action-menu-trigger" aria-label="Record actions" aria-haspopup="menu" aria-expanded="false"><span class="iconify i-lucide:ellipsis action-menu-trigger-icon" aria-hidden="true"></span></button>
13
13
  <div id="action-menu-v-0-0" popover="" class="action-menu-popover">
14
14
  <ul class="action-menu-list" role="menu" aria-label="Record actions"></ul>
15
15
  </div>
@@ -17,7 +17,7 @@ exports[`ActionMenu > Snapshots > with custom label 1`] = `
17
17
  `;
18
18
 
19
19
  exports[`ActionMenu > Snapshots > with slot content 1`] = `
20
- "<div class="action-menu" style="--_anchor-name: --action-menu-anchor-v-0-0;"><button popovertarget="action-menu-v-0-0" popovertargetaction="toggle" type="button" class="action-menu-trigger" aria-label="Open actions menu" aria-haspopup="menu"><span class="iconify i-lucide:ellipsis action-menu-trigger-icon" aria-hidden="true"></span></button>
20
+ "<div class="action-menu" style="--_anchor-name: --action-menu-anchor-v-0-0;"><button popovertarget="action-menu-v-0-0" popovertargetaction="toggle" type="button" class="action-menu-trigger" aria-label="Open actions menu" aria-haspopup="menu" aria-expanded="false"><span class="iconify i-lucide:ellipsis action-menu-trigger-icon" aria-hidden="true"></span></button>
21
21
  <div id="action-menu-v-0-0" popover="" class="action-menu-popover">
22
22
  <ul class="action-menu-list" role="menu" aria-label="Open actions menu">
23
23
  <li class="action-menu-list-item" role="none"><button role="menuitem">Edit</button></li>
@@ -7,7 +7,7 @@
7
7
  <template v-if="$slots.triggerContent" #triggerContent>
8
8
  <slot name="triggerContent"></slot>
9
9
  </template>
10
- <template #tooltipContent>
10
+ <template #tooltipContent="{ close }">
11
11
  <div class="popover-content-defined">
12
12
  <component
13
13
  :is="contentText.tooltipTitle?.tag"
@@ -31,6 +31,7 @@
31
31
  popovertargetaction="hide"
32
32
  class="display-tooltip-close-button"
33
33
  :aria-label="closeButtonAriaLabel"
34
+ @click="close"
34
35
  >
35
36
  {{ closeButtonText }}
36
37
  </button>
@@ -67,7 +67,7 @@ export const GuidedTour: Story = {
67
67
  docs: {
68
68
  description: {
69
69
  story:
70
- "Recreates the deleted `app/pages/ui/tooltips.vue` demo: `useTooltipsGuide` walks a container's `[popover]` elements in DOM order, opening each and waiting for its close button before advancing — the autoplay-through-steps pattern seen on some guide intros. Mixes a `DisplayTooltipDefined` step with a plain `DisplayTooltip` step (the guide works with either, since it only needs native `[popover]`/`popovertarget` markup). Guide auto-starts 800ms after mount in this story; use \"Run guide again\" to replay it.",
70
+ "Recreates the deleted `app/pages/ui/tooltips.vue` demo: `useTooltipsGuide` walks a container's `[popover]` elements in DOM order, opening each and waiting for its close button before advancing — the autoplay-through-steps pattern seen on some guide intros. Mixes a `DisplayTooltipDefined` step (its close button relabelled \"Next\" via `closeButtonText`) with a plain `DisplayTooltip` step. A hand-written close button must call the `tooltipContent` slot's `close()` as well as carrying `popovertargetaction=\"hide\"`: the attribute alone does nothing on Safari 16, which has no Popover API. Guide auto-starts 800ms after mount in this story; use \"Run guide again\" to replay it.",
71
71
  },
72
72
  },
73
73
  },
@@ -89,12 +89,14 @@ export const GuidedTour: Story = {
89
89
  {{ isGuideRunning ? 'Guide running…' : 'Run guide again' }}
90
90
  </button>
91
91
  <span v-if="isGuideRunning" style="font-size: 1.3rem; color: #666;">
92
- Step {{ currentTooltipIndex + 1 }} of {{ totalPopovers }} — click each popover's Close button to advance
92
+ Step {{ currentTooltipIndex + 1 }} of {{ totalPopovers }} — click Next (Close on the last step) to advance
93
93
  </span>
94
94
  </div>
95
95
 
96
96
  <div ref="guideContainer" style="display: grid; gap: 2rem;">
97
97
  <DisplayTooltipDefined
98
+ close-button-text="Next"
99
+ close-button-aria-label="Next step"
98
100
  :content-text="{
99
101
  tooltipTitle: { tag: 'h3', text: 'Step 1' },
100
102
  tooltipContent: { tag: 'p', text: 'This step uses DisplayTooltipDefined — structured title/body/action content plus a built-in close button.' },
@@ -106,14 +108,15 @@ export const GuidedTour: Story = {
106
108
 
107
109
  <DisplayTooltip tooltip-id="guided-tour-step-2">
108
110
  <template #triggerContent>Trigger from plain DisplayTooltip</template>
109
- <template #tooltipContent>
110
- <p style="margin: 0;">This step uses plain DisplayTooltip — you supply the close button yourself via the tooltipContent slot.</p>
111
+ <template #tooltipContent="{ close }">
112
+ <p>This step uses plain DisplayTooltip — you supply the close button yourself via the tooltipContent slot.</p>
111
113
  <button
112
114
  popovertarget="guided-tour-step-2"
113
115
  popovertargetaction="hide"
114
116
  class="display-tooltip-close-button"
115
117
  style="align-self: flex-end;"
116
118
  aria-label="Close tool tip"
119
+ @click="close"
117
120
  >
118
121
  Close
119
122
  </button>
@@ -1,4 +1,4 @@
1
- import { describe, it, expect } from "vitest";
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
2
  import { mountSuspended } from "@nuxt/test-utils/runtime";
3
3
  import DisplayTooltipDefined from "../DisplayTooltipDefined.vue";
4
4
  import type { TooltipContentText } from "~/types/components";
@@ -63,4 +63,23 @@ describe("DisplayTooltipDefined", () => {
63
63
  });
64
64
  expect(wrapper.classes()).toContain("custom-class");
65
65
  });
66
+
67
+ describe("without the Popover API", () => {
68
+ beforeEach(() => {
69
+ Object.defineProperty(HTMLElement.prototype, "showPopover", { value: undefined, writable: true, configurable: true });
70
+ });
71
+
72
+ afterEach(() => {
73
+ delete (HTMLElement.prototype as unknown as Record<string, unknown>)["showPopover"];
74
+ });
75
+
76
+ it("closes from the close button", async () => {
77
+ const wrapper = await mountSuspended(DisplayTooltipDefined, { props: { contentText } });
78
+ await wrapper.find(".display-tooltip-trigger-button").trigger("click");
79
+ expect(wrapper.find(".display-tooltip-popover").classes()).toContain("display-tooltip-popover-open");
80
+
81
+ await wrapper.find(".display-tooltip-close-button").trigger("click");
82
+ expect(wrapper.find(".display-tooltip-popover").classes()).not.toContain("display-tooltip-popover-open");
83
+ });
84
+ });
66
85
  });
@@ -3,7 +3,7 @@
3
3
  exports[`DisplayTooltipDefined > renders correct HTML structure 1`] = `
4
4
  "<div class="display-tooltip-core" style="--_anchor-name: --tooltip-anchor-v-0-0-0;">
5
5
  <div class="display-tooltip-trigger-wrapper body-md">
6
- <!--v-if--><button popovertarget="nuxt-tooltip-fixed" popovertargetaction="toggle" class="display-tooltip-trigger-button" aria-label="Toggle the popover"><span class="iconify i-fa7-solid:circle-question display-tooltip-trigger-icon" aria-hidden="true"></span></button>
6
+ <!--v-if--><button popovertarget="nuxt-tooltip-fixed" 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>
7
7
  </div>
8
8
  <div id="nuxt-tooltip-fixed" popover="" class="display-tooltip-popover">
9
9
  <div class="display-tooltip-popover-content">
@@ -20,6 +20,7 @@
20
20
  | `--deep-expanding-menu-panel-list-gap` | `1.2rem` | Gap between child link grid items |
21
21
  | `--deep-expanding-menu-group-link-colour` | `var(--slate-10)` | Child link text colour |
22
22
  | `--deep-expanding-menu-group-link-border-colour-hover` | `var(--slate-10)` | Child link bottom border colour on hover/focus |
23
+ | `--deep-expanding-menu-panel-z-index` | `999999` | Panel stacking order in browsers without CSS anchor positioning. Ignored where the panel renders in the top layer |
23
24
 
24
25
  ```css
25
26
  .my-page {
@@ -39,14 +40,28 @@ Or scope to a single instance via `styleClassPassthrough`:
39
40
 
40
41
  ## Not tokenised
41
42
 
42
- - The `@position-try` fallback offsets (`--anchor-left`/`--anchor-right`) are fixed at `1rem` —
43
- these are CSS anchor-positioning fallback rules, not runtime custom properties, so they can't be
44
- overridden per-instance without a new `@position-try` block.
43
+ - The `1rem` gap between a toggle and its panel.
45
44
  - Anchor names and popover target ids are generated per-instance via `useId()` and are not
46
45
  consumer-configurable — they only need to be unique, not meaningful.
47
46
 
48
47
  ---
49
48
 
49
+ ## Open state and older browsers
50
+
51
+ | Hook | Meaning |
52
+ |---|---|
53
+ | `.navigation-group-toggle[aria-expanded="true"]` | Group open, every browser (flips the caret) |
54
+ | `.navigation-group-panel:popover-open` | Panel open, Popover API browsers |
55
+ | `.deep-expanding-menu-panel-open` | Panel open, browsers without the Popover API (Safari 16 and older). Style it in a **separate rule** from `:popover-open`: a selector list containing `:popover-open` is dropped whole where it's unsupported |
56
+ | `.navigation-group-panel[data-placement="top"]` | Flipped above the toggle, browsers without CSS anchor positioning only |
57
+
58
+ Without CSS anchor positioning (e.g. Safari 17–18) the panel is placed with `position: fixed` from
59
+ the toggle's measured position. Without the Popover API (Safari 16) the panel also opens and closes
60
+ in JS and isn't in the top layer, so `--deep-expanding-menu-panel-z-index` applies, and an ancestor
61
+ with `transform`, `filter` or `contain` becomes its containing block and can misplace it.
62
+
63
+ ---
64
+
50
65
  ## Local overrides
51
66
 
52
67
  Set the tokens above on an element you own (a page or section class, or a class added with
@@ -54,4 +69,3 @@ Set the tokens above on an element you own (a page or section class, or a class
54
69
  `@layer` wrapper) so it beats the library's `@layer components`. If your own file uses
55
70
  `<style scoped>`, tokens set on your element still work, but selectors that reach inside the component need
56
71
  `:deep()`. Patterns and examples: `.claude/skills/component-local-style-override.md`.
57
-