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
@@ -782,7 +782,7 @@ watch(
782
782
  .main-navigation-link {
783
783
  display: flex;
784
784
  gap: 6px;
785
- text-wrap-mode: nowrap;
785
+ white-space: nowrap;
786
786
  font-size: var(--responsive-header-link-font-size, inherit);
787
787
  color: var(--responsive-header-link-color, inherit);
788
788
  text-decoration: none;
@@ -821,7 +821,7 @@ watch(
821
821
  .has-toggle-icon {
822
822
  display: flex;
823
823
  gap: 6px;
824
- text-wrap-mode: nowrap;
824
+ white-space: nowrap;
825
825
 
826
826
  /* Reserved for the same reason as .decorator-icon above — an
827
827
  unsized chevron would widen the summary once its SVG loads. */
@@ -883,7 +883,7 @@ watch(
883
883
 
884
884
  .main-navigation-sub-nav-link {
885
885
  display: block;
886
- text-wrap-mode: nowrap;
886
+ white-space: nowrap;
887
887
  text-decoration: none;
888
888
  color: var(--responsive-header-link-color, inherit);
889
889
  }
@@ -994,7 +994,7 @@ watch(
994
994
  align-items: center;
995
995
  justify-content: center;
996
996
  padding-inline: 5px;
997
- text-wrap: nowrap;
997
+ white-space: nowrap;
998
998
 
999
999
  aspect-ratio: 1;
1000
1000
  border-radius: 4px;
@@ -186,10 +186,9 @@ onMounted(() => {
186
186
  position: relative;
187
187
 
188
188
  .option-group {
189
- aspect-ratio: 1;
190
189
  display: grid;
191
190
  grid-template-areas: "icon-stack";
192
- place-content: center;
191
+ place-items: center;
193
192
  background: transparent;
194
193
  border: var(--form-element-border-width) solid var(--triple-toggle-switch-option-border, #00000025);
195
194
  outline: var(--form-element-outline-width) solid transparent;
@@ -214,10 +213,18 @@ onMounted(() => {
214
213
  grid-area: icon-stack;
215
214
  display: block;
216
215
  font-size: var(--_scheme-icon-font-size);
216
+ width: 1em;
217
+ height: 1em;
217
218
  color: var(--triple-toggle-switch-option-icon-color, var(--slate-10));
219
+ transition: color calc(v-bind(stepAnimationDuration) / 2) ease-in-out;
218
220
 
219
221
  &.active {
220
222
  color: var(--triple-toggle-switch-option-icon-color-active, var(--slate-00));
223
+ transition-delay: calc(v-bind(stepAnimationDuration) / 2);
224
+ }
225
+
226
+ @media (prefers-reduced-motion: reduce) {
227
+ transition: none;
221
228
  }
222
229
 
223
230
  &:hover {
@@ -227,9 +234,10 @@ onMounted(() => {
227
234
 
228
235
  .option-input {
229
236
  grid-area: icon-stack;
237
+ appearance: none;
230
238
  opacity: 0;
231
- aspect-ratio: 1;
232
239
  width: var(--_scheme-icon-font-size);
240
+ height: var(--_scheme-icon-font-size);
233
241
 
234
242
  &:hover {
235
243
  cursor: pointer;
@@ -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.4",
4
+ "version": "9.5.6",
5
5
  "main": "nuxt.config.ts",
6
6
  "types": "types.d.ts",
7
7
  "license": "MIT",