@sit-onyx/headless 1.0.0-beta.0 → 1.0.0-beta.10
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 +6 -2
- package/src/composables/comboBox/SelectOnlyCombobox.vue +6 -2
- package/src/composables/comboBox/TestCombobox.ct.tsx +1 -1
- package/src/composables/comboBox/TestCombobox.vue +9 -6
- package/src/composables/comboBox/createComboBox.ts +20 -15
- package/src/composables/helpers/useDismissible.ts +19 -0
- package/src/composables/helpers/useGlobalListener.spec.ts +93 -0
- package/src/composables/helpers/useGlobalListener.ts +64 -0
- package/src/composables/helpers/useOutsideClick.spec.ts +83 -0
- package/src/composables/helpers/useOutsideClick.ts +40 -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/TestListbox.vue +2 -0
- package/src/composables/listbox/createListbox.ts +26 -9
- 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 +121 -101
- package/src/composables/navigationMenu/TestMenu.ct.tsx +12 -0
- package/src/composables/navigationMenu/TestMenu.vue +16 -0
- package/src/composables/navigationMenu/createMenu.testing.ts +37 -0
- package/src/composables/navigationMenu/createMenu.ts +55 -0
- package/src/composables/tabs/TestTabs.ct.tsx +13 -0
- package/src/composables/tabs/TestTabs.vue +26 -0
- package/src/composables/tabs/createTabs.testing.ts +79 -0
- package/src/composables/tabs/createTabs.ts +72 -0
- package/src/composables/tooltip/createToggletip.ts +61 -0
- package/src/composables/tooltip/createTooltip.ts +37 -92
- package/src/index.ts +6 -1
- package/src/playwright.ts +5 -3
- package/src/utils/builder.ts +111 -13
- package/src/utils/math.spec.ts +14 -0
- package/src/utils/math.ts +6 -0
- package/src/utils/types.ts +7 -0
- package/src/utils/vitest.ts +36 -0
- package/src/composables/outsideClick.ts +0 -52
- package/src/utils/id.ts +0 -14
- /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,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.10",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "Schwarz IT KG",
|
|
7
7
|
"license": "Apache-2.0",
|
|
@@ -24,7 +24,11 @@
|
|
|
24
24
|
},
|
|
25
25
|
"peerDependencies": {
|
|
26
26
|
"typescript": ">= 5",
|
|
27
|
-
"vue": ">= 3"
|
|
27
|
+
"vue": ">= 3.5.0"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@vue/compiler-dom": "3.5.12",
|
|
31
|
+
"vue": "3.5.12"
|
|
28
32
|
},
|
|
29
33
|
"scripts": {
|
|
30
34
|
"build": "vue-tsc --build --force",
|
|
@@ -26,7 +26,6 @@ const onToggle = () => (isExpanded.value = !isExpanded.value);
|
|
|
26
26
|
const onTypeAhead = () => {};
|
|
27
27
|
|
|
28
28
|
const comboBox = createComboBox({
|
|
29
|
-
inputValue: selectedOption,
|
|
30
29
|
autocomplete: "none",
|
|
31
30
|
label: "some label",
|
|
32
31
|
listLabel: "List",
|
|
@@ -51,7 +50,12 @@ defineExpose({ comboBox });
|
|
|
51
50
|
|
|
52
51
|
<template>
|
|
53
52
|
<div ref="comboboxRef">
|
|
54
|
-
<input
|
|
53
|
+
<input
|
|
54
|
+
v-bind="input"
|
|
55
|
+
v-model="selectedOption"
|
|
56
|
+
readonly
|
|
57
|
+
@keydown.arrow-down="isExpanded = true"
|
|
58
|
+
/>
|
|
55
59
|
|
|
56
60
|
<button v-bind="button">
|
|
57
61
|
<template v-if="isExpanded">⬆️</template>
|
|
@@ -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
|
|
|
@@ -30,7 +30,6 @@ const onAutocomplete = (input: string) => (searchTerm.value = input);
|
|
|
30
30
|
const onToggle = () => (isExpanded.value = !isExpanded.value);
|
|
31
31
|
|
|
32
32
|
const comboBox = createComboBox({
|
|
33
|
-
inputValue: searchTerm,
|
|
34
33
|
autocomplete: "list",
|
|
35
34
|
label: "some label",
|
|
36
35
|
listLabel: "List",
|
|
@@ -55,13 +54,13 @@ defineExpose({ comboBox });
|
|
|
55
54
|
|
|
56
55
|
<template>
|
|
57
56
|
<div ref="comboboxRef">
|
|
58
|
-
<input v-bind="input" @keydown.arrow-down="isExpanded = true" />
|
|
57
|
+
<input v-bind="input" v-model="searchTerm" @keydown.arrow-down="isExpanded = true" />
|
|
59
58
|
|
|
60
|
-
<button v-bind="button">
|
|
61
|
-
<template v-if="isExpanded"
|
|
62
|
-
<template v-else
|
|
59
|
+
<button v-bind="button" type="button">
|
|
60
|
+
<template v-if="isExpanded"> ⬆️ </template>
|
|
61
|
+
<template v-else> ⬇️ </template>
|
|
63
62
|
</button>
|
|
64
|
-
<ul v-bind="listbox" :class="{ hidden: !isExpanded }"
|
|
63
|
+
<ul v-bind="listbox" :class="{ list: true, hidden: !isExpanded }">
|
|
65
64
|
<li
|
|
66
65
|
v-for="e in filteredOptions"
|
|
67
66
|
:key="e"
|
|
@@ -75,6 +74,10 @@ defineExpose({ comboBox });
|
|
|
75
74
|
</template>
|
|
76
75
|
|
|
77
76
|
<style>
|
|
77
|
+
.list {
|
|
78
|
+
width: 400px;
|
|
79
|
+
}
|
|
80
|
+
|
|
78
81
|
.hidden {
|
|
79
82
|
display: none;
|
|
80
83
|
}
|
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
import { computed, unref, type MaybeRef, type Ref } from "vue";
|
|
1
|
+
import { computed, unref, useId, type MaybeRef, type Ref } from "vue";
|
|
2
2
|
import { createBuilder } from "../../utils/builder";
|
|
3
|
-
import { createId } from "../../utils/id";
|
|
4
3
|
import { isPrintableCharacter, wasKeyPressed, type PressedKey } from "../../utils/keyboard";
|
|
4
|
+
import { useOutsideClick } from "../helpers/useOutsideClick";
|
|
5
|
+
import { useTypeAhead } from "../helpers/useTypeAhead";
|
|
5
6
|
import {
|
|
6
7
|
createListbox,
|
|
7
8
|
type CreateListboxOptions,
|
|
8
9
|
type ListboxValue,
|
|
9
10
|
} from "../listbox/createListbox";
|
|
10
|
-
import { useOutsideClick } from "../outsideClick";
|
|
11
|
-
import { useTypeAhead } from "../typeAhead";
|
|
12
11
|
|
|
12
|
+
/** See https://w3c.github.io/aria/#aria-autocomplete */
|
|
13
13
|
export type ComboboxAutoComplete = "none" | "list" | "both";
|
|
14
14
|
|
|
15
15
|
export const OPENING_KEYS: PressedKey[] = ["ArrowDown", "ArrowUp", " ", "Enter", "Home", "End"];
|
|
@@ -19,11 +19,15 @@ export const CLOSING_KEYS: PressedKey[] = [
|
|
|
19
19
|
"Enter",
|
|
20
20
|
"Tab",
|
|
21
21
|
];
|
|
22
|
-
const SELECTING_KEYS_SINGLE: PressedKey[] = ["Enter", " "];
|
|
23
|
-
const SELECTING_KEYS_MULTIPLE: PressedKey[] = ["Enter"];
|
|
24
22
|
|
|
25
|
-
const
|
|
26
|
-
|
|
23
|
+
const SELECTING_KEYS: PressedKey[] = ["Enter"];
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* if the a search input is included, space should not be used to select
|
|
27
|
+
* TODO: idea for the future: move this distinction to the listbox?
|
|
28
|
+
*/
|
|
29
|
+
const isSelectingKey = (event: KeyboardEvent, withSpace?: boolean) => {
|
|
30
|
+
const selectingKeys = withSpace ? [...SELECTING_KEYS, " "] : SELECTING_KEYS;
|
|
27
31
|
return isKeyOfGroup(event, selectingKeys);
|
|
28
32
|
};
|
|
29
33
|
|
|
@@ -42,9 +46,9 @@ export type CreateComboboxOptions<
|
|
|
42
46
|
*/
|
|
43
47
|
listLabel: MaybeRef<string>;
|
|
44
48
|
/**
|
|
45
|
-
*
|
|
49
|
+
* Provides additional description for the listbox which displays the available options.
|
|
46
50
|
*/
|
|
47
|
-
|
|
51
|
+
listDescription?: MaybeRef<string | undefined>;
|
|
48
52
|
/**
|
|
49
53
|
* Controls the opened/visible state of the associated pop-up. When expanded the activeOption can be controlled via the keyboard.
|
|
50
54
|
*/
|
|
@@ -105,13 +109,13 @@ export const createComboBox = createBuilder(
|
|
|
105
109
|
TAutoComplete extends ComboboxAutoComplete,
|
|
106
110
|
TMultiple extends boolean = false,
|
|
107
111
|
>({
|
|
108
|
-
inputValue,
|
|
109
112
|
autocomplete: autocompleteRef,
|
|
110
113
|
onAutocomplete,
|
|
111
114
|
onTypeAhead,
|
|
112
115
|
multiple: multipleRef,
|
|
113
116
|
label,
|
|
114
117
|
listLabel,
|
|
118
|
+
listDescription,
|
|
115
119
|
isExpanded: isExpandedRef,
|
|
116
120
|
activeOption,
|
|
117
121
|
onToggle,
|
|
@@ -122,7 +126,7 @@ export const createComboBox = createBuilder(
|
|
|
122
126
|
onActivatePrevious,
|
|
123
127
|
templateRef,
|
|
124
128
|
}: CreateComboboxOptions<TValue, TAutoComplete, TMultiple>) => {
|
|
125
|
-
const controlsId =
|
|
129
|
+
const controlsId = useId();
|
|
126
130
|
|
|
127
131
|
const autocomplete = computed(() => unref(autocompleteRef));
|
|
128
132
|
const isExpanded = computed(() => unref(isExpandedRef));
|
|
@@ -188,7 +192,7 @@ export const createComboBox = createBuilder(
|
|
|
188
192
|
}
|
|
189
193
|
return onActivateFirst?.();
|
|
190
194
|
}
|
|
191
|
-
if (isSelectingKey(event,
|
|
195
|
+
if (isSelectingKey(event, autocomplete.value === "none")) {
|
|
192
196
|
return handleSelect(activeOption.value!);
|
|
193
197
|
}
|
|
194
198
|
if (isExpanded.value && isKeyOfGroup(event, CLOSING_KEYS)) {
|
|
@@ -218,14 +222,16 @@ export const createComboBox = createBuilder(
|
|
|
218
222
|
internals: { getOptionId },
|
|
219
223
|
} = createListbox({
|
|
220
224
|
label: listLabel,
|
|
225
|
+
description: listDescription,
|
|
221
226
|
multiple,
|
|
222
227
|
controlled: true,
|
|
223
228
|
activeOption,
|
|
229
|
+
isExpanded,
|
|
224
230
|
onSelect: handleSelect,
|
|
225
231
|
});
|
|
226
232
|
|
|
227
233
|
useOutsideClick({
|
|
228
|
-
|
|
234
|
+
inside: templateRef,
|
|
229
235
|
onOutsideClick() {
|
|
230
236
|
if (!isExpanded.value) return;
|
|
231
237
|
onToggle?.(true);
|
|
@@ -250,7 +256,6 @@ export const createComboBox = createBuilder(
|
|
|
250
256
|
* The input MAY be either a single-line text field that supports editing and typing or an element that only displays the current value of the combobox.
|
|
251
257
|
*/
|
|
252
258
|
input: computed(() => ({
|
|
253
|
-
value: inputValue.value,
|
|
254
259
|
role: "combobox",
|
|
255
260
|
"aria-expanded": isExpanded.value,
|
|
256
261
|
"aria-controls": controlsId,
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { computed, type Ref } from "vue";
|
|
2
|
+
import { useGlobalEventListener } from "./useGlobalListener";
|
|
3
|
+
|
|
4
|
+
type UseDismissibleOptions = { isExpanded: Ref<boolean> };
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Composable that sets `isExpanded` to false, when the `Escape` key is pressed.
|
|
8
|
+
* Addresses the "dismissible" aspect of https://www.w3.org/WAI/WCAG21/Understanding/content-on-hover-or-focus.html
|
|
9
|
+
*/
|
|
10
|
+
export const useDismissible = ({ isExpanded }: UseDismissibleOptions) =>
|
|
11
|
+
useGlobalEventListener({
|
|
12
|
+
type: "keydown",
|
|
13
|
+
listener: (e) => {
|
|
14
|
+
if (e.key === "Escape") {
|
|
15
|
+
isExpanded.value = false;
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
disabled: computed(() => !isExpanded.value),
|
|
19
|
+
});
|
|
@@ -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,83 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { ref } from "vue";
|
|
3
|
+
import { mockVueLifecycle } from "../../utils/vitest";
|
|
4
|
+
import { useOutsideClick } from "./useOutsideClick";
|
|
5
|
+
|
|
6
|
+
describe("useOutsideClick", () => {
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
vi.clearAllMocks();
|
|
9
|
+
mockVueLifecycle();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("should be defined", () => {
|
|
13
|
+
expect(useOutsideClick).toBeDefined();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("should detect outside clicks", () => {
|
|
17
|
+
// ARRANGE
|
|
18
|
+
const inside = ref(document.createElement("button"));
|
|
19
|
+
document.body.appendChild(inside.value);
|
|
20
|
+
const outside = ref(document.createElement("button"));
|
|
21
|
+
document.body.appendChild(outside.value);
|
|
22
|
+
|
|
23
|
+
const onOutsideClick = vi.fn();
|
|
24
|
+
useOutsideClick({ inside, onOutsideClick });
|
|
25
|
+
// ACT
|
|
26
|
+
const event = new MouseEvent("click", { bubbles: true });
|
|
27
|
+
outside.value.dispatchEvent(event);
|
|
28
|
+
// ASSERT
|
|
29
|
+
expect(onOutsideClick).toHaveBeenCalledTimes(1);
|
|
30
|
+
expect(onOutsideClick).toBeCalledWith(event);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("should detect outside clicks correctly for multiple inside elements", () => {
|
|
34
|
+
// ARRANGE
|
|
35
|
+
const inside = [document.createElement("button"), document.createElement("button")];
|
|
36
|
+
inside.forEach((e) => document.body.appendChild(e));
|
|
37
|
+
const outside = ref(document.createElement("button"));
|
|
38
|
+
document.body.appendChild(outside.value);
|
|
39
|
+
|
|
40
|
+
const onOutsideClick = vi.fn();
|
|
41
|
+
useOutsideClick({ inside, onOutsideClick });
|
|
42
|
+
// ACT
|
|
43
|
+
const event = new MouseEvent("click", { bubbles: true });
|
|
44
|
+
inside[0].dispatchEvent(event);
|
|
45
|
+
inside[1].dispatchEvent(event);
|
|
46
|
+
// ASSERT
|
|
47
|
+
expect(onOutsideClick).not.toHaveBeenCalled();
|
|
48
|
+
|
|
49
|
+
// ACT
|
|
50
|
+
outside.value.dispatchEvent(event);
|
|
51
|
+
// ASSERT
|
|
52
|
+
expect(onOutsideClick).toHaveBeenCalledTimes(1);
|
|
53
|
+
expect(onOutsideClick).toBeCalledWith(event);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("should ignore outside clicks when disabled", async () => {
|
|
57
|
+
// ARRANGE
|
|
58
|
+
vi.useFakeTimers();
|
|
59
|
+
const inside = ref(document.createElement("button"));
|
|
60
|
+
document.body.appendChild(inside.value);
|
|
61
|
+
const outside = ref(document.createElement("button"));
|
|
62
|
+
document.body.appendChild(outside.value);
|
|
63
|
+
|
|
64
|
+
const disabled = ref(false);
|
|
65
|
+
const onOutsideClick = vi.fn();
|
|
66
|
+
useOutsideClick({ inside, disabled, onOutsideClick });
|
|
67
|
+
|
|
68
|
+
// ACT
|
|
69
|
+
const event = new MouseEvent("click", { bubbles: true });
|
|
70
|
+
outside.value.dispatchEvent(event);
|
|
71
|
+
// ASSERT
|
|
72
|
+
expect(onOutsideClick).toHaveBeenCalledTimes(1);
|
|
73
|
+
expect(onOutsideClick).toBeCalledWith(event);
|
|
74
|
+
|
|
75
|
+
// ACT
|
|
76
|
+
disabled.value = true;
|
|
77
|
+
await vi.runAllTimersAsync();
|
|
78
|
+
const event2 = new MouseEvent("click", { bubbles: true });
|
|
79
|
+
outside.value.dispatchEvent(event2);
|
|
80
|
+
// ASSERT
|
|
81
|
+
expect(onOutsideClick).toHaveBeenCalledTimes(1);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { Arrayable } from "vitest";
|
|
2
|
+
import { toValue, type Ref } from "vue";
|
|
3
|
+
import type { MaybeReactiveSource } from "../../utils/types";
|
|
4
|
+
import { useGlobalEventListener } from "./useGlobalListener";
|
|
5
|
+
|
|
6
|
+
export type UseOutsideClickOptions = {
|
|
7
|
+
/**
|
|
8
|
+
* HTML element of the component where clicks should be ignored
|
|
9
|
+
*/
|
|
10
|
+
inside: MaybeReactiveSource<Arrayable<HTMLElement | undefined>>;
|
|
11
|
+
/**
|
|
12
|
+
* Callback when an outside click occurred.
|
|
13
|
+
*/
|
|
14
|
+
onOutsideClick: (event: MouseEvent) => void;
|
|
15
|
+
/**
|
|
16
|
+
* If `true`, event listeners will be removed and no outside clicks will be captured.
|
|
17
|
+
*/
|
|
18
|
+
disabled?: Ref<boolean>;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Composable for listening to click events that occur outside of a component.
|
|
23
|
+
* Useful to e.g. close flyouts or tooltips.
|
|
24
|
+
*/
|
|
25
|
+
export const useOutsideClick = ({ inside, onOutsideClick, disabled }: UseOutsideClickOptions) => {
|
|
26
|
+
/**
|
|
27
|
+
* Document click handle that closes then tooltip when clicked outside.
|
|
28
|
+
* Should only be called when trigger is "click".
|
|
29
|
+
*/
|
|
30
|
+
const listener = (event: MouseEvent) => {
|
|
31
|
+
const raw = toValue(inside);
|
|
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);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
useGlobalEventListener({ type: "click", listener, disabled });
|
|
40
|
+
};
|
|
@@ -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 />);
|
|
@@ -30,7 +30,9 @@ const {
|
|
|
30
30
|
elements: { listbox, option: headlessOption },
|
|
31
31
|
} = createListbox({
|
|
32
32
|
label: "Test listbox",
|
|
33
|
+
description: "Test description",
|
|
33
34
|
activeOption,
|
|
35
|
+
isExpanded: true,
|
|
34
36
|
onSelect: (id) => {
|
|
35
37
|
selectedOption.value = selectedOption.value === id ? undefined : id;
|
|
36
38
|
},
|
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import { computed, ref, unref, watchEffect, type MaybeRef, type Ref } from "vue";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import { useTypeAhead } from "../typeAhead";
|
|
1
|
+
import { computed, nextTick, ref, unref, useId, watchEffect, type MaybeRef, type Ref } from "vue";
|
|
2
|
+
import { createBuilder, type VBindAttributes } from "../../utils/builder";
|
|
3
|
+
import { useTypeAhead } from "../helpers/useTypeAhead";
|
|
5
4
|
|
|
6
5
|
export type ListboxValue = string | number | boolean;
|
|
7
6
|
|
|
@@ -10,6 +9,10 @@ export type CreateListboxOptions<TValue extends ListboxValue, TMultiple extends
|
|
|
10
9
|
* Aria label for the listbox.
|
|
11
10
|
*/
|
|
12
11
|
label: MaybeRef<string>;
|
|
12
|
+
/**
|
|
13
|
+
* Aria description for the listbox.
|
|
14
|
+
*/
|
|
15
|
+
description?: MaybeRef<string | undefined>;
|
|
13
16
|
/**
|
|
14
17
|
* Value of currently (visually) active option.
|
|
15
18
|
*/
|
|
@@ -19,6 +22,10 @@ export type CreateListboxOptions<TValue extends ListboxValue, TMultiple extends
|
|
|
19
22
|
* This disables keyboard events and makes the listbox not focusable.
|
|
20
23
|
*/
|
|
21
24
|
controlled?: boolean;
|
|
25
|
+
/**
|
|
26
|
+
* Controls the opened/visible state of the listbox. When expanded the activeOption can be controlled via the keyboard.
|
|
27
|
+
*/
|
|
28
|
+
isExpanded?: MaybeRef<boolean>;
|
|
22
29
|
/**
|
|
23
30
|
* Whether the listbox is multiselect.
|
|
24
31
|
*/
|
|
@@ -77,6 +84,7 @@ export const createListbox = createBuilder(
|
|
|
77
84
|
options: CreateListboxOptions<TValue, TMultiple>,
|
|
78
85
|
) => {
|
|
79
86
|
const isMultiselect = computed(() => unref(options.multiple) ?? false);
|
|
87
|
+
const isExpanded = computed(() => unref(options.isExpanded) ?? false);
|
|
80
88
|
|
|
81
89
|
/**
|
|
82
90
|
* Map for option IDs. key = option value, key = ID for the HTML element
|
|
@@ -85,7 +93,7 @@ export const createListbox = createBuilder(
|
|
|
85
93
|
|
|
86
94
|
const getOptionId = (value: TValue) => {
|
|
87
95
|
if (!descendantKeyIdMap.has(value)) {
|
|
88
|
-
descendantKeyIdMap.set(value,
|
|
96
|
+
descendantKeyIdMap.set(value, useId());
|
|
89
97
|
}
|
|
90
98
|
return descendantKeyIdMap.get(value)!;
|
|
91
99
|
};
|
|
@@ -96,11 +104,18 @@ export const createListbox = createBuilder(
|
|
|
96
104
|
const isFocused = ref(false);
|
|
97
105
|
|
|
98
106
|
// scroll currently active option into view if needed
|
|
99
|
-
watchEffect(() => {
|
|
100
|
-
if (
|
|
107
|
+
watchEffect(async () => {
|
|
108
|
+
if (
|
|
109
|
+
!isExpanded.value ||
|
|
110
|
+
options.activeOption.value == undefined ||
|
|
111
|
+
(!isFocused.value && !options.controlled)
|
|
112
|
+
)
|
|
101
113
|
return;
|
|
102
114
|
const id = getOptionId(options.activeOption.value);
|
|
103
|
-
|
|
115
|
+
|
|
116
|
+
await nextTick(() => {
|
|
117
|
+
document.getElementById(id)?.scrollIntoView({ block: "end", inline: "nearest" });
|
|
118
|
+
});
|
|
104
119
|
});
|
|
105
120
|
|
|
106
121
|
const typeAhead = useTypeAhead((inputString) => options.onTypeAhead?.(inputString));
|
|
@@ -152,18 +167,20 @@ export const createListbox = createBuilder(
|
|
|
152
167
|
}
|
|
153
168
|
};
|
|
154
169
|
|
|
155
|
-
const listbox = computed<
|
|
170
|
+
const listbox = computed<VBindAttributes>(() =>
|
|
156
171
|
options.controlled
|
|
157
172
|
? {
|
|
158
173
|
role: "listbox",
|
|
159
174
|
"aria-multiselectable": isMultiselect.value,
|
|
160
175
|
"aria-label": unref(options.label),
|
|
176
|
+
"aria-description": options.description,
|
|
161
177
|
tabindex: "-1",
|
|
162
178
|
}
|
|
163
179
|
: {
|
|
164
180
|
role: "listbox",
|
|
165
181
|
"aria-multiselectable": isMultiselect.value,
|
|
166
182
|
"aria-label": unref(options.label),
|
|
183
|
+
"aria-description": options.description,
|
|
167
184
|
tabindex: "0",
|
|
168
185
|
"aria-activedescendant":
|
|
169
186
|
options.activeOption.value != undefined
|
|
@@ -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>
|