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.
- package/.claude/skills/components/action-menu.md +21 -15
- package/.claude/skills/components/deep-expanding-menu.md +18 -6
- package/.claude/skills/components/display-tooltip-defined.md +2 -1
- package/.claude/skills/components/display-tooltip.md +10 -4
- package/.claude/skills/components/pop-over.md +11 -8
- package/.claude/skills/components/select-menu.md +28 -8
- package/.claude/skills/components/triple-toggle-switch.md +7 -1
- package/.claude/skills/components/ui-block-decorated.md +7 -0
- package/.claude/skills/composable-anchored-popover.md +108 -0
- package/.claude/skills/composable-tooltips-guide.md +3 -1
- package/.claude/skills/index.md +1 -0
- package/app/assets/styles/setup/06.utility-classes/animations/_animation-scroller-x.css +5 -3
- package/app/assets/styles/setup/06.utility-classes/animations/_auto-rotate.css +5 -3
- package/app/assets/styles/setup/06.utility-classes/animations/_entry-exit-blur.css +5 -3
- package/app/assets/styles/setup/06.utility-classes/animations/_entry-slide-in.css +5 -3
- package/app/assets/styles/setup/06.utility-classes/animations/_entry-zoom-reveal.css +5 -3
- package/app/components/01.atoms/animations/entry/CONSUMER-STYLING.md +6 -2
- package/app/components/01.atoms/display-tooltip/CONSUMER-STYLING.md +15 -3
- package/app/components/01.atoms/display-tooltip/DisplayTooltip.vue +62 -3
- package/app/components/01.atoms/display-tooltip/tests/DisplayTooltip.spec.ts +36 -1
- package/app/components/01.atoms/display-tooltip/tests/__snapshots__/DisplayTooltip.spec.ts.snap +1 -1
- package/app/components/01.atoms/navigation/skip-links/SkipLinks.vue +1 -1
- package/app/components/01.atoms/pop-over/CONSUMER-STYLING.md +9 -3
- package/app/components/01.atoms/pop-over/PopOver.vue +70 -9
- package/app/components/01.atoms/pop-over/tests/PopOver.spec.ts +53 -0
- package/app/components/01.atoms/pop-over/tests/__snapshots__/PopOver.spec.ts.snap +1 -1
- package/app/components/01.atoms/ui-block-decorated/CONSUMER-STYLING.md +12 -4
- package/app/components/01.atoms/ui-block-decorated/UiBlockDecorated.vue +20 -16
- package/app/components/01.atoms/ui-block-decorated/stories/UiBlockDecorated.stories.ts +124 -96
- package/app/components/02.molecules/action-menu/ActionMenu.vue +41 -16
- package/app/components/02.molecules/action-menu/CONSUMER-STYLING.md +16 -3
- package/app/components/02.molecules/action-menu/tests/ActionMenu.spec.ts +28 -0
- package/app/components/02.molecules/action-menu/tests/__snapshots__/ActionMenu.spec.ts.snap +3 -3
- package/app/components/02.molecules/display-tooltip-defined/DisplayTooltipDefined.vue +2 -1
- package/app/components/02.molecules/display-tooltip-defined/stories/DisplayTooltipDefined.stories.ts +7 -4
- package/app/components/02.molecules/display-tooltip-defined/tests/DisplayTooltipDefined.spec.ts +20 -1
- package/app/components/02.molecules/display-tooltip-defined/tests/__snapshots__/DisplayTooltipDefined.spec.ts.snap +1 -1
- package/app/components/02.molecules/navigation/deep-expanding-menu/CONSUMER-STYLING.md +18 -4
- package/app/components/02.molecules/navigation/deep-expanding-menu/DeepExpandingMenu.vue +87 -16
- package/app/components/02.molecules/navigation/deep-expanding-menu/tests/DeepExpandingMenu.spec.ts +83 -1
- package/app/components/02.molecules/navigation/deep-expanding-menu/tests/__snapshots__/DeepExpandingMenu.spec.ts.snap +2 -2
- package/app/components/02.molecules/navigation/site-navigation/SiteNavigation.vue +1 -1
- package/app/components/02.molecules/navigation/tab-navigation/TabNavigation.vue +1 -1
- package/app/components/02.molecules/select-menu/CONSUMER-STYLING.md +26 -6
- package/app/components/02.molecules/select-menu/SelectMenu.vue +90 -18
- package/app/components/02.molecules/select-menu/tests/SelectMenu.spec.ts +36 -0
- package/app/components/02.molecules/select-menu/tests/__snapshots__/SelectMenu.spec.ts.snap +9 -9
- package/app/components/03.organisms/responsive-header/NavigationItems.vue +2 -2
- package/app/components/03.organisms/responsive-header/ResponsiveHeader.vue +4 -4
- package/app/components/05.forms/triple-toggle-switch/TripleToggleSwitch.vue +11 -3
- package/app/composables/tests/useAnchoredPopover.spec.ts +231 -0
- package/app/composables/tests/useTooltips.spec.ts +69 -0
- package/app/composables/useAnchoredPopover.ts +183 -0
- package/app/composables/useTooltips.ts +37 -20
- package/package.json +1 -1
|
@@ -782,7 +782,7 @@ watch(
|
|
|
782
782
|
.main-navigation-link {
|
|
783
783
|
display: flex;
|
|
784
784
|
gap: 6px;
|
|
785
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
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
|
-
|
|
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
|
-
|
|
67
|
-
|
|
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(
|
|
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(
|
|
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
|