@sit-onyx/headless 1.0.0-beta.0 → 1.0.0-beta.2
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/package.json +1 -1
- package/src/composables/comboBox/TestCombobox.ct.tsx +1 -1
- package/src/composables/comboBox/createComboBox.ts +3 -3
- package/src/composables/helpers/useGlobalListener.spec.ts +93 -0
- package/src/composables/helpers/useGlobalListener.ts +64 -0
- package/src/composables/helpers/useOutsideClick.ts +34 -0
- package/src/composables/{typeAhead.spec.ts → helpers/useTypeAhead.spec.ts} +1 -1
- package/src/composables/{typeAhead.ts → helpers/useTypeAhead.ts} +2 -2
- package/src/composables/listbox/TestListbox.ct.tsx +1 -1
- package/src/composables/listbox/createListbox.ts +1 -1
- package/src/composables/menuButton/TestMenuButton.ct.tsx +2 -2
- package/src/composables/menuButton/TestMenuButton.vue +7 -6
- package/src/composables/menuButton/{createMenuButton.ct.ts → createMenuButton.testing.ts} +17 -16
- package/src/composables/menuButton/createMenuButton.ts +117 -99
- package/src/composables/navigationMenu/TestMenu.ct.tsx +12 -0
- package/src/composables/navigationMenu/TestMenu.vue +16 -0
- package/src/composables/navigationMenu/createMenu.testing.ts +48 -0
- package/src/composables/navigationMenu/createMenu.ts +56 -0
- package/src/composables/tooltip/createTooltip.ts +6 -2
- package/src/index.ts +2 -0
- package/src/playwright.ts +3 -3
- package/src/utils/builder.ts +8 -6
- package/src/utils/math.spec.ts +14 -0
- package/src/utils/math.ts +6 -0
- package/src/utils/vitest.ts +36 -0
- package/src/composables/outsideClick.ts +0 -52
- /package/src/composables/comboBox/{createComboBox.ct.ts → createComboBox.testing.ts} +0 -0
- /package/src/composables/listbox/{createListbox.ct.ts → createListbox.testing.ts} +0 -0
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { test } from "@playwright/experimental-ct-vue";
|
|
2
|
-
import { comboboxSelectOnlyTesting, comboboxTesting } from "./createComboBox.
|
|
2
|
+
import { comboboxSelectOnlyTesting, comboboxTesting } from "./createComboBox.testing";
|
|
3
3
|
import SelectOnlyCombobox from "./SelectOnlyCombobox.vue";
|
|
4
4
|
import TestCombobox from "./TestCombobox.vue";
|
|
5
5
|
|
|
@@ -2,13 +2,13 @@ import { computed, unref, type MaybeRef, type Ref } from "vue";
|
|
|
2
2
|
import { createBuilder } from "../../utils/builder";
|
|
3
3
|
import { createId } from "../../utils/id";
|
|
4
4
|
import { isPrintableCharacter, wasKeyPressed, type PressedKey } from "../../utils/keyboard";
|
|
5
|
+
import { useOutsideClick } from "../helpers/useOutsideClick";
|
|
6
|
+
import { useTypeAhead } from "../helpers/useTypeAhead";
|
|
5
7
|
import {
|
|
6
8
|
createListbox,
|
|
7
9
|
type CreateListboxOptions,
|
|
8
10
|
type ListboxValue,
|
|
9
11
|
} from "../listbox/createListbox";
|
|
10
|
-
import { useOutsideClick } from "../outsideClick";
|
|
11
|
-
import { useTypeAhead } from "../typeAhead";
|
|
12
12
|
|
|
13
13
|
export type ComboboxAutoComplete = "none" | "list" | "both";
|
|
14
14
|
|
|
@@ -225,7 +225,7 @@ export const createComboBox = createBuilder(
|
|
|
225
225
|
});
|
|
226
226
|
|
|
227
227
|
useOutsideClick({
|
|
228
|
-
|
|
228
|
+
element: templateRef,
|
|
229
229
|
onOutsideClick() {
|
|
230
230
|
if (!isExpanded.value) return;
|
|
231
231
|
onToggle?.(true);
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { ref, type Ref } from "vue";
|
|
3
|
+
import { mockVueLifecycle } from "../../utils/vitest";
|
|
4
|
+
import { useGlobalEventListener } from "./useGlobalListener";
|
|
5
|
+
|
|
6
|
+
let unmount: () => Promise<void> | undefined;
|
|
7
|
+
|
|
8
|
+
describe("useGlobalEventListener", () => {
|
|
9
|
+
let target: Ref<HTMLButtonElement>;
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
vi.clearAllMocks();
|
|
13
|
+
unmount = mockVueLifecycle();
|
|
14
|
+
target = ref(document.createElement("button"));
|
|
15
|
+
document.body.appendChild(target.value);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("should be defined", () => {
|
|
19
|
+
expect(useGlobalEventListener).toBeDefined();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("should listen to global events", () => {
|
|
23
|
+
// ARRANGE
|
|
24
|
+
const listener = vi.fn();
|
|
25
|
+
useGlobalEventListener({ type: "click", listener });
|
|
26
|
+
// ACT
|
|
27
|
+
const event = new MouseEvent("click", { bubbles: true });
|
|
28
|
+
target.value.dispatchEvent(event);
|
|
29
|
+
// ASSERT
|
|
30
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
31
|
+
expect(listener).toBeCalledWith(event);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("should stop to listen to global events after unmount", async () => {
|
|
35
|
+
// ARRANGE
|
|
36
|
+
const listener = vi.fn();
|
|
37
|
+
useGlobalEventListener({ type: "click", listener });
|
|
38
|
+
// ACT
|
|
39
|
+
await unmount();
|
|
40
|
+
expect(listener).toHaveBeenCalledTimes(0);
|
|
41
|
+
target.value.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
42
|
+
// ASSERT
|
|
43
|
+
expect(listener).toHaveBeenCalledTimes(0);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("should allow for multiple of the same listener types", async () => {
|
|
47
|
+
// ARRANGE
|
|
48
|
+
vi.useFakeTimers();
|
|
49
|
+
const listener = vi.fn();
|
|
50
|
+
const disabled = ref(false);
|
|
51
|
+
const listener2 = vi.fn();
|
|
52
|
+
useGlobalEventListener({ type: "click", listener, disabled });
|
|
53
|
+
useGlobalEventListener({ type: "click", listener: listener2 });
|
|
54
|
+
// ACT
|
|
55
|
+
target.value.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
56
|
+
// ASSERT
|
|
57
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
58
|
+
expect(listener2).toHaveBeenCalledTimes(1);
|
|
59
|
+
// ACT
|
|
60
|
+
disabled.value = true;
|
|
61
|
+
await vi.runAllTimersAsync();
|
|
62
|
+
// ACT
|
|
63
|
+
target.value.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
64
|
+
// ASSERT
|
|
65
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
66
|
+
expect(listener2).toHaveBeenCalledTimes(2);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("should not listen to events when disabled", async () => {
|
|
70
|
+
// ARRANGE
|
|
71
|
+
vi.useFakeTimers();
|
|
72
|
+
const disabled = ref(false);
|
|
73
|
+
const listener = vi.fn();
|
|
74
|
+
useGlobalEventListener({ type: "click", listener, disabled });
|
|
75
|
+
// ACT
|
|
76
|
+
await vi.runAllTimersAsync();
|
|
77
|
+
target.value.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
78
|
+
// ASSERT
|
|
79
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
80
|
+
// ACT
|
|
81
|
+
disabled.value = true;
|
|
82
|
+
await vi.runAllTimersAsync();
|
|
83
|
+
target.value.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
84
|
+
// ASSERT
|
|
85
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
86
|
+
// ACT
|
|
87
|
+
disabled.value = false;
|
|
88
|
+
await vi.runAllTimersAsync();
|
|
89
|
+
target.value.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
90
|
+
// ASSERT
|
|
91
|
+
expect(listener).toHaveBeenCalledTimes(2);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { onBeforeMount, onBeforeUnmount, reactive, watchEffect, type Ref } from "vue";
|
|
2
|
+
|
|
3
|
+
type DocumentEventType = keyof DocumentEventMap;
|
|
4
|
+
type GlobalListener<K extends DocumentEventType = DocumentEventType> = (
|
|
5
|
+
event: DocumentEventMap[K],
|
|
6
|
+
) => void;
|
|
7
|
+
|
|
8
|
+
export type UseGlobalEventListenerOptions<K extends DocumentEventType> = {
|
|
9
|
+
type: K;
|
|
10
|
+
listener: GlobalListener<K>;
|
|
11
|
+
disabled?: Ref<boolean>;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const GLOBAL_LISTENERS = reactive(new Map<DocumentEventType, Set<GlobalListener>>());
|
|
15
|
+
|
|
16
|
+
const updateRemainingListeners = (type: DocumentEventType, remaining?: Set<GlobalListener>) => {
|
|
17
|
+
if (remaining?.size) {
|
|
18
|
+
GLOBAL_LISTENERS.set(type, remaining);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
GLOBAL_LISTENERS.delete(type);
|
|
22
|
+
document.removeEventListener(type, GLOBAL_HANDLER);
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const removeGlobalListener = <K extends DocumentEventType>(
|
|
26
|
+
type: K,
|
|
27
|
+
listener: GlobalListener<K>,
|
|
28
|
+
) => {
|
|
29
|
+
const globalListener = GLOBAL_LISTENERS.get(type);
|
|
30
|
+
globalListener?.delete(listener as GlobalListener);
|
|
31
|
+
|
|
32
|
+
updateRemainingListeners(type, globalListener);
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const addGlobalListener = <K extends DocumentEventType>(type: K, listener: GlobalListener<K>) => {
|
|
36
|
+
const globalListener = GLOBAL_LISTENERS.get(type) ?? new Set();
|
|
37
|
+
globalListener.add(listener as GlobalListener);
|
|
38
|
+
GLOBAL_LISTENERS.set(type, globalListener);
|
|
39
|
+
|
|
40
|
+
document.addEventListener(type, GLOBAL_HANDLER);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A single and unique function for all event types.
|
|
45
|
+
* We use the fact that `addEventListener` and `removeEventListener` are idempotent when called with the same function reference.
|
|
46
|
+
*/
|
|
47
|
+
const GLOBAL_HANDLER = (event: Event) => {
|
|
48
|
+
const type = event.type as DocumentEventType;
|
|
49
|
+
GLOBAL_LISTENERS.get(type)?.forEach((cb) => cb(event));
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export const useGlobalEventListener = <K extends DocumentEventType>({
|
|
53
|
+
type,
|
|
54
|
+
listener,
|
|
55
|
+
disabled,
|
|
56
|
+
}: UseGlobalEventListenerOptions<K>) => {
|
|
57
|
+
onBeforeMount(() =>
|
|
58
|
+
watchEffect(() =>
|
|
59
|
+
disabled?.value ? removeGlobalListener(type, listener) : addGlobalListener(type, listener),
|
|
60
|
+
),
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
onBeforeUnmount(() => removeGlobalListener(type, listener));
|
|
64
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { type Ref } from "vue";
|
|
2
|
+
import { useGlobalEventListener } from "./useGlobalListener";
|
|
3
|
+
|
|
4
|
+
export type UseOutsideClickOptions = {
|
|
5
|
+
/**
|
|
6
|
+
* HTML element of the component where clicks should be ignored
|
|
7
|
+
*/
|
|
8
|
+
element: Ref<HTMLElement | undefined>;
|
|
9
|
+
/**
|
|
10
|
+
* Callback when an outside click occurred.
|
|
11
|
+
*/
|
|
12
|
+
onOutsideClick: () => void;
|
|
13
|
+
/**
|
|
14
|
+
* If `true`, event listeners will be removed and no outside clicks will be captured.
|
|
15
|
+
*/
|
|
16
|
+
disabled?: Ref<boolean>;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Composable for listening to click events that occur outside of a component.
|
|
21
|
+
* Useful to e.g. close flyouts or tooltips.
|
|
22
|
+
*/
|
|
23
|
+
export const useOutsideClick = ({ element, onOutsideClick, disabled }: UseOutsideClickOptions) => {
|
|
24
|
+
/**
|
|
25
|
+
* Document click handle that closes then tooltip when clicked outside.
|
|
26
|
+
* Should only be called when trigger is "click".
|
|
27
|
+
*/
|
|
28
|
+
const listener = ({ target }: MouseEvent) => {
|
|
29
|
+
const isOutsideClick = !element.value?.contains(target as HTMLElement);
|
|
30
|
+
if (isOutsideClick) onOutsideClick();
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
useGlobalEventListener({ type: "click", listener, disabled });
|
|
34
|
+
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { isPrintableCharacter } from "
|
|
2
|
-
import { debounce } from "
|
|
1
|
+
import { isPrintableCharacter } from "../../utils/keyboard";
|
|
2
|
+
import { debounce } from "../../utils/timer";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Enhances typeAhead to combine multiple inputs in quick succession and filter out non-printable characters.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { test } from "@playwright/experimental-ct-vue";
|
|
2
2
|
import TestListbox from "./TestListbox.vue";
|
|
3
|
-
import { listboxTesting } from "./createListbox.
|
|
3
|
+
import { listboxTesting } from "./createListbox.testing";
|
|
4
4
|
|
|
5
5
|
test("listbox", async ({ mount, page }) => {
|
|
6
6
|
await mount(<TestListbox />);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { computed, ref, unref, watchEffect, type MaybeRef, type Ref } from "vue";
|
|
2
2
|
import { createId } from "../..";
|
|
3
3
|
import { createBuilder, type HeadlessElementAttributes } from "../../utils/builder";
|
|
4
|
-
import { useTypeAhead } from "../
|
|
4
|
+
import { useTypeAhead } from "../helpers/useTypeAhead";
|
|
5
5
|
|
|
6
6
|
export type ListboxValue = string | number | boolean;
|
|
7
7
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { test } from "@playwright/experimental-ct-vue";
|
|
2
|
-
import { menuButtonTesting } from "./createMenuButton.
|
|
2
|
+
import { menuButtonTesting } from "./createMenuButton.testing";
|
|
3
3
|
import TestMenuButton from "./TestMenuButton.vue";
|
|
4
4
|
|
|
5
5
|
test("menuButton", async ({ mount, page }) => {
|
|
@@ -9,6 +9,6 @@ test("menuButton", async ({ mount, page }) => {
|
|
|
9
9
|
page,
|
|
10
10
|
button: page.getByRole("button"),
|
|
11
11
|
menu: page.locator("ul"),
|
|
12
|
-
menuItems:
|
|
12
|
+
menuItems: page.getByRole("menuitem"),
|
|
13
13
|
});
|
|
14
14
|
});
|
|
@@ -8,18 +8,19 @@ const items = Array.from({ length: 10 }, (_, index) => {
|
|
|
8
8
|
});
|
|
9
9
|
|
|
10
10
|
const activeItem = ref<string>();
|
|
11
|
+
const isExpanded = ref(false);
|
|
12
|
+
const onToggle = () => (isExpanded.value = !isExpanded.value);
|
|
11
13
|
|
|
12
14
|
const {
|
|
13
|
-
elements: { button, menu, menuItem, listItem
|
|
14
|
-
|
|
15
|
-
} = createMenuButton({});
|
|
15
|
+
elements: { root, button, menu, menuItem, listItem },
|
|
16
|
+
} = createMenuButton({ isExpanded, onToggle });
|
|
16
17
|
</script>
|
|
17
18
|
|
|
18
19
|
<template>
|
|
19
|
-
<
|
|
20
|
-
|
|
20
|
+
<div v-bind="root">
|
|
21
|
+
<button v-bind="button">Toggle nav menu</button>
|
|
21
22
|
<ul v-show="isExpanded" v-bind="menu">
|
|
22
|
-
<li v-for="item in items" v-bind="listItem" :key="item.value"
|
|
23
|
+
<li v-for="item in items" v-bind="listItem" :key="item.value">
|
|
23
24
|
<a v-bind="menuItem({ active: activeItem === item.value })" href="#">{{ item.label }}</a>
|
|
24
25
|
</li>
|
|
25
26
|
</ul>
|
|
@@ -17,7 +17,7 @@ export type MenuButtonTestingOptions = {
|
|
|
17
17
|
/**
|
|
18
18
|
* List items (at least 3).
|
|
19
19
|
*/
|
|
20
|
-
menuItems: Locator
|
|
20
|
+
menuItems: Locator;
|
|
21
21
|
};
|
|
22
22
|
|
|
23
23
|
/**
|
|
@@ -45,25 +45,26 @@ export const menuButtonTesting = async ({
|
|
|
45
45
|
await expect(button).toBeVisible();
|
|
46
46
|
|
|
47
47
|
// ensure correct navigation menu aria attributes
|
|
48
|
-
await expect(
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
await expect(
|
|
56
|
-
button,
|
|
57
|
-
'flyout menu must have an "aria-expanded" attribute set to true',
|
|
58
|
-
).toHaveAttribute("aria-expanded", "true");
|
|
59
|
-
|
|
60
|
-
const firstItem = menuItems[0].getByRole("menuitem");
|
|
61
|
-
const secondItem = menuItems[1].getByRole("menuitem");
|
|
62
|
-
const lastItem = menuItems[menuItems.length - 1].getByRole("menuitem");
|
|
48
|
+
await expect(button, "button must have arial-controls attribute").toHaveAttribute(
|
|
49
|
+
"aria-controls",
|
|
50
|
+
);
|
|
51
|
+
await expect(button, "button must have aria-expanded attribute").toHaveAttribute(
|
|
52
|
+
"aria-expanded",
|
|
53
|
+
"false",
|
|
54
|
+
);
|
|
63
55
|
|
|
64
56
|
await page.keyboard.press("Tab");
|
|
65
57
|
await expect(button, "Button should be focused when pressing tab key").toBeFocused();
|
|
66
58
|
|
|
59
|
+
const firstItem = menuItems.first();
|
|
60
|
+
const secondItem = menuItems.nth(1);
|
|
61
|
+
const lastItem = menuItems.last();
|
|
62
|
+
|
|
63
|
+
await page.keyboard.press("Enter");
|
|
64
|
+
await expect(button, "button must have aria-expanded attribute").toHaveAttribute(
|
|
65
|
+
"aria-expanded",
|
|
66
|
+
"true",
|
|
67
|
+
);
|
|
67
68
|
await button.press("ArrowDown");
|
|
68
69
|
await expect(
|
|
69
70
|
firstItem,
|
|
@@ -1,123 +1,141 @@
|
|
|
1
|
-
import { computed, ref } from "vue";
|
|
1
|
+
import { computed, ref, type Ref } from "vue";
|
|
2
2
|
import { createBuilder } from "../../utils/builder";
|
|
3
3
|
import { createId } from "../../utils/id";
|
|
4
4
|
import { debounce } from "../../utils/timer";
|
|
5
|
+
import { useGlobalEventListener } from "../helpers/useGlobalListener";
|
|
6
|
+
|
|
7
|
+
type CreateMenuButtonOptions = {
|
|
8
|
+
isExpanded: Ref<boolean>;
|
|
9
|
+
onToggle: () => void;
|
|
10
|
+
};
|
|
5
11
|
|
|
6
12
|
/**
|
|
7
13
|
* Based on https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/examples/disclosure-navigation/
|
|
8
14
|
*/
|
|
9
|
-
export const createMenuButton = createBuilder(
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
15
|
+
export const createMenuButton = createBuilder(
|
|
16
|
+
({ isExpanded, onToggle }: CreateMenuButtonOptions) => {
|
|
17
|
+
const rootId = createId("menu-button-root");
|
|
18
|
+
const menuId = createId("menu-button-list");
|
|
19
|
+
const menuRef = ref<HTMLElement>();
|
|
20
|
+
const buttonId = createId("menu-button-button");
|
|
13
21
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
200,
|
|
20
|
-
);
|
|
22
|
+
useGlobalEventListener({
|
|
23
|
+
type: "keydown",
|
|
24
|
+
listener: (e) => e.key === "Escape" && isExpanded.value && onToggle(),
|
|
25
|
+
disabled: computed(() => !isExpanded.value),
|
|
26
|
+
});
|
|
21
27
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
});
|
|
28
|
+
/**
|
|
29
|
+
* Debounced expanded state that will only be toggled after a given timeout.
|
|
30
|
+
*/
|
|
31
|
+
const updateDebouncedExpanded = debounce(
|
|
32
|
+
(expanded: boolean) => isExpanded.value !== expanded && onToggle(),
|
|
33
|
+
200,
|
|
34
|
+
);
|
|
30
35
|
|
|
31
|
-
|
|
32
|
-
|
|
36
|
+
const focusRelativeItem = (next: "next" | "prev" | "first" | "last") => {
|
|
37
|
+
const currentMenuItem = document.activeElement as HTMLElement;
|
|
33
38
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
if (!currentMenu) return;
|
|
39
|
+
// Either the current focus is on a "menuitem", then we can just get the parent menu.
|
|
40
|
+
// Or the current focus is on the button, then we can get the connected menu using the menuId
|
|
41
|
+
const currentMenu = currentMenuItem?.closest('[role="menu"]') || menuRef.value;
|
|
42
|
+
if (!currentMenu) return;
|
|
39
43
|
|
|
40
|
-
|
|
41
|
-
|
|
44
|
+
const menuItems = [...currentMenu.querySelectorAll<HTMLElement>('[role="menuitem"]')];
|
|
45
|
+
let nextIndex = 0;
|
|
42
46
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
47
|
+
if (currentMenuItem) {
|
|
48
|
+
const currentIndex = menuItems.indexOf(currentMenuItem);
|
|
49
|
+
switch (next) {
|
|
50
|
+
case "next":
|
|
51
|
+
nextIndex = currentIndex + 1;
|
|
52
|
+
break;
|
|
53
|
+
case "prev":
|
|
54
|
+
nextIndex = currentIndex - 1;
|
|
55
|
+
break;
|
|
56
|
+
case "first":
|
|
57
|
+
nextIndex = 0;
|
|
58
|
+
break;
|
|
59
|
+
case "last":
|
|
60
|
+
nextIndex = menuItems.length - 1;
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const nextMenuItem = menuItems[nextIndex];
|
|
66
|
+
nextMenuItem?.focus();
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const handleKeydown = (event: KeyboardEvent) => {
|
|
70
|
+
switch (event.key) {
|
|
71
|
+
case "ArrowDown":
|
|
72
|
+
case "ArrowRight":
|
|
73
|
+
event.preventDefault();
|
|
74
|
+
focusRelativeItem("next");
|
|
75
|
+
break;
|
|
76
|
+
case "ArrowUp":
|
|
77
|
+
case "ArrowLeft":
|
|
78
|
+
event.preventDefault();
|
|
79
|
+
focusRelativeItem("prev");
|
|
80
|
+
break;
|
|
81
|
+
case "Home":
|
|
82
|
+
event.preventDefault();
|
|
83
|
+
focusRelativeItem("first");
|
|
48
84
|
break;
|
|
49
|
-
case "
|
|
50
|
-
|
|
85
|
+
case "End":
|
|
86
|
+
event.preventDefault();
|
|
87
|
+
focusRelativeItem("last");
|
|
51
88
|
break;
|
|
52
|
-
case "
|
|
53
|
-
|
|
89
|
+
case " ":
|
|
90
|
+
event.preventDefault();
|
|
91
|
+
(event.target as HTMLElement).click();
|
|
54
92
|
break;
|
|
55
|
-
case "
|
|
56
|
-
|
|
93
|
+
case "Escape":
|
|
94
|
+
event.preventDefault();
|
|
95
|
+
isExpanded.value && onToggle();
|
|
57
96
|
break;
|
|
58
97
|
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
const nextMenuItem = menuItems[nextIndex];
|
|
62
|
-
nextMenuItem?.focus();
|
|
63
|
-
};
|
|
64
|
-
|
|
65
|
-
const handleKeydown = (event: KeyboardEvent) => {
|
|
66
|
-
switch (event.key) {
|
|
67
|
-
case "ArrowDown":
|
|
68
|
-
case "ArrowRight":
|
|
69
|
-
event.preventDefault();
|
|
70
|
-
focusRelativeItem("next");
|
|
71
|
-
break;
|
|
72
|
-
case "ArrowUp":
|
|
73
|
-
case "ArrowLeft":
|
|
74
|
-
event.preventDefault();
|
|
75
|
-
focusRelativeItem("prev");
|
|
76
|
-
break;
|
|
77
|
-
case "Home":
|
|
78
|
-
event.preventDefault();
|
|
79
|
-
focusRelativeItem("first");
|
|
80
|
-
break;
|
|
81
|
-
case "End":
|
|
82
|
-
event.preventDefault();
|
|
83
|
-
focusRelativeItem("last");
|
|
84
|
-
break;
|
|
85
|
-
case " ":
|
|
86
|
-
event.preventDefault();
|
|
87
|
-
(event.target as HTMLElement).click();
|
|
88
|
-
break;
|
|
89
|
-
}
|
|
90
|
-
};
|
|
98
|
+
};
|
|
91
99
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
(
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
100
|
+
return {
|
|
101
|
+
elements: {
|
|
102
|
+
root: {
|
|
103
|
+
id: rootId,
|
|
104
|
+
onKeydown: handleKeydown,
|
|
105
|
+
onMouseover: () => updateDebouncedExpanded(true),
|
|
106
|
+
onMouseout: () => updateDebouncedExpanded(false),
|
|
107
|
+
onFocusout: (event) => {
|
|
108
|
+
// if focus receiving element is not part of the menu button, then close
|
|
109
|
+
if (document.getElementById(rootId)?.contains(event.relatedTarget as HTMLElement)) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
isExpanded.value && onToggle();
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
button: computed(
|
|
116
|
+
() =>
|
|
117
|
+
({
|
|
118
|
+
"aria-controls": menuId,
|
|
119
|
+
"aria-expanded": isExpanded.value,
|
|
120
|
+
"aria-haspopup": true,
|
|
121
|
+
onFocus: () => !isExpanded.value && onToggle(),
|
|
122
|
+
id: buttonId,
|
|
123
|
+
}) as const,
|
|
124
|
+
),
|
|
125
|
+
menu: {
|
|
126
|
+
id: menuId,
|
|
127
|
+
ref: menuRef,
|
|
128
|
+
role: "menu",
|
|
129
|
+
"aria-labelledby": buttonId,
|
|
130
|
+
onClick: () => isExpanded.value && onToggle(),
|
|
131
|
+
},
|
|
132
|
+
...createMenuItems().elements,
|
|
114
133
|
},
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
});
|
|
134
|
+
};
|
|
135
|
+
},
|
|
136
|
+
);
|
|
119
137
|
|
|
120
|
-
export const
|
|
138
|
+
export const createMenuItems = createBuilder(() => {
|
|
121
139
|
return {
|
|
122
140
|
elements: {
|
|
123
141
|
listItem: {
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { test } from "@playwright/experimental-ct-vue";
|
|
2
|
+
import { navigationTesting } from "./createMenu.testing";
|
|
3
|
+
import TestMenu from "./TestMenu.vue";
|
|
4
|
+
|
|
5
|
+
test("navigationMenu", async ({ mount, page }) => {
|
|
6
|
+
await mount(<TestMenu />);
|
|
7
|
+
|
|
8
|
+
await navigationTesting({
|
|
9
|
+
buttons: page.getByRole("button"),
|
|
10
|
+
nav: page.getByRole("navigation"),
|
|
11
|
+
});
|
|
12
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<script lang="ts" setup>
|
|
2
|
+
import TestMenuButton from "../menuButton/TestMenuButton.vue";
|
|
3
|
+
import { createNavigationMenu } from "./createMenu";
|
|
4
|
+
|
|
5
|
+
const {
|
|
6
|
+
elements: { nav },
|
|
7
|
+
} = createNavigationMenu({ navigationName: "test menu" });
|
|
8
|
+
</script>
|
|
9
|
+
|
|
10
|
+
<template>
|
|
11
|
+
<nav v-bind="nav">
|
|
12
|
+
<TestMenuButton />
|
|
13
|
+
<TestMenuButton />
|
|
14
|
+
<TestMenuButton />
|
|
15
|
+
</nav>
|
|
16
|
+
</template>
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { expect } from "@playwright/experimental-ct-vue";
|
|
2
|
+
import type { Locator } from "@playwright/test";
|
|
3
|
+
|
|
4
|
+
export type NavigationMenuTestingOptions = {
|
|
5
|
+
/**
|
|
6
|
+
* Locator for the navigation landmark.
|
|
7
|
+
*/
|
|
8
|
+
nav: Locator;
|
|
9
|
+
/**
|
|
10
|
+
* Locator for the button elements.
|
|
11
|
+
*/
|
|
12
|
+
buttons: Locator;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Playwright utility for executing accessibility testing for a navigation menu.
|
|
17
|
+
* Will check aria attributes and keyboard shortcuts as defined in https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/examples/disclosure-navigation/
|
|
18
|
+
*/
|
|
19
|
+
export const navigationTesting = async ({ nav, buttons }: NavigationMenuTestingOptions) => {
|
|
20
|
+
/**
|
|
21
|
+
* Navigation landmark should have label
|
|
22
|
+
*/
|
|
23
|
+
await expect(nav).toHaveRole("navigation");
|
|
24
|
+
await expect(nav).toHaveAttribute("aria-label");
|
|
25
|
+
/**
|
|
26
|
+
* Disclosure buttons should have aria attributes
|
|
27
|
+
*/
|
|
28
|
+
for (const button of await buttons.all()) {
|
|
29
|
+
await expect(button, "button must have arial-controls attribute").toHaveAttribute(
|
|
30
|
+
"aria-controls",
|
|
31
|
+
);
|
|
32
|
+
await expect(button, "button must have aria-expanded attribute").toHaveAttribute(
|
|
33
|
+
"aria-expanded",
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Focus first button
|
|
38
|
+
*/
|
|
39
|
+
await nav.press("Tab");
|
|
40
|
+
await expect(buttons.nth(0)).toBeFocused();
|
|
41
|
+
/**
|
|
42
|
+
* Move keyboard focus among top-level buttons using arrow keys
|
|
43
|
+
*/
|
|
44
|
+
await nav.press("ArrowRight");
|
|
45
|
+
await expect(buttons.nth(1)).toBeFocused();
|
|
46
|
+
await nav.press("ArrowLeft");
|
|
47
|
+
await expect(buttons.nth(0)).toBeFocused();
|
|
48
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { unref, type MaybeRef } from "vue";
|
|
2
|
+
import { createId } from "../..";
|
|
3
|
+
import { createBuilder } from "../../utils/builder";
|
|
4
|
+
import { MathUtils } from "../../utils/math";
|
|
5
|
+
|
|
6
|
+
type CreateNavigationMenu = {
|
|
7
|
+
/**
|
|
8
|
+
* Name of the navigation landmark.
|
|
9
|
+
* Usually this is the name of the website.
|
|
10
|
+
*/
|
|
11
|
+
navigationName?: MaybeRef<string | undefined>;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Based on https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/examples/disclosure-navigation/
|
|
16
|
+
*/
|
|
17
|
+
export const createNavigationMenu = createBuilder(({ navigationName }: CreateNavigationMenu) => {
|
|
18
|
+
const navId = createId("nav");
|
|
19
|
+
|
|
20
|
+
const getMenuButtons = () => {
|
|
21
|
+
const nav = document.getElementById(navId);
|
|
22
|
+
if (!nav) return [];
|
|
23
|
+
return [...nav.querySelectorAll<HTMLElement>("button[aria-expanded][aria-controls]")];
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const focusRelative = (trigger: HTMLElement, next: "next" | "previous") => {
|
|
27
|
+
const menuButtons = getMenuButtons();
|
|
28
|
+
const index = menuButtons.indexOf(trigger);
|
|
29
|
+
if (index === -1) return;
|
|
30
|
+
const nextIndex = MathUtils.clamp(
|
|
31
|
+
index + (next === "next" ? 1 : -1),
|
|
32
|
+
0,
|
|
33
|
+
menuButtons.length - 1,
|
|
34
|
+
);
|
|
35
|
+
menuButtons[nextIndex].focus();
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
elements: {
|
|
40
|
+
nav: {
|
|
41
|
+
"aria-label": unref(navigationName),
|
|
42
|
+
id: navId,
|
|
43
|
+
onKeydown: (event) => {
|
|
44
|
+
switch (event.key) {
|
|
45
|
+
case "ArrowRight":
|
|
46
|
+
focusRelative(event.target as HTMLElement, "next");
|
|
47
|
+
break;
|
|
48
|
+
case "ArrowLeft":
|
|
49
|
+
focusRelative(event.target as HTMLElement, "previous");
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { computed, onBeforeMount, onBeforeUnmount, ref, unref, type MaybeRef } from "vue";
|
|
2
2
|
import { createId } from "../..";
|
|
3
3
|
import { createBuilder } from "../../utils/builder";
|
|
4
|
-
import { useOutsideClick } from "../
|
|
4
|
+
import { useOutsideClick } from "../helpers/useOutsideClick";
|
|
5
5
|
|
|
6
6
|
export type CreateTooltipOptions = {
|
|
7
7
|
open: MaybeRef<TooltipOpen>;
|
|
@@ -22,6 +22,7 @@ export const TOOLTIP_TRIGGERS = ["hover", "click"] as const;
|
|
|
22
22
|
export type TooltipTrigger = (typeof TOOLTIP_TRIGGERS)[number];
|
|
23
23
|
|
|
24
24
|
export const createTooltip = createBuilder((options: CreateTooltipOptions) => {
|
|
25
|
+
const rootRef = ref<HTMLElement>();
|
|
25
26
|
const tooltipId = createId("tooltip");
|
|
26
27
|
const _isVisible = ref(false);
|
|
27
28
|
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
@@ -87,7 +88,7 @@ export const createTooltip = createBuilder((options: CreateTooltipOptions) => {
|
|
|
87
88
|
|
|
88
89
|
// close tooltip on outside click
|
|
89
90
|
useOutsideClick({
|
|
90
|
-
|
|
91
|
+
element: rootRef,
|
|
91
92
|
onOutsideClick: () => (_isVisible.value = false),
|
|
92
93
|
disabled: computed(() => openType.value !== "click"),
|
|
93
94
|
});
|
|
@@ -106,6 +107,9 @@ export const createTooltip = createBuilder((options: CreateTooltipOptions) => {
|
|
|
106
107
|
|
|
107
108
|
return {
|
|
108
109
|
elements: {
|
|
110
|
+
root: {
|
|
111
|
+
ref: rootRef,
|
|
112
|
+
},
|
|
109
113
|
trigger: computed(() => ({
|
|
110
114
|
"aria-describedby": tooltipId,
|
|
111
115
|
onClick: openType.value === "click" ? handleClick : undefined,
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export * from "./composables/comboBox/createComboBox";
|
|
2
2
|
export * from "./composables/listbox/createListbox";
|
|
3
3
|
export * from "./composables/menuButton/createMenuButton";
|
|
4
|
+
export * from "./composables/navigationMenu/createMenu";
|
|
4
5
|
export * from "./composables/tooltip/createTooltip";
|
|
5
6
|
export { createId } from "./utils/id";
|
|
6
7
|
export { isPrintableCharacter, wasKeyPressed } from "./utils/keyboard";
|
|
8
|
+
export { debounce } from "./utils/timer";
|
package/src/playwright.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export * from "./composables/comboBox/createComboBox.
|
|
2
|
-
export * from "./composables/listbox/createListbox.
|
|
3
|
-
export * from "./composables/menuButton/createMenuButton.
|
|
1
|
+
export * from "./composables/comboBox/createComboBox.testing";
|
|
2
|
+
export * from "./composables/listbox/createListbox.testing";
|
|
3
|
+
export * from "./composables/menuButton/createMenuButton.testing";
|
package/src/utils/builder.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
|
-
import type { ComputedRef, HtmlHTMLAttributes, Ref } from "vue";
|
|
1
|
+
import type { ComputedRef, HtmlHTMLAttributes, Ref, VNodeRef } from "vue";
|
|
2
2
|
import type { IfDefined } from "./types";
|
|
3
3
|
|
|
4
|
+
export type ElementAttributes = HtmlHTMLAttributes & { ref?: VNodeRef };
|
|
5
|
+
|
|
4
6
|
export type IteratedHeadlessElementFunc<T extends Record<string, unknown>> = (
|
|
5
7
|
opts: T,
|
|
6
|
-
) =>
|
|
8
|
+
) => ElementAttributes;
|
|
7
9
|
|
|
8
10
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
9
|
-
export type HeadlessElementAttributes =
|
|
11
|
+
export type HeadlessElementAttributes = ElementAttributes | IteratedHeadlessElementFunc<any>;
|
|
10
12
|
|
|
11
13
|
export type HeadlessElements = Record<
|
|
12
14
|
string,
|
|
@@ -28,10 +30,10 @@ export type HeadlessComposable<
|
|
|
28
30
|
* We use this identity function to ensure the correct typings of the headless composables
|
|
29
31
|
*/
|
|
30
32
|
export const createBuilder = <
|
|
31
|
-
|
|
32
|
-
Elements extends HeadlessElements,
|
|
33
|
+
Args extends unknown[] = unknown[],
|
|
34
|
+
Elements extends HeadlessElements = HeadlessElements,
|
|
33
35
|
State extends HeadlessState | undefined = undefined,
|
|
34
36
|
Internals extends object | undefined = undefined,
|
|
35
37
|
>(
|
|
36
|
-
builder: (
|
|
38
|
+
builder: (...args: Args) => HeadlessComposable<Elements, State, Internals>,
|
|
37
39
|
) => builder;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { MathUtils } from "./math";
|
|
3
|
+
|
|
4
|
+
describe("MathUtils.clamp", () => {
|
|
5
|
+
test.each([
|
|
6
|
+
{ number: 1, min: 1, max: 2, result: 1 },
|
|
7
|
+
{ number: 1, min: 2, max: 2, result: 2 },
|
|
8
|
+
{ number: 1, min: 0, max: 0, result: 0 },
|
|
9
|
+
{ number: 1, min: 1, max: 1, result: 1 },
|
|
10
|
+
])(
|
|
11
|
+
"should return $result for key number:$number min:$min max:$max",
|
|
12
|
+
({ number, min, max, result }) => expect(MathUtils.clamp(number, min, max)).toBe(result),
|
|
13
|
+
);
|
|
14
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { vi, type Awaitable } from "vitest";
|
|
2
|
+
|
|
3
|
+
type Callback = () => Awaitable<void>;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Mocks the following vue lifecycle functions:
|
|
7
|
+
* - onBeforeMount
|
|
8
|
+
* - onMounted
|
|
9
|
+
* - onBeforeUnmount
|
|
10
|
+
* - onUnmounted
|
|
11
|
+
*
|
|
12
|
+
* `onBeforeMount` and `onMounted` callbacks are executed immediately.
|
|
13
|
+
* `onBeforeUnmount` and `onUnmounted` are executed when the returned callback is run.
|
|
14
|
+
* @returns a callback to trigger the run of `onBeforeUnmount` and `onUnmounted`
|
|
15
|
+
*/
|
|
16
|
+
export const mockVueLifecycle = () => {
|
|
17
|
+
const { callbacks } = vi.hoisted(() => ({
|
|
18
|
+
callbacks: {
|
|
19
|
+
onBeforeUnmountedCb: null as Callback | null,
|
|
20
|
+
onUnmountedCb: null as Callback | null,
|
|
21
|
+
},
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
vi.mock("vue", async (original) => ({
|
|
25
|
+
...((await original()) as typeof import("vue")),
|
|
26
|
+
onBeforeMount: vi.fn((cb: Callback) => cb()),
|
|
27
|
+
onMounted: vi.fn((cb: Callback) => cb()),
|
|
28
|
+
onBeforeUnmount: vi.fn((cb: Callback) => (callbacks.onBeforeUnmountedCb = cb)),
|
|
29
|
+
onUnmounted: vi.fn((cb: Callback) => (callbacks.onUnmountedCb = cb)),
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
return async () => {
|
|
33
|
+
await callbacks.onBeforeUnmountedCb?.();
|
|
34
|
+
await callbacks.onUnmountedCb?.();
|
|
35
|
+
};
|
|
36
|
+
};
|
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
import { onBeforeMount, onBeforeUnmount, watchEffect, type Ref } from "vue";
|
|
2
|
-
|
|
3
|
-
export type UseOutsideClickOptions = {
|
|
4
|
-
/**
|
|
5
|
-
* Function that returns the HTML element of the component where outside clicks should be listened to.
|
|
6
|
-
*/
|
|
7
|
-
queryComponent: () => ReturnType<typeof document.querySelector> | undefined;
|
|
8
|
-
/**
|
|
9
|
-
* Callback when an outside click occurred.
|
|
10
|
-
*/
|
|
11
|
-
onOutsideClick: () => void;
|
|
12
|
-
/**
|
|
13
|
-
* If `true`, event listeners will be removed and no outside clicks will be captured.
|
|
14
|
-
*/
|
|
15
|
-
disabled?: Ref<boolean>;
|
|
16
|
-
};
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Composable for listening to click events that occur outside of a component.
|
|
20
|
-
* Useful to e.g. close flyouts or tooltips.
|
|
21
|
-
*/
|
|
22
|
-
export const useOutsideClick = (options: UseOutsideClickOptions) => {
|
|
23
|
-
/**
|
|
24
|
-
* Document click handle that closes then tooltip when clicked outside.
|
|
25
|
-
* Should only be called when trigger is "click".
|
|
26
|
-
*/
|
|
27
|
-
const handleDocumentClick = (event: MouseEvent) => {
|
|
28
|
-
const component = options.queryComponent();
|
|
29
|
-
if (!component || !(event.target instanceof Node)) return;
|
|
30
|
-
|
|
31
|
-
const isOutsideClick = !component.contains(event.target);
|
|
32
|
-
if (isOutsideClick) options.onOutsideClick();
|
|
33
|
-
};
|
|
34
|
-
|
|
35
|
-
// add global document event listeners only on/before mounted to also work in server side rendering
|
|
36
|
-
onBeforeMount(() => {
|
|
37
|
-
watchEffect(() => {
|
|
38
|
-
if (options.disabled?.value) {
|
|
39
|
-
document.removeEventListener("click", handleDocumentClick);
|
|
40
|
-
} else {
|
|
41
|
-
document.addEventListener("click", handleDocumentClick);
|
|
42
|
-
}
|
|
43
|
-
});
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Clean up global event listeners to prevent dangling events.
|
|
48
|
-
*/
|
|
49
|
-
onBeforeUnmount(() => {
|
|
50
|
-
document.removeEventListener("click", handleDocumentClick);
|
|
51
|
-
});
|
|
52
|
-
};
|
|
File without changes
|
|
File without changes
|