@sit-onyx/headless 1.0.0-beta.18 → 1.0.0-beta.19
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 +3 -3
- package/src/composables/helpers/useGlobalListener.ts +1 -1
- package/src/composables/helpers/useOutsideClick.spec.ts +35 -1
- package/src/composables/helpers/useOutsideClick.ts +40 -11
- package/src/composables/menuButton/TestMenuButton.vue +2 -2
- package/src/composables/menuButton/createMenuButton.testing.ts +0 -12
- package/src/composables/menuButton/createMenuButton.ts +44 -25
- package/src/utils/types.ts +1 -1
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sit-onyx/headless",
|
|
3
3
|
"description": "Headless composables for Vue",
|
|
4
|
-
"version": "1.0.0-beta.
|
|
4
|
+
"version": "1.0.0-beta.19",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "Schwarz IT KG",
|
|
7
7
|
"license": "Apache-2.0",
|
|
@@ -27,8 +27,8 @@
|
|
|
27
27
|
"vue": ">= 3.5.0"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
|
30
|
-
"@vue/compiler-dom": "3.5.
|
|
31
|
-
"vue": "3.5.
|
|
30
|
+
"@vue/compiler-dom": "3.5.16",
|
|
31
|
+
"vue": "3.5.16",
|
|
32
32
|
"@sit-onyx/shared": "^1.0.0-beta.3"
|
|
33
33
|
},
|
|
34
34
|
"scripts": {
|
|
@@ -3,7 +3,7 @@ import { onBeforeMount, onBeforeUnmount, reactive, watchEffect, type Ref } from
|
|
|
3
3
|
type DocumentEventType = keyof DocumentEventMap;
|
|
4
4
|
type GlobalListener<K extends DocumentEventType = DocumentEventType> = (
|
|
5
5
|
event: DocumentEventMap[K],
|
|
6
|
-
) =>
|
|
6
|
+
) => unknown;
|
|
7
7
|
|
|
8
8
|
export type UseGlobalEventListenerOptions<K extends DocumentEventType> = {
|
|
9
9
|
type: K;
|
|
@@ -13,8 +13,9 @@ describe("useOutsideClick", () => {
|
|
|
13
13
|
expect(useOutsideClick).toBeDefined();
|
|
14
14
|
});
|
|
15
15
|
|
|
16
|
-
it("should detect outside clicks", () => {
|
|
16
|
+
it("should detect outside clicks", async () => {
|
|
17
17
|
// ARRANGE
|
|
18
|
+
vi.useFakeTimers();
|
|
18
19
|
const inside = ref(document.createElement("button"));
|
|
19
20
|
document.body.appendChild(inside.value);
|
|
20
21
|
const outside = ref(document.createElement("button"));
|
|
@@ -22,12 +23,24 @@ describe("useOutsideClick", () => {
|
|
|
22
23
|
|
|
23
24
|
const onOutsideClick = vi.fn();
|
|
24
25
|
useOutsideClick({ inside, onOutsideClick });
|
|
26
|
+
|
|
25
27
|
// ACT
|
|
26
28
|
const event = new MouseEvent("click", { bubbles: true });
|
|
27
29
|
outside.value.dispatchEvent(event);
|
|
30
|
+
|
|
28
31
|
// ASSERT
|
|
29
32
|
expect(onOutsideClick).toHaveBeenCalledTimes(1);
|
|
30
33
|
expect(onOutsideClick).toBeCalledWith(event);
|
|
34
|
+
|
|
35
|
+
// ACT
|
|
36
|
+
outside.value.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Tab" }));
|
|
37
|
+
await vi.runAllTimersAsync();
|
|
38
|
+
|
|
39
|
+
// ASSERT
|
|
40
|
+
expect(
|
|
41
|
+
onOutsideClick,
|
|
42
|
+
"should not trigger on Tab press when checkOnTab option is disabled",
|
|
43
|
+
).toHaveBeenCalledTimes(1);
|
|
31
44
|
});
|
|
32
45
|
|
|
33
46
|
it("should detect outside clicks correctly for multiple inside elements", () => {
|
|
@@ -80,4 +93,25 @@ describe("useOutsideClick", () => {
|
|
|
80
93
|
// ASSERT
|
|
81
94
|
expect(onOutsideClick).toHaveBeenCalledTimes(1);
|
|
82
95
|
});
|
|
96
|
+
|
|
97
|
+
it("should detect outside tab via keyboard", async () => {
|
|
98
|
+
// ARRANGE
|
|
99
|
+
vi.useFakeTimers();
|
|
100
|
+
const inside = ref(document.createElement("button"));
|
|
101
|
+
document.body.appendChild(inside.value);
|
|
102
|
+
const outside = ref(document.createElement("button"));
|
|
103
|
+
document.body.appendChild(outside.value);
|
|
104
|
+
|
|
105
|
+
const onOutsideClick = vi.fn();
|
|
106
|
+
useOutsideClick({ inside, onOutsideClick, checkOnTab: true });
|
|
107
|
+
|
|
108
|
+
// ACT
|
|
109
|
+
const event = new KeyboardEvent("keydown", { bubbles: true, key: "Tab" });
|
|
110
|
+
outside.value.dispatchEvent(event);
|
|
111
|
+
await vi.runAllTimersAsync();
|
|
112
|
+
|
|
113
|
+
// ASSERT
|
|
114
|
+
expect(onOutsideClick).toHaveBeenCalledTimes(1);
|
|
115
|
+
expect(onOutsideClick).toBeCalledWith(event);
|
|
116
|
+
});
|
|
83
117
|
});
|
|
@@ -3,7 +3,7 @@ import { toValue, type MaybeRefOrGetter, type Ref } from "vue";
|
|
|
3
3
|
import type { Nullable } from "../../utils/types";
|
|
4
4
|
import { useGlobalEventListener } from "./useGlobalListener";
|
|
5
5
|
|
|
6
|
-
export type UseOutsideClickOptions = {
|
|
6
|
+
export type UseOutsideClickOptions<TCheckOnTab extends boolean | undefined = undefined> = {
|
|
7
7
|
/**
|
|
8
8
|
* HTML element of the component where clicks should be ignored
|
|
9
9
|
*/
|
|
@@ -11,7 +11,13 @@ export type UseOutsideClickOptions = {
|
|
|
11
11
|
/**
|
|
12
12
|
* Callback when an outside click occurred.
|
|
13
13
|
*/
|
|
14
|
-
onOutsideClick: (
|
|
14
|
+
onOutsideClick: (
|
|
15
|
+
event: TCheckOnTab extends true ? MouseEvent | KeyboardEvent : MouseEvent,
|
|
16
|
+
) => void;
|
|
17
|
+
/**
|
|
18
|
+
* Whether the outside focus should also be checked when pressing the Tab key.
|
|
19
|
+
*/
|
|
20
|
+
checkOnTab?: TCheckOnTab;
|
|
15
21
|
/**
|
|
16
22
|
* If `true`, event listeners will be removed and no outside clicks will be captured.
|
|
17
23
|
*/
|
|
@@ -22,19 +28,42 @@ export type UseOutsideClickOptions = {
|
|
|
22
28
|
* Composable for listening to click events that occur outside of a component.
|
|
23
29
|
* Useful to e.g. close flyouts or tooltips.
|
|
24
30
|
*/
|
|
25
|
-
export const useOutsideClick =
|
|
31
|
+
export const useOutsideClick = <TCheckOnTab extends boolean | undefined>({
|
|
32
|
+
inside,
|
|
33
|
+
onOutsideClick,
|
|
34
|
+
disabled,
|
|
35
|
+
checkOnTab,
|
|
36
|
+
}: UseOutsideClickOptions<TCheckOnTab>) => {
|
|
37
|
+
const isOutsideClick = (target: EventTarget | null) => {
|
|
38
|
+
if (!target) return true;
|
|
39
|
+
const raw = toValue(inside);
|
|
40
|
+
const elements = Array.isArray(raw) ? raw : [raw];
|
|
41
|
+
return !elements.some((element) => element?.contains(target as Node | null));
|
|
42
|
+
};
|
|
43
|
+
|
|
26
44
|
/**
|
|
27
45
|
* Document click handle that closes then tooltip when clicked outside.
|
|
28
46
|
* Should only be called when trigger is "click".
|
|
29
47
|
*/
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
const elements = Array.isArray(raw) ? raw : [raw];
|
|
33
|
-
const isOutsideClick = !elements.some((element) =>
|
|
34
|
-
element?.contains(event.target as HTMLElement),
|
|
35
|
-
);
|
|
36
|
-
if (isOutsideClick) onOutsideClick(event);
|
|
48
|
+
const clickListener = (event: MouseEvent) => {
|
|
49
|
+
if (isOutsideClick(event.target)) onOutsideClick(event);
|
|
37
50
|
};
|
|
38
51
|
|
|
39
|
-
useGlobalEventListener({ type: "click", listener, disabled });
|
|
52
|
+
useGlobalEventListener({ type: "click", listener: clickListener, disabled });
|
|
53
|
+
|
|
54
|
+
if (checkOnTab) {
|
|
55
|
+
const keydownListener = async (event: KeyboardEvent) => {
|
|
56
|
+
if (event.key !== "Tab") return;
|
|
57
|
+
|
|
58
|
+
// using setTimeout here to guarantee that side effects that might change the document.activeElement have run before checking
|
|
59
|
+
// the activeElement
|
|
60
|
+
await new Promise((resolve) => setTimeout(resolve));
|
|
61
|
+
|
|
62
|
+
if (isOutsideClick(document.activeElement)) {
|
|
63
|
+
onOutsideClick(event as Parameters<typeof onOutsideClick>[0]);
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
useGlobalEventListener({ type: "keydown", listener: keydownListener, disabled });
|
|
68
|
+
}
|
|
40
69
|
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script lang="ts" setup>
|
|
2
|
-
import { ref
|
|
2
|
+
import { ref } from "vue";
|
|
3
3
|
import { createMenuButton } from "./createMenuButton";
|
|
4
4
|
|
|
5
5
|
const items = Array.from({ length: 10 }, (_, index) => {
|
|
@@ -10,7 +10,7 @@ const items = Array.from({ length: 10 }, (_, index) => {
|
|
|
10
10
|
const activeItem = ref<string>();
|
|
11
11
|
const isExpanded = ref(false);
|
|
12
12
|
const onToggle = () => (isExpanded.value = !isExpanded.value);
|
|
13
|
-
const trigger
|
|
13
|
+
const trigger = ref<"click" | "hover">("hover");
|
|
14
14
|
|
|
15
15
|
const {
|
|
16
16
|
elements: { root, button, menu, menuItem, listItem },
|
|
@@ -73,18 +73,6 @@ export const menuButtonTesting = async ({
|
|
|
73
73
|
await menu.press("ArrowUp");
|
|
74
74
|
await expect(firstItem, "First item should be focused when pressing arrow up key").toBeFocused();
|
|
75
75
|
|
|
76
|
-
await menu.press("ArrowRight");
|
|
77
|
-
await expect(
|
|
78
|
-
secondItem,
|
|
79
|
-
"Second item should be focused when pressing arrow right key",
|
|
80
|
-
).toBeFocused();
|
|
81
|
-
|
|
82
|
-
await menu.press("ArrowLeft");
|
|
83
|
-
await expect(
|
|
84
|
-
firstItem,
|
|
85
|
-
"First item should be focused when pressing arrow left key",
|
|
86
|
-
).toBeFocused();
|
|
87
|
-
|
|
88
76
|
await page.keyboard.press("Tab");
|
|
89
77
|
await expect(button, "Button should be focused when pressing tab key").not.toBeFocused();
|
|
90
78
|
|
|
@@ -2,6 +2,7 @@ import { computed, useId, watch, type Ref } from "vue";
|
|
|
2
2
|
import { createBuilder, createElRef } from "../../utils/builder";
|
|
3
3
|
import { debounce } from "../../utils/timer";
|
|
4
4
|
import { useGlobalEventListener } from "../helpers/useGlobalListener";
|
|
5
|
+
import { useOutsideClick } from "../helpers/useOutsideClick";
|
|
5
6
|
|
|
6
7
|
type CreateMenuButtonOptions = {
|
|
7
8
|
isExpanded: Readonly<Ref<boolean>>;
|
|
@@ -16,6 +17,7 @@ type CreateMenuButtonOptions = {
|
|
|
16
17
|
export const createMenuButton = createBuilder((options: CreateMenuButtonOptions) => {
|
|
17
18
|
const rootId = useId();
|
|
18
19
|
const menuId = useId();
|
|
20
|
+
const rootRef = createElRef<HTMLElement>();
|
|
19
21
|
const menuRef = createElRef<HTMLElement>();
|
|
20
22
|
const buttonId = useId();
|
|
21
23
|
|
|
@@ -52,7 +54,9 @@ export const createMenuButton = createBuilder((options: CreateMenuButtonOptions)
|
|
|
52
54
|
const currentMenu = currentMenuItem?.closest('[role="menu"]') || menuRef.value;
|
|
53
55
|
if (!currentMenu) return;
|
|
54
56
|
|
|
55
|
-
const menuItems = Array.from(currentMenu.querySelectorAll<HTMLElement>('[role="menuitem"]'))
|
|
57
|
+
const menuItems = Array.from(currentMenu.querySelectorAll<HTMLElement>('[role="menuitem"]'))
|
|
58
|
+
// filter out nested children
|
|
59
|
+
.filter((item) => item.closest('[role="menu"]') === currentMenu);
|
|
56
60
|
let nextIndex = 0;
|
|
57
61
|
|
|
58
62
|
if (currentMenuItem) {
|
|
@@ -80,12 +84,10 @@ export const createMenuButton = createBuilder((options: CreateMenuButtonOptions)
|
|
|
80
84
|
const handleKeydown = (event: KeyboardEvent) => {
|
|
81
85
|
switch (event.key) {
|
|
82
86
|
case "ArrowDown":
|
|
83
|
-
case "ArrowRight":
|
|
84
87
|
event.preventDefault();
|
|
85
88
|
focusRelativeItem("next");
|
|
86
89
|
break;
|
|
87
90
|
case "ArrowUp":
|
|
88
|
-
case "ArrowLeft":
|
|
89
91
|
event.preventDefault();
|
|
90
92
|
focusRelativeItem("prev");
|
|
91
93
|
break;
|
|
@@ -98,6 +100,7 @@ export const createMenuButton = createBuilder((options: CreateMenuButtonOptions)
|
|
|
98
100
|
focusRelativeItem("last");
|
|
99
101
|
break;
|
|
100
102
|
case " ":
|
|
103
|
+
case "Enter":
|
|
101
104
|
if (event.target instanceof HTMLInputElement) break;
|
|
102
105
|
event.preventDefault();
|
|
103
106
|
(event.target as HTMLElement).click();
|
|
@@ -109,32 +112,29 @@ export const createMenuButton = createBuilder((options: CreateMenuButtonOptions)
|
|
|
109
112
|
}
|
|
110
113
|
};
|
|
111
114
|
|
|
112
|
-
const triggerEvents = () => {
|
|
113
|
-
if (options.trigger.value
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
115
|
+
const triggerEvents = computed(() => {
|
|
116
|
+
if (options.trigger.value !== "hover") return;
|
|
117
|
+
return {
|
|
118
|
+
onMouseenter: () => setExpanded(true),
|
|
119
|
+
onMouseleave: () => setExpanded(false, true),
|
|
120
|
+
};
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
useOutsideClick({
|
|
124
|
+
inside: rootRef,
|
|
125
|
+
onOutsideClick: () => setExpanded(false),
|
|
126
|
+
disabled: computed(() => !options.isExpanded.value),
|
|
127
|
+
checkOnTab: true,
|
|
128
|
+
});
|
|
120
129
|
|
|
121
130
|
return {
|
|
122
131
|
elements: {
|
|
123
|
-
root: {
|
|
132
|
+
root: computed(() => ({
|
|
124
133
|
id: rootId,
|
|
125
134
|
onKeydown: handleKeydown,
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
if (
|
|
130
|
-
rootId &&
|
|
131
|
-
document.getElementById(rootId)?.contains(event.relatedTarget as HTMLElement)
|
|
132
|
-
) {
|
|
133
|
-
return;
|
|
134
|
-
}
|
|
135
|
-
setExpanded(false);
|
|
136
|
-
},
|
|
137
|
-
},
|
|
135
|
+
ref: rootRef,
|
|
136
|
+
...triggerEvents.value,
|
|
137
|
+
})),
|
|
138
138
|
button: computed(
|
|
139
139
|
() =>
|
|
140
140
|
({
|
|
@@ -160,7 +160,25 @@ export const createMenuButton = createBuilder((options: CreateMenuButtonOptions)
|
|
|
160
160
|
};
|
|
161
161
|
});
|
|
162
162
|
|
|
163
|
-
|
|
163
|
+
type CreateMenuItemOptions = {
|
|
164
|
+
/**
|
|
165
|
+
* Called when the menu item should be opened (if it has nested children).
|
|
166
|
+
*/
|
|
167
|
+
onOpen?: () => void;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
export const createMenuItems = createBuilder((options?: CreateMenuItemOptions) => {
|
|
171
|
+
const onKeydown = (event: KeyboardEvent) => {
|
|
172
|
+
switch (event.key) {
|
|
173
|
+
case "ArrowRight":
|
|
174
|
+
case " ":
|
|
175
|
+
case "Enter":
|
|
176
|
+
event.preventDefault();
|
|
177
|
+
options?.onOpen?.();
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
|
|
164
182
|
return {
|
|
165
183
|
elements: {
|
|
166
184
|
listItem: {
|
|
@@ -170,6 +188,7 @@ export const createMenuItems = createBuilder(() => {
|
|
|
170
188
|
"aria-current": data.active ? "page" : undefined,
|
|
171
189
|
"aria-disabled": data.disabled,
|
|
172
190
|
role: "menuitem",
|
|
191
|
+
onKeydown,
|
|
173
192
|
}),
|
|
174
193
|
},
|
|
175
194
|
};
|
package/src/utils/types.ts
CHANGED