srcdev-nuxt-components 9.5.5 → 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 (52) 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 +18 -8
  7. package/.claude/skills/components/ui-block-decorated.md +7 -0
  8. package/.claude/skills/composable-anchored-popover.md +108 -0
  9. package/.claude/skills/composable-tooltips-guide.md +3 -1
  10. package/.claude/skills/index.md +1 -0
  11. package/app/assets/styles/setup/06.utility-classes/animations/_animation-scroller-x.css +5 -3
  12. package/app/assets/styles/setup/06.utility-classes/animations/_auto-rotate.css +5 -3
  13. package/app/assets/styles/setup/06.utility-classes/animations/_entry-exit-blur.css +5 -3
  14. package/app/assets/styles/setup/06.utility-classes/animations/_entry-slide-in.css +5 -3
  15. package/app/assets/styles/setup/06.utility-classes/animations/_entry-zoom-reveal.css +5 -3
  16. package/app/components/01.atoms/animations/entry/CONSUMER-STYLING.md +6 -2
  17. package/app/components/01.atoms/display-tooltip/CONSUMER-STYLING.md +15 -3
  18. package/app/components/01.atoms/display-tooltip/DisplayTooltip.vue +62 -3
  19. package/app/components/01.atoms/display-tooltip/tests/DisplayTooltip.spec.ts +36 -1
  20. package/app/components/01.atoms/display-tooltip/tests/__snapshots__/DisplayTooltip.spec.ts.snap +1 -1
  21. package/app/components/01.atoms/navigation/skip-links/SkipLinks.vue +1 -1
  22. package/app/components/01.atoms/pop-over/CONSUMER-STYLING.md +9 -3
  23. package/app/components/01.atoms/pop-over/PopOver.vue +70 -9
  24. package/app/components/01.atoms/pop-over/tests/PopOver.spec.ts +53 -0
  25. package/app/components/01.atoms/pop-over/tests/__snapshots__/PopOver.spec.ts.snap +1 -1
  26. package/app/components/01.atoms/ui-block-decorated/CONSUMER-STYLING.md +12 -4
  27. package/app/components/01.atoms/ui-block-decorated/UiBlockDecorated.vue +20 -16
  28. package/app/components/01.atoms/ui-block-decorated/stories/UiBlockDecorated.stories.ts +124 -96
  29. package/app/components/02.molecules/action-menu/ActionMenu.vue +41 -16
  30. package/app/components/02.molecules/action-menu/CONSUMER-STYLING.md +16 -3
  31. package/app/components/02.molecules/action-menu/tests/ActionMenu.spec.ts +28 -0
  32. package/app/components/02.molecules/action-menu/tests/__snapshots__/ActionMenu.spec.ts.snap +3 -3
  33. package/app/components/02.molecules/display-tooltip-defined/DisplayTooltipDefined.vue +2 -1
  34. package/app/components/02.molecules/display-tooltip-defined/stories/DisplayTooltipDefined.stories.ts +7 -4
  35. package/app/components/02.molecules/display-tooltip-defined/tests/DisplayTooltipDefined.spec.ts +20 -1
  36. package/app/components/02.molecules/display-tooltip-defined/tests/__snapshots__/DisplayTooltipDefined.spec.ts.snap +1 -1
  37. package/app/components/02.molecules/navigation/deep-expanding-menu/CONSUMER-STYLING.md +18 -4
  38. package/app/components/02.molecules/navigation/deep-expanding-menu/DeepExpandingMenu.vue +87 -16
  39. package/app/components/02.molecules/navigation/deep-expanding-menu/tests/DeepExpandingMenu.spec.ts +83 -1
  40. package/app/components/02.molecules/navigation/deep-expanding-menu/tests/__snapshots__/DeepExpandingMenu.spec.ts.snap +2 -2
  41. package/app/components/02.molecules/navigation/site-navigation/SiteNavigation.vue +1 -1
  42. package/app/components/02.molecules/navigation/tab-navigation/TabNavigation.vue +1 -1
  43. package/app/components/02.molecules/select-menu/CONSUMER-STYLING.md +17 -5
  44. package/app/components/02.molecules/select-menu/SelectMenu.vue +51 -16
  45. package/app/components/02.molecules/select-menu/tests/SelectMenu.spec.ts +36 -0
  46. package/app/components/03.organisms/responsive-header/NavigationItems.vue +2 -2
  47. package/app/components/03.organisms/responsive-header/ResponsiveHeader.vue +4 -4
  48. package/app/composables/tests/useAnchoredPopover.spec.ts +231 -0
  49. package/app/composables/tests/useTooltips.spec.ts +69 -0
  50. package/app/composables/useAnchoredPopover.ts +183 -0
  51. package/app/composables/useTooltips.ts +37 -20
  52. package/package.json +1 -1
@@ -0,0 +1,231 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { mountSuspended } from "@nuxt/test-utils/runtime";
3
+ import { defineComponent, h, nextTick, ref } from "vue";
4
+ import { useAnchoredPopover, type AnchoredPopoverSide } from "../useAnchoredPopover";
5
+
6
+ type Support = { popover: boolean; anchor: boolean };
7
+
8
+ const setSupport = ({ popover, anchor }: Support) => {
9
+ Object.defineProperty(HTMLElement.prototype, "showPopover", {
10
+ value: popover ? vi.fn() : undefined,
11
+ writable: true,
12
+ configurable: true,
13
+ });
14
+ Object.defineProperty(HTMLElement.prototype, "hidePopover", {
15
+ value: popover ? vi.fn() : undefined,
16
+ writable: true,
17
+ configurable: true,
18
+ });
19
+ // happy-dom's CSS.supports can't be spied on, so swap the whole global and restore it in afterEach.
20
+ Object.defineProperty(globalThis, "CSS", { value: { supports: () => anchor }, writable: true, configurable: true });
21
+ };
22
+
23
+ const originalCSS = globalThis.CSS;
24
+
25
+ const setViewport = (width: number, height: number) => {
26
+ Object.defineProperty(document.documentElement, "clientWidth", { value: width, configurable: true });
27
+ Object.defineProperty(document.documentElement, "clientHeight", { value: height, configurable: true });
28
+ };
29
+
30
+ const mountHost = (side: AnchoredPopoverSide = "bottom") => {
31
+ const onOpen = vi.fn();
32
+ const mounted = mountSuspended(
33
+ defineComponent({
34
+ setup() {
35
+ const rootRef = ref<HTMLElement | null>(null);
36
+ const triggerRef = ref<HTMLElement | null>(null);
37
+ const popoverRef = ref<HTMLElement | null>(null);
38
+ return { rootRef, triggerRef, popoverRef, ...useAnchoredPopover({ rootRef, triggerRef, popoverRef, side, onOpen }) };
39
+ },
40
+ render() {
41
+ return h("div", [
42
+ h("div", { ref: "rootRef", class: "root" }, [
43
+ h("button", { ref: "triggerRef", class: "trigger", onClick: this.handleTriggerClick }),
44
+ h("div", { ref: "popoverRef", class: "popover" }),
45
+ ]),
46
+ h("button", { class: "outside" }),
47
+ ]);
48
+ },
49
+ }),
50
+ { attachTo: document.body }
51
+ );
52
+ return { mounted, onOpen };
53
+ };
54
+
55
+ const mockTriggerRect = (wrapper: Awaited<ReturnType<typeof mountHost>["mounted"]>, rect: Partial<DOMRect>) => {
56
+ const trigger = wrapper.find(".trigger").element as HTMLElement;
57
+ vi.spyOn(trigger, "getBoundingClientRect").mockReturnValue({ top: 0, bottom: 0, left: 0, right: 0, ...rect } as DOMRect);
58
+ };
59
+
60
+ const mockPopoverSize = (wrapper: Awaited<ReturnType<typeof mountHost>["mounted"]>, width: number, height: number) => {
61
+ const popover = wrapper.find(".popover").element;
62
+ Object.defineProperty(popover, "offsetWidth", { value: width, configurable: true });
63
+ Object.defineProperty(popover, "offsetHeight", { value: height, configurable: true });
64
+ };
65
+
66
+ describe("useAnchoredPopover", () => {
67
+ beforeEach(() => {
68
+ setViewport(1000, 800);
69
+ });
70
+
71
+ afterEach(() => {
72
+ vi.restoreAllMocks();
73
+ delete (HTMLElement.prototype as unknown as Record<string, unknown>)["showPopover"];
74
+ delete (HTMLElement.prototype as unknown as Record<string, unknown>)["hidePopover"];
75
+ delete (document.documentElement as unknown as Record<string, unknown>)["clientWidth"];
76
+ delete (document.documentElement as unknown as Record<string, unknown>)["clientHeight"];
77
+ Object.defineProperty(globalThis, "CSS", { value: originalCSS, writable: true, configurable: true });
78
+ document.body.innerHTML = "";
79
+ });
80
+
81
+ describe("native Popover API and anchor positioning", () => {
82
+ beforeEach(() => setSupport({ popover: true, anchor: true }));
83
+
84
+ it("uses neither fallback", async () => {
85
+ const wrapper = await mountHost().mounted;
86
+ expect(wrapper.vm.usesFallbackPopover).toBe(false);
87
+ expect(wrapper.vm.needsPositioning).toBe(false);
88
+ expect(wrapper.vm.positionStyle).toBeUndefined();
89
+ expect(wrapper.vm.popoverPlacement).toBeUndefined();
90
+ });
91
+
92
+ it("show and hide call the native methods", async () => {
93
+ const wrapper = await mountHost().mounted;
94
+ const popover = wrapper.find(".popover").element as HTMLElement;
95
+ wrapper.vm.show();
96
+ wrapper.vm.hide();
97
+ expect(popover.showPopover).toHaveBeenCalledOnce();
98
+ expect(popover.hidePopover).toHaveBeenCalledOnce();
99
+ });
100
+
101
+ it("trigger click is left to popovertarget", async () => {
102
+ const wrapper = await mountHost().mounted;
103
+ await wrapper.find(".trigger").trigger("click");
104
+ expect(wrapper.vm.isOpen).toBe(false);
105
+ });
106
+
107
+ it("tracks isOpen and calls onOpen from the toggle event", async () => {
108
+ const { mounted, onOpen } = mountHost();
109
+ const wrapper = await mounted;
110
+ wrapper.vm.handleToggle(Object.assign(new Event("toggle"), { newState: "open" }));
111
+ expect(wrapper.vm.isOpen).toBe(true);
112
+ expect(onOpen).toHaveBeenCalledOnce();
113
+
114
+ wrapper.vm.handleToggle(Object.assign(new Event("toggle"), { newState: "closed" }));
115
+ expect(wrapper.vm.isOpen).toBe(false);
116
+ });
117
+ });
118
+
119
+ describe("without the Popover API", () => {
120
+ beforeEach(() => setSupport({ popover: false, anchor: true }));
121
+
122
+ it("detects the fallback on mount", async () => {
123
+ const wrapper = await mountHost().mounted;
124
+ expect(wrapper.vm.usesFallbackPopover).toBe(true);
125
+ });
126
+
127
+ it("toggles open and closed from the trigger, calling onOpen after render", async () => {
128
+ const { mounted, onOpen } = mountHost();
129
+ const wrapper = await mounted;
130
+
131
+ await wrapper.find(".trigger").trigger("click");
132
+ expect(wrapper.vm.isOpen).toBe(true);
133
+ await nextTick();
134
+ expect(onOpen).toHaveBeenCalledOnce();
135
+
136
+ await wrapper.find(".trigger").trigger("click");
137
+ expect(wrapper.vm.isOpen).toBe(false);
138
+ });
139
+
140
+ it("closes on a pointerdown outside the root, not inside it", async () => {
141
+ const wrapper = await mountHost().mounted;
142
+ wrapper.vm.show();
143
+ await nextTick();
144
+
145
+ wrapper.find(".popover").element.dispatchEvent(new Event("pointerdown", { bubbles: true }));
146
+ expect(wrapper.vm.isOpen).toBe(true);
147
+
148
+ wrapper.find(".outside").element.dispatchEvent(new Event("pointerdown", { bubbles: true }));
149
+ expect(wrapper.vm.isOpen).toBe(false);
150
+ });
151
+
152
+ it("closes on Escape and returns focus to the trigger", async () => {
153
+ const wrapper = await mountHost().mounted;
154
+ const focusSpy = vi.spyOn(wrapper.find(".trigger").element as HTMLElement, "focus");
155
+ wrapper.vm.show();
156
+ await nextTick();
157
+
158
+ document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
159
+ expect(wrapper.vm.isOpen).toBe(false);
160
+ expect(focusSpy).toHaveBeenCalledOnce();
161
+ });
162
+
163
+ it("stops listening once closed", async () => {
164
+ const wrapper = await mountHost().mounted;
165
+ const focusSpy = vi.spyOn(wrapper.find(".trigger").element as HTMLElement, "focus");
166
+ wrapper.vm.show();
167
+ await nextTick();
168
+ wrapper.vm.hide();
169
+
170
+ document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
171
+ expect(focusSpy).not.toHaveBeenCalled();
172
+ });
173
+ });
174
+
175
+ describe("without anchor positioning", () => {
176
+ beforeEach(() => setSupport({ popover: true, anchor: false }));
177
+
178
+ const openWith = async (side: AnchoredPopoverSide, rect: Partial<DOMRect>, size = { width: 100, height: 200 }) => {
179
+ const wrapper = await mountHost(side).mounted;
180
+ mockTriggerRect(wrapper, rect);
181
+ mockPopoverSize(wrapper, size.width, size.height);
182
+ wrapper.vm.handleToggle(Object.assign(new Event("toggle"), { newState: "open" }));
183
+ return wrapper;
184
+ };
185
+
186
+ it("writes every trigger edge, plain and measured from the viewport's far edge", async () => {
187
+ const wrapper = await mountHost().mounted;
188
+ mockTriggerRect(wrapper, { top: 100, bottom: 140, left: 50, right: 250 });
189
+
190
+ wrapper.vm.handleBeforeToggle(Object.assign(new Event("beforetoggle"), { newState: "open" }));
191
+ expect(wrapper.vm.needsPositioning).toBe(true);
192
+ expect(wrapper.vm.positionStyle).toEqual({
193
+ "--_anchor-top": "100px",
194
+ "--_anchor-bottom": "140px",
195
+ "--_anchor-left": "50px",
196
+ "--_anchor-right": "250px",
197
+ "--_anchor-top-inverse": "700px",
198
+ "--_anchor-bottom-inverse": "660px",
199
+ "--_anchor-left-inverse": "950px",
200
+ "--_anchor-right-inverse": "750px",
201
+ });
202
+ });
203
+
204
+ it("ignores beforetoggle when closing", async () => {
205
+ const wrapper = await mountHost().mounted;
206
+ mockTriggerRect(wrapper, { top: 100, bottom: 140, left: 50, right: 250 });
207
+
208
+ wrapper.vm.handleBeforeToggle(Object.assign(new Event("beforetoggle"), { newState: "closed" }));
209
+ expect(wrapper.vm.positionStyle).toMatchObject({ "--_anchor-top": "0px" });
210
+ });
211
+
212
+ it.each([
213
+ ["bottom", { top: 100, bottom: 140, left: 400, right: 450 }, "bottom"],
214
+ ["bottom", { top: 700, bottom: 740, left: 400, right: 450 }, "top"],
215
+ ["top", { top: 300, bottom: 340, left: 400, right: 450 }, "top"],
216
+ ["top", { top: 100, bottom: 140, left: 400, right: 450 }, "bottom"],
217
+ ["right", { top: 300, bottom: 340, left: 400, right: 450 }, "right"],
218
+ ["right", { top: 300, bottom: 340, left: 850, right: 950 }, "left"],
219
+ ["left", { top: 300, bottom: 340, left: 400, right: 450 }, "left"],
220
+ ["left", { top: 300, bottom: 340, left: 50, right: 100 }, "right"],
221
+ ] as const)("prefers %s and resolves to %s for %o", async (side, rect, expected) => {
222
+ const wrapper = await openWith(side, rect);
223
+ expect(wrapper.vm.popoverPlacement).toBe(expected);
224
+ });
225
+
226
+ it("keeps the preferred side when neither side has room", async () => {
227
+ const wrapper = await openWith("bottom", { top: 100, bottom: 140, left: 50, right: 250 }, { width: 100, height: 900 });
228
+ expect(wrapper.vm.popoverPlacement).toBe("bottom");
229
+ });
230
+ });
231
+ });
@@ -0,0 +1,69 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { mountSuspended } from "@nuxt/test-utils/runtime";
3
+ import { defineComponent, h, nextTick, ref } from "vue";
4
+ import { useTooltipsGuide } from "../useTooltips";
5
+ import DisplayTooltipDefined from "~/components/02.molecules/display-tooltip-defined/DisplayTooltipDefined.vue";
6
+
7
+ const flush = async () => {
8
+ for (let i = 0; i < 5; i++) await nextTick();
9
+ };
10
+
11
+ const mountGuide = () =>
12
+ mountSuspended(
13
+ defineComponent({
14
+ setup() {
15
+ const containerRef = ref<HTMLElement | null>(null);
16
+ const guide = useTooltipsGuide(containerRef, { autoStart: false, startDelay: 0 });
17
+ return { containerRef, guide };
18
+ },
19
+ render() {
20
+ return h("div", { ref: "containerRef" }, [
21
+ h(DisplayTooltipDefined, { tooltipId: "one", contentText: { tooltipTitle: { tag: "h4", text: "One" } } }),
22
+ h(DisplayTooltipDefined, { tooltipId: "two", contentText: { tooltipTitle: { tag: "h4", text: "Two" } } }),
23
+ ]);
24
+ },
25
+ }),
26
+ { attachTo: document.body }
27
+ );
28
+
29
+ describe("useTooltipsGuide without the Popover API", () => {
30
+ beforeEach(() => {
31
+ Object.defineProperty(HTMLElement.prototype, "showPopover", { value: undefined, writable: true, configurable: true });
32
+ });
33
+
34
+ afterEach(() => {
35
+ delete (HTMLElement.prototype as unknown as Record<string, unknown>)["showPopover"];
36
+ document.body.innerHTML = "";
37
+ });
38
+
39
+ it("steps through each tooltip as its close button is clicked", async () => {
40
+ const wrapper = await mountGuide();
41
+ const vm = wrapper.vm as unknown as { guide: ReturnType<typeof useTooltipsGuide> };
42
+ const popovers = () => wrapper.findAll(".display-tooltip-popover");
43
+
44
+ vm.guide.initializePopovers();
45
+ void vm.guide.startGuide();
46
+ await flush();
47
+ expect(popovers()[0]!.classes()).toContain("display-tooltip-popover-open");
48
+
49
+ await wrapper.findAll(".display-tooltip-close-button")[0]!.trigger("click");
50
+ await flush();
51
+ expect(popovers()[0]!.classes()).not.toContain("display-tooltip-popover-open");
52
+ expect(popovers()[1]!.classes()).toContain("display-tooltip-popover-open");
53
+ expect(vm.guide.currentTooltipIndex.value).toBe(1);
54
+ });
55
+
56
+ it("stopGuide closes the open tooltip without calling Popover API methods", async () => {
57
+ const wrapper = await mountGuide();
58
+ const vm = wrapper.vm as unknown as { guide: ReturnType<typeof useTooltipsGuide> };
59
+
60
+ vm.guide.initializePopovers();
61
+ void vm.guide.startGuide();
62
+ await flush();
63
+
64
+ expect(() => vm.guide.stopGuide()).not.toThrow();
65
+ await flush();
66
+ expect(wrapper.find(".display-tooltip-popover-open").exists()).toBe(false);
67
+ expect(vm.guide.isGuideRunning.value).toBe(false);
68
+ });
69
+ });
@@ -0,0 +1,183 @@
1
+ import type { MaybeRefOrGetter, Ref } from "vue";
2
+
3
+ interface AnchoredPopoverOptions {
4
+ rootRef: Readonly<Ref<HTMLElement | null>>;
5
+ triggerRef: Readonly<Ref<HTMLElement | null>>;
6
+ popoverRef: Readonly<Ref<HTMLElement | null>>;
7
+ side?: MaybeRefOrGetter<AnchoredPopoverSide>;
8
+ onOpen?: () => void;
9
+ }
10
+
11
+ export type AnchoredPopoverSide = "top" | "right" | "bottom" | "left";
12
+
13
+ type PopoverToggleEvent = Event & { newState?: string };
14
+
15
+ const OPPOSITE_SIDE: Record<AnchoredPopoverSide, AnchoredPopoverSide> = {
16
+ top: "bottom",
17
+ bottom: "top",
18
+ left: "right",
19
+ right: "left",
20
+ };
21
+
22
+ // Native popover + anchor positioning where supported; JS positioning without anchor positioning
23
+ // (Safari 17-18); JS open/close, outside click and Escape without the Popover API (Safari 16).
24
+ export function useAnchoredPopover({ rootRef, triggerRef, popoverRef, side = "bottom", onOpen }: AnchoredPopoverOptions) {
25
+ const isOpen = ref(false);
26
+ const usesFallbackPopover = ref(false);
27
+ const needsPositioning = ref(false);
28
+ const placement = ref<AnchoredPopoverSide>(toValue(side));
29
+ const anchor = ref({ top: 0, bottom: 0, left: 0, right: 0, viewportWidth: 0, viewportHeight: 0 });
30
+
31
+ // `--_anchor-{edge}` mirrors anchor({edge}) for top/left; `-inverse` is the same edge measured
32
+ // from the viewport's bottom/right, for bottom/right.
33
+ const positionStyle = computed(() => {
34
+ if (!needsPositioning.value) return undefined;
35
+ const { top, bottom, left, right, viewportWidth, viewportHeight } = anchor.value;
36
+ return {
37
+ "--_anchor-top": `${top}px`,
38
+ "--_anchor-bottom": `${bottom}px`,
39
+ "--_anchor-left": `${left}px`,
40
+ "--_anchor-right": `${right}px`,
41
+ "--_anchor-top-inverse": `${viewportHeight - top}px`,
42
+ "--_anchor-bottom-inverse": `${viewportHeight - bottom}px`,
43
+ "--_anchor-left-inverse": `${viewportWidth - left}px`,
44
+ "--_anchor-right-inverse": `${viewportWidth - right}px`,
45
+ };
46
+ });
47
+
48
+ const popoverPlacement = computed(() => (needsPositioning.value ? placement.value : undefined));
49
+
50
+ const updatePosition = () => {
51
+ const trigger = triggerRef.value;
52
+ if (!trigger) return;
53
+ const rect = trigger.getBoundingClientRect();
54
+ const viewportWidth = document.documentElement.clientWidth;
55
+ const viewportHeight = document.documentElement.clientHeight;
56
+ const width = popoverRef.value?.offsetWidth ?? 0;
57
+ const height = popoverRef.value?.offsetHeight ?? 0;
58
+
59
+ const fits: Record<AnchoredPopoverSide, boolean> = {
60
+ bottom: rect.bottom + height <= viewportHeight,
61
+ top: rect.top - height >= 0,
62
+ right: rect.right + width <= viewportWidth,
63
+ left: rect.left - width >= 0,
64
+ };
65
+
66
+ anchor.value = { top: rect.top, bottom: rect.bottom, left: rect.left, right: rect.right, viewportWidth, viewportHeight };
67
+ const preferred = toValue(side);
68
+ placement.value = fits[preferred] || !fits[OPPOSITE_SIDE[preferred]] ? preferred : OPPOSITE_SIDE[preferred];
69
+ };
70
+
71
+ let frame = 0;
72
+ const schedulePositionUpdate = () => {
73
+ if (frame) return;
74
+ frame = requestAnimationFrame(() => {
75
+ frame = 0;
76
+ updatePosition();
77
+ });
78
+ };
79
+
80
+ const handleDocumentPointerdown = (event: PointerEvent) => {
81
+ if (!rootRef.value?.contains(event.target as Node)) hide();
82
+ };
83
+
84
+ const handleDocumentKeydown = (event: KeyboardEvent) => {
85
+ if (event.key !== "Escape") return;
86
+ hide();
87
+ triggerRef.value?.focus();
88
+ };
89
+
90
+ const addListeners = () => {
91
+ if (needsPositioning.value) {
92
+ window.addEventListener("scroll", schedulePositionUpdate, { capture: true, passive: true });
93
+ window.addEventListener("resize", schedulePositionUpdate, { passive: true });
94
+ }
95
+ if (usesFallbackPopover.value) {
96
+ document.addEventListener("pointerdown", handleDocumentPointerdown);
97
+ document.addEventListener("keydown", handleDocumentKeydown);
98
+ }
99
+ };
100
+
101
+ const removeListeners = () => {
102
+ window.removeEventListener("scroll", schedulePositionUpdate, { capture: true });
103
+ window.removeEventListener("resize", schedulePositionUpdate);
104
+ document.removeEventListener("pointerdown", handleDocumentPointerdown);
105
+ document.removeEventListener("keydown", handleDocumentKeydown);
106
+ if (frame) cancelAnimationFrame(frame);
107
+ frame = 0;
108
+ };
109
+
110
+ const afterOpen = () => {
111
+ if (needsPositioning.value) updatePosition();
112
+ addListeners();
113
+ };
114
+
115
+ const show = () => {
116
+ if (!usesFallbackPopover.value) {
117
+ popoverRef.value?.showPopover();
118
+ return;
119
+ }
120
+ if (isOpen.value) return;
121
+ if (needsPositioning.value) updatePosition();
122
+ isOpen.value = true;
123
+ nextTick(() => {
124
+ afterOpen();
125
+ onOpen?.();
126
+ });
127
+ };
128
+
129
+ const hide = () => {
130
+ if (!usesFallbackPopover.value) {
131
+ popoverRef.value?.hidePopover();
132
+ return;
133
+ }
134
+ if (!isOpen.value) return;
135
+ // Matches native popovers, which return focus to the invoker when focus was inside.
136
+ if (popoverRef.value?.contains(document.activeElement)) triggerRef.value?.focus();
137
+ isOpen.value = false;
138
+ removeListeners();
139
+ };
140
+
141
+ /** Bound to the trigger's click. A no-op where `popovertarget` already toggles natively. */
142
+ const handleTriggerClick = () => {
143
+ if (!usesFallbackPopover.value) return;
144
+ if (isOpen.value) hide();
145
+ else show();
146
+ };
147
+
148
+ /** Bound to the popover's `beforetoggle`, so the first open frame is already in place. */
149
+ const handleBeforeToggle = (event: Event) => {
150
+ if (needsPositioning.value && (event as PopoverToggleEvent).newState === "open") updatePosition();
151
+ };
152
+
153
+ /** Bound to the popover's `toggle`. */
154
+ const handleToggle = (event: Event) => {
155
+ isOpen.value = (event as PopoverToggleEvent).newState === "open";
156
+ if (isOpen.value) {
157
+ afterOpen();
158
+ onOpen?.();
159
+ } else {
160
+ removeListeners();
161
+ }
162
+ };
163
+
164
+ onMounted(() => {
165
+ usesFallbackPopover.value = typeof HTMLElement.prototype.showPopover !== "function";
166
+ needsPositioning.value = typeof CSS !== "undefined" && typeof CSS.supports === "function" && !CSS.supports("anchor-name: --a");
167
+ });
168
+
169
+ onBeforeUnmount(removeListeners);
170
+
171
+ return {
172
+ isOpen,
173
+ usesFallbackPopover,
174
+ needsPositioning,
175
+ positionStyle,
176
+ popoverPlacement,
177
+ show,
178
+ hide,
179
+ handleTriggerClick,
180
+ handleBeforeToggle,
181
+ handleToggle,
182
+ };
183
+ }
@@ -32,23 +32,40 @@ export const useTooltipsGuide = (
32
32
  })
33
33
  }
34
34
 
35
+ const findTrigger = (popover: HTMLElement) =>
36
+ document.querySelector<HTMLElement>(`[popovertarget="${popover.id}"][popovertargetaction="toggle"]`)
37
+
38
+ // Without the Popover API (Safari 16) there's no togglePopover() and :popover-open throws, so
39
+ // tooltips are driven through their trigger, whose aria-expanded tracks the open state everywhere.
40
+ const closePopover = (popover: HTMLElement) => {
41
+ const trigger = findTrigger(popover)
42
+ if (trigger?.getAttribute("aria-expanded") === "true") {
43
+ trigger.click()
44
+ return
45
+ }
46
+ if (typeof popover.hidePopover !== "function") return
47
+ try {
48
+ if (popover.matches(":popover-open")) popover.hidePopover()
49
+ } catch {
50
+ // :popover-open unsupported
51
+ }
52
+ }
53
+
35
54
  /**
36
55
  * Show a tooltip and wait for user dismissal
37
56
  */
38
57
  const showTooltipAndWaitForDismiss = (popover: HTMLElement): Promise<void> => {
39
58
  return new Promise((resolve) => {
40
- // Find the trigger button that corresponds to this popover
41
- const popoverId = popover.id
42
- const triggerButton = document.querySelector<HTMLElement>(
43
- `[popovertarget="${popoverId}"][popovertargetaction="toggle"]`
44
- )
59
+ const triggerButton = findTrigger(popover)
45
60
 
46
61
  // Use the trigger button to show the popover to maintain proper anchor relationship
47
62
  if (triggerButton) {
48
63
  triggerButton.click()
49
- } else {
50
- // Fallback if no trigger button found
64
+ } else if (typeof popover.togglePopover === "function") {
51
65
  popover.togglePopover(true)
66
+ } else {
67
+ resolve()
68
+ return
52
69
  }
53
70
 
54
71
  // Find the close button within this popover
@@ -62,10 +79,18 @@ export const useTooltipsGuide = (
62
79
  }
63
80
 
64
81
  closeButton.addEventListener("click", handleClose)
82
+ } else if (triggerButton) {
83
+ // No close button: resolve once the trigger reports the tooltip closed
84
+ const observer = new MutationObserver(() => {
85
+ if (triggerButton.getAttribute("aria-expanded") === "false") {
86
+ observer.disconnect()
87
+ resolve()
88
+ }
89
+ })
90
+ observer.observe(triggerButton, { attributes: true, attributeFilter: ["aria-expanded"] })
65
91
  } else {
66
- // Fallback: listen for the popover to be hidden
67
- const handleToggle = () => {
68
- if (!popover.matches(":popover-open")) {
92
+ const handleToggle = (event: Event) => {
93
+ if ((event as Event & { newState?: string }).newState === "closed") {
69
94
  popover.removeEventListener("toggle", handleToggle)
70
95
  resolve()
71
96
  }
@@ -105,11 +130,7 @@ export const useTooltipsGuide = (
105
130
  if (isGuideRunning.value) return
106
131
 
107
132
  // Close any currently open popovers
108
- popovers.forEach((popover) => {
109
- if (popover.matches(":popover-open")) {
110
- popover.togglePopover(false)
111
- }
112
- })
133
+ popovers.forEach(closePopover)
113
134
 
114
135
  // Reset state and start the guide
115
136
  autoRunGuide.value = true
@@ -123,11 +144,7 @@ export const useTooltipsGuide = (
123
144
  if (!isGuideRunning.value) return
124
145
 
125
146
  // Close any currently open popovers
126
- popovers.forEach((popover) => {
127
- if (popover.matches(":popover-open")) {
128
- popover.togglePopover(false)
129
- }
130
- })
147
+ popovers.forEach(closePopover)
131
148
 
132
149
  isGuideRunning.value = false
133
150
  autoRunGuide.value = false
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "srcdev-nuxt-components",
3
3
  "type": "module",
4
- "version": "9.5.5",
4
+ "version": "9.5.6",
5
5
  "main": "nuxt.config.ts",
6
6
  "types": "types.d.ts",
7
7
  "license": "MIT",