@sit-onyx/headless 1.0.0-beta.2 → 1.0.0-beta.21
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/README.md +1 -5
- package/package.json +11 -3
- package/src/composables/comboBox/SelectOnlyCombobox.vue +15 -8
- package/src/composables/comboBox/TestCombobox.ct.tsx +1 -1
- package/src/composables/comboBox/TestCombobox.vue +13 -10
- package/src/composables/comboBox/createComboBox.ts +34 -28
- package/src/composables/helpers/useDismissible.ts +19 -0
- package/src/composables/helpers/useGlobalListener.spec.ts +2 -2
- package/src/composables/helpers/useGlobalListener.ts +1 -1
- package/src/composables/helpers/useOutsideClick.spec.ts +117 -0
- package/src/composables/helpers/useOutsideClick.ts +45 -10
- package/src/composables/helpers/useTypeAhead.spec.ts +1 -1
- package/src/composables/helpers/useTypeAhead.ts +2 -2
- package/src/composables/listbox/TestListbox.ct.tsx +1 -1
- package/src/composables/listbox/TestListbox.vue +3 -1
- package/src/composables/listbox/createListbox.ts +28 -10
- package/src/composables/menuButton/TestMenuButton.ct.tsx +1 -1
- package/src/composables/menuButton/TestMenuButton.vue +4 -3
- package/src/composables/menuButton/createMenuButton.testing.ts +0 -19
- package/src/composables/menuButton/createMenuButton.ts +174 -119
- package/src/composables/navigationMenu/TestMenu.ct.tsx +1 -1
- package/src/composables/navigationMenu/TestMenu.vue +1 -1
- package/src/composables/navigationMenu/createMenu.testing.ts +2 -13
- package/src/composables/navigationMenu/createMenu.ts +6 -7
- package/src/composables/tabs/TestTabs.ct.tsx +12 -0
- package/src/composables/tabs/TestTabs.vue +28 -0
- package/src/composables/tabs/createTabs.testing.ts +151 -0
- package/src/composables/tabs/createTabs.ts +129 -0
- package/src/composables/tooltip/createToggletip.ts +58 -0
- package/src/composables/tooltip/createTooltip.ts +39 -97
- package/src/index.ts +11 -8
- package/src/playwright.ts +5 -3
- package/src/utils/builder.ts +108 -12
- package/src/utils/keyboard.spec.ts +1 -1
- package/src/utils/keyboard.ts +1 -1
- package/src/utils/math.spec.ts +1 -1
- package/src/utils/object.spec.ts +1 -1
- package/src/utils/timer.ts +10 -3
- package/src/utils/types.ts +10 -0
- package/src/utils/vitest.ts +2 -2
- package/src/utils/id.ts +0 -14
package/README.md
CHANGED
|
@@ -1,9 +1,5 @@
|
|
|
1
1
|
<div align="center" style="text-align: center">
|
|
2
|
-
<
|
|
3
|
-
<source media="(prefers-color-scheme: dark)" type="image/svg+xml" srcset="https://raw.githubusercontent.com/SchwarzIT/onyx/main/.github/onyx-logo-light.svg">
|
|
4
|
-
<source media="(prefers-color-scheme: light)" type="image/svg+xml" srcset="https://raw.githubusercontent.com/SchwarzIT/onyx/main/.github/onyx-logo-dark.svg">
|
|
5
|
-
<img alt="onyx logo" src="https://raw.githubusercontent.com/SchwarzIT/onyx/main/.github/onyx-logo-dark.svg" width="160px">
|
|
6
|
-
</picture>
|
|
2
|
+
<img alt="onyx logo" src="https://raw.githubusercontent.com/SchwarzIT/onyx/main/.github/onyx-logo.svg" height="96px">
|
|
7
3
|
</div>
|
|
8
4
|
|
|
9
5
|
<br>
|
package/package.json
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
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.21",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "Schwarz IT KG",
|
|
7
7
|
"license": "Apache-2.0",
|
|
8
|
+
"engines": {
|
|
9
|
+
"node": ">=20"
|
|
10
|
+
},
|
|
8
11
|
"files": [
|
|
9
12
|
"src"
|
|
10
13
|
],
|
|
@@ -24,11 +27,16 @@
|
|
|
24
27
|
},
|
|
25
28
|
"peerDependencies": {
|
|
26
29
|
"typescript": ">= 5",
|
|
27
|
-
"vue": ">= 3"
|
|
30
|
+
"vue": ">= 3.5.0"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@vue/compiler-dom": "3.5.16",
|
|
34
|
+
"vue": "3.5.16",
|
|
35
|
+
"@sit-onyx/shared": "^1.0.0-beta.4"
|
|
28
36
|
},
|
|
29
37
|
"scripts": {
|
|
30
38
|
"build": "vue-tsc --build --force",
|
|
31
39
|
"test": "vitest",
|
|
32
|
-
"test:
|
|
40
|
+
"test:playwright": "playwright install && playwright test"
|
|
33
41
|
}
|
|
34
42
|
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { computed, ref } from "vue";
|
|
3
|
-
import { createComboBox } from "./createComboBox";
|
|
2
|
+
import { computed, ref, useTemplateRef } from "vue";
|
|
3
|
+
import { createComboBox } from "./createComboBox.js";
|
|
4
4
|
|
|
5
5
|
const options = ["a", "b", "c", "d"];
|
|
6
6
|
const isExpanded = ref(false);
|
|
7
|
-
const comboboxRef =
|
|
7
|
+
const comboboxRef = useTemplateRef("combobox");
|
|
8
8
|
const activeOption = ref("");
|
|
9
9
|
const selectedOption = ref("");
|
|
10
10
|
const selectedIndex = computed<number | undefined>(() => {
|
|
@@ -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",
|
|
@@ -50,14 +49,19 @@ defineExpose({ comboBox });
|
|
|
50
49
|
</script>
|
|
51
50
|
|
|
52
51
|
<template>
|
|
53
|
-
<div ref="
|
|
54
|
-
<input
|
|
52
|
+
<div ref="combobox">
|
|
53
|
+
<input
|
|
54
|
+
v-bind="input"
|
|
55
|
+
v-model="selectedOption"
|
|
56
|
+
readonly
|
|
57
|
+
@keydown.arrow-down="isExpanded = true"
|
|
58
|
+
/>
|
|
55
59
|
|
|
56
|
-
<button v-bind="button">
|
|
60
|
+
<button v-bind="button" type="button">
|
|
57
61
|
<template v-if="isExpanded">⬆️</template>
|
|
58
62
|
<template v-else>⬇️</template>
|
|
59
63
|
</button>
|
|
60
|
-
<ul v-bind="listbox" :class="{ hidden: !isExpanded }"
|
|
64
|
+
<ul class="listbox" v-bind="listbox" :class="{ hidden: !isExpanded }">
|
|
61
65
|
<li
|
|
62
66
|
v-for="e in options"
|
|
63
67
|
:key="e"
|
|
@@ -80,4 +84,7 @@ defineExpose({ comboBox });
|
|
|
80
84
|
[aria-selected="true"] {
|
|
81
85
|
background-color: red;
|
|
82
86
|
}
|
|
87
|
+
.listbox {
|
|
88
|
+
width: 400px;
|
|
89
|
+
}
|
|
83
90
|
</style>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { test } from "@playwright/experimental-ct-vue";
|
|
2
|
-
import { comboboxSelectOnlyTesting, comboboxTesting } from "./createComboBox.testing";
|
|
2
|
+
import { comboboxSelectOnlyTesting, comboboxTesting } from "./createComboBox.testing.js";
|
|
3
3
|
import SelectOnlyCombobox from "./SelectOnlyCombobox.vue";
|
|
4
4
|
import TestCombobox from "./TestCombobox.vue";
|
|
5
5
|
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { computed, ref } from "vue";
|
|
3
|
-
import { createComboBox } from "./createComboBox";
|
|
2
|
+
import { computed, ref, useTemplateRef } from "vue";
|
|
3
|
+
import { createComboBox } from "./createComboBox.js";
|
|
4
4
|
|
|
5
5
|
const options = ["a", "b", "c", "d"];
|
|
6
6
|
const isExpanded = ref(false);
|
|
7
7
|
const searchTerm = ref("");
|
|
8
|
-
const comboboxRef =
|
|
8
|
+
const comboboxRef = useTemplateRef("combobox");
|
|
9
9
|
const activeOption = ref("");
|
|
10
10
|
const filteredOptions = computed(() => options.filter((v) => v.includes(searchTerm.value)));
|
|
11
11
|
const selectedIndex = computed<number | undefined>(() => {
|
|
@@ -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",
|
|
@@ -54,14 +53,14 @@ defineExpose({ comboBox });
|
|
|
54
53
|
</script>
|
|
55
54
|
|
|
56
55
|
<template>
|
|
57
|
-
<div ref="
|
|
58
|
-
<input v-bind="input" @keydown.arrow-down="isExpanded = true" />
|
|
56
|
+
<div ref="combobox">
|
|
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,16 @@
|
|
|
1
|
-
import { computed, unref, type MaybeRef, type Ref } from "vue";
|
|
2
|
-
import { createBuilder } from "../../utils/builder";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import { useOutsideClick } from "../helpers/useOutsideClick";
|
|
6
|
-
import { useTypeAhead } from "../helpers/useTypeAhead";
|
|
1
|
+
import { computed, unref, useId, type MaybeRef, type Ref } from "vue";
|
|
2
|
+
import { createBuilder } from "../../utils/builder.js";
|
|
3
|
+
import { isPrintableCharacter, wasKeyPressed, type PressedKey } from "../../utils/keyboard.js";
|
|
4
|
+
import type { Nullable } from "../../utils/types.js";
|
|
5
|
+
import { useOutsideClick } from "../helpers/useOutsideClick.js";
|
|
6
|
+
import { useTypeAhead } from "../helpers/useTypeAhead.js";
|
|
7
7
|
import {
|
|
8
8
|
createListbox,
|
|
9
9
|
type CreateListboxOptions,
|
|
10
10
|
type ListboxValue,
|
|
11
|
-
} from "../listbox/createListbox";
|
|
11
|
+
} from "../listbox/createListbox.js";
|
|
12
12
|
|
|
13
|
+
/** See https://w3c.github.io/aria/#aria-autocomplete */
|
|
13
14
|
export type ComboboxAutoComplete = "none" | "list" | "both";
|
|
14
15
|
|
|
15
16
|
export const OPENING_KEYS: PressedKey[] = ["ArrowDown", "ArrowUp", " ", "Enter", "Home", "End"];
|
|
@@ -19,11 +20,15 @@ export const CLOSING_KEYS: PressedKey[] = [
|
|
|
19
20
|
"Enter",
|
|
20
21
|
"Tab",
|
|
21
22
|
];
|
|
22
|
-
const SELECTING_KEYS_SINGLE: PressedKey[] = ["Enter", " "];
|
|
23
|
-
const SELECTING_KEYS_MULTIPLE: PressedKey[] = ["Enter"];
|
|
24
23
|
|
|
25
|
-
const
|
|
26
|
-
|
|
24
|
+
const SELECTING_KEYS: PressedKey[] = ["Enter"];
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* if the a search input is included, space should not be used to select
|
|
28
|
+
* TODO: idea for the future: move this distinction to the listbox?
|
|
29
|
+
*/
|
|
30
|
+
const isSelectingKey = (event: KeyboardEvent, withSpace?: boolean) => {
|
|
31
|
+
const selectingKeys = withSpace ? [...SELECTING_KEYS, " "] : SELECTING_KEYS;
|
|
27
32
|
return isKeyOfGroup(event, selectingKeys);
|
|
28
33
|
};
|
|
29
34
|
|
|
@@ -42,9 +47,9 @@ export type CreateComboboxOptions<
|
|
|
42
47
|
*/
|
|
43
48
|
listLabel: MaybeRef<string>;
|
|
44
49
|
/**
|
|
45
|
-
*
|
|
50
|
+
* Provides additional description for the listbox which displays the available options.
|
|
46
51
|
*/
|
|
47
|
-
|
|
52
|
+
listDescription?: MaybeRef<Nullable<string>>;
|
|
48
53
|
/**
|
|
49
54
|
* Controls the opened/visible state of the associated pop-up. When expanded the activeOption can be controlled via the keyboard.
|
|
50
55
|
*/
|
|
@@ -52,11 +57,11 @@ export type CreateComboboxOptions<
|
|
|
52
57
|
/**
|
|
53
58
|
* If expanded, the active option is the currently highlighted option of the controlled listbox.
|
|
54
59
|
*/
|
|
55
|
-
activeOption: Ref<TValue
|
|
60
|
+
activeOption: Ref<Nullable<TValue>>;
|
|
56
61
|
/**
|
|
57
62
|
* Template ref to the component root (required to close combobox on outside click).
|
|
58
63
|
*/
|
|
59
|
-
templateRef: Ref<HTMLElement
|
|
64
|
+
templateRef: Ref<Nullable<HTMLElement>>;
|
|
60
65
|
/**
|
|
61
66
|
* Hook when the popover should toggle.
|
|
62
67
|
*
|
|
@@ -105,13 +110,13 @@ export const createComboBox = createBuilder(
|
|
|
105
110
|
TAutoComplete extends ComboboxAutoComplete,
|
|
106
111
|
TMultiple extends boolean = false,
|
|
107
112
|
>({
|
|
108
|
-
inputValue,
|
|
109
113
|
autocomplete: autocompleteRef,
|
|
110
114
|
onAutocomplete,
|
|
111
115
|
onTypeAhead,
|
|
112
116
|
multiple: multipleRef,
|
|
113
117
|
label,
|
|
114
118
|
listLabel,
|
|
119
|
+
listDescription,
|
|
115
120
|
isExpanded: isExpandedRef,
|
|
116
121
|
activeOption,
|
|
117
122
|
onToggle,
|
|
@@ -122,7 +127,7 @@ export const createComboBox = createBuilder(
|
|
|
122
127
|
onActivatePrevious,
|
|
123
128
|
templateRef,
|
|
124
129
|
}: CreateComboboxOptions<TValue, TAutoComplete, TMultiple>) => {
|
|
125
|
-
const controlsId =
|
|
130
|
+
const controlsId = useId();
|
|
126
131
|
|
|
127
132
|
const autocomplete = computed(() => unref(autocompleteRef));
|
|
128
133
|
const isExpanded = computed(() => unref(isExpandedRef));
|
|
@@ -188,7 +193,7 @@ export const createComboBox = createBuilder(
|
|
|
188
193
|
}
|
|
189
194
|
return onActivateFirst?.();
|
|
190
195
|
}
|
|
191
|
-
if (isSelectingKey(event,
|
|
196
|
+
if (isSelectingKey(event, autocomplete.value === "none")) {
|
|
192
197
|
return handleSelect(activeOption.value!);
|
|
193
198
|
}
|
|
194
199
|
if (isExpanded.value && isKeyOfGroup(event, CLOSING_KEYS)) {
|
|
@@ -205,27 +210,29 @@ export const createComboBox = createBuilder(
|
|
|
205
210
|
return handleNavigation(event);
|
|
206
211
|
};
|
|
207
212
|
|
|
208
|
-
const autocompleteInput =
|
|
209
|
-
autocomplete.value
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
213
|
+
const autocompleteInput = computed(() => {
|
|
214
|
+
if (autocomplete.value === "none") return null;
|
|
215
|
+
return {
|
|
216
|
+
"aria-autocomplete": autocomplete.value,
|
|
217
|
+
type: "text",
|
|
218
|
+
};
|
|
219
|
+
});
|
|
215
220
|
|
|
216
221
|
const {
|
|
217
222
|
elements: { option, group, listbox },
|
|
218
223
|
internals: { getOptionId },
|
|
219
224
|
} = createListbox({
|
|
220
225
|
label: listLabel,
|
|
226
|
+
description: listDescription,
|
|
221
227
|
multiple,
|
|
222
228
|
controlled: true,
|
|
223
229
|
activeOption,
|
|
230
|
+
isExpanded,
|
|
224
231
|
onSelect: handleSelect,
|
|
225
232
|
});
|
|
226
233
|
|
|
227
234
|
useOutsideClick({
|
|
228
|
-
|
|
235
|
+
inside: templateRef,
|
|
229
236
|
onOutsideClick() {
|
|
230
237
|
if (!isExpanded.value) return;
|
|
231
238
|
onToggle?.(true);
|
|
@@ -250,7 +257,6 @@ export const createComboBox = createBuilder(
|
|
|
250
257
|
* 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
258
|
*/
|
|
252
259
|
input: computed(() => ({
|
|
253
|
-
value: inputValue.value,
|
|
254
260
|
role: "combobox",
|
|
255
261
|
"aria-expanded": isExpanded.value,
|
|
256
262
|
"aria-controls": controlsId,
|
|
@@ -259,7 +265,7 @@ export const createComboBox = createBuilder(
|
|
|
259
265
|
activeOption.value != undefined ? getOptionId(activeOption.value) : undefined,
|
|
260
266
|
onInput: handleInput,
|
|
261
267
|
onKeydown: handleKeydown,
|
|
262
|
-
...autocompleteInput,
|
|
268
|
+
...autocompleteInput.value,
|
|
263
269
|
})),
|
|
264
270
|
/**
|
|
265
271
|
* An optional button to control the visibility of the popup.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { computed, type Ref } from "vue";
|
|
2
|
+
import { useGlobalEventListener } from "./useGlobalListener.js";
|
|
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
|
+
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
import { ref, type Ref } from "vue";
|
|
3
|
-
import { mockVueLifecycle } from "../../utils/vitest";
|
|
4
|
-
import { useGlobalEventListener } from "./useGlobalListener";
|
|
3
|
+
import { mockVueLifecycle } from "../../utils/vitest.js";
|
|
4
|
+
import { useGlobalEventListener } from "./useGlobalListener.js";
|
|
5
5
|
|
|
6
6
|
let unmount: () => Promise<void> | undefined;
|
|
7
7
|
|
|
@@ -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;
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { ref } from "vue";
|
|
3
|
+
import { mockVueLifecycle } from "../../utils/vitest.js";
|
|
4
|
+
import { useOutsideClick } from "./useOutsideClick.js";
|
|
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", async () => {
|
|
17
|
+
// ARRANGE
|
|
18
|
+
vi.useFakeTimers();
|
|
19
|
+
const inside = ref(document.createElement("button"));
|
|
20
|
+
document.body.appendChild(inside.value);
|
|
21
|
+
const outside = ref(document.createElement("button"));
|
|
22
|
+
document.body.appendChild(outside.value);
|
|
23
|
+
|
|
24
|
+
const onOutsideClick = vi.fn();
|
|
25
|
+
useOutsideClick({ inside, onOutsideClick });
|
|
26
|
+
|
|
27
|
+
// ACT
|
|
28
|
+
const event = new MouseEvent("mousedown", { bubbles: true });
|
|
29
|
+
outside.value.dispatchEvent(event);
|
|
30
|
+
|
|
31
|
+
// ASSERT
|
|
32
|
+
expect(onOutsideClick).toHaveBeenCalledTimes(1);
|
|
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);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("should detect outside clicks correctly for multiple inside elements", () => {
|
|
47
|
+
// ARRANGE
|
|
48
|
+
const inside = [document.createElement("button"), document.createElement("button")];
|
|
49
|
+
inside.forEach((e) => document.body.appendChild(e));
|
|
50
|
+
const outside = ref(document.createElement("button"));
|
|
51
|
+
document.body.appendChild(outside.value);
|
|
52
|
+
|
|
53
|
+
const onOutsideClick = vi.fn();
|
|
54
|
+
useOutsideClick({ inside, onOutsideClick });
|
|
55
|
+
// ACT
|
|
56
|
+
const event = new MouseEvent("mousedown", { bubbles: true });
|
|
57
|
+
inside[0].dispatchEvent(event);
|
|
58
|
+
inside[1].dispatchEvent(event);
|
|
59
|
+
// ASSERT
|
|
60
|
+
expect(onOutsideClick).not.toHaveBeenCalled();
|
|
61
|
+
|
|
62
|
+
// ACT
|
|
63
|
+
outside.value.dispatchEvent(event);
|
|
64
|
+
// ASSERT
|
|
65
|
+
expect(onOutsideClick).toHaveBeenCalledTimes(1);
|
|
66
|
+
expect(onOutsideClick).toBeCalledWith(event);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("should ignore outside clicks when disabled", async () => {
|
|
70
|
+
// ARRANGE
|
|
71
|
+
vi.useFakeTimers();
|
|
72
|
+
const inside = ref(document.createElement("button"));
|
|
73
|
+
document.body.appendChild(inside.value);
|
|
74
|
+
const outside = ref(document.createElement("button"));
|
|
75
|
+
document.body.appendChild(outside.value);
|
|
76
|
+
|
|
77
|
+
const disabled = ref(false);
|
|
78
|
+
const onOutsideClick = vi.fn();
|
|
79
|
+
useOutsideClick({ inside, disabled, onOutsideClick });
|
|
80
|
+
|
|
81
|
+
// ACT
|
|
82
|
+
const event = new MouseEvent("mousedown", { bubbles: true });
|
|
83
|
+
outside.value.dispatchEvent(event);
|
|
84
|
+
// ASSERT
|
|
85
|
+
expect(onOutsideClick).toHaveBeenCalledTimes(1);
|
|
86
|
+
expect(onOutsideClick).toBeCalledWith(event);
|
|
87
|
+
|
|
88
|
+
// ACT
|
|
89
|
+
disabled.value = true;
|
|
90
|
+
await vi.runAllTimersAsync();
|
|
91
|
+
const event2 = new MouseEvent("mousedown", { bubbles: true });
|
|
92
|
+
outside.value.dispatchEvent(event2);
|
|
93
|
+
// ASSERT
|
|
94
|
+
expect(onOutsideClick).toHaveBeenCalledTimes(1);
|
|
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
|
+
});
|
|
117
|
+
});
|
|
@@ -1,15 +1,23 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import type { Arrayable } from "vitest"; // For an unknown reason removing this import will break the build of "demo-app" and "playground"
|
|
2
|
+
import { toValue, type MaybeRefOrGetter, type Ref } from "vue";
|
|
3
|
+
import type { Nullable } from "../../utils/types.js";
|
|
4
|
+
import { useGlobalEventListener } from "./useGlobalListener.js";
|
|
3
5
|
|
|
4
|
-
export type UseOutsideClickOptions = {
|
|
6
|
+
export type UseOutsideClickOptions<TCheckOnTab extends boolean | undefined = undefined> = {
|
|
5
7
|
/**
|
|
6
8
|
* HTML element of the component where clicks should be ignored
|
|
7
9
|
*/
|
|
8
|
-
|
|
10
|
+
inside: MaybeRefOrGetter<Arrayable<Nullable<HTMLElement>>>;
|
|
9
11
|
/**
|
|
10
12
|
* Callback when an outside click occurred.
|
|
11
13
|
*/
|
|
12
|
-
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;
|
|
13
21
|
/**
|
|
14
22
|
* If `true`, event listeners will be removed and no outside clicks will be captured.
|
|
15
23
|
*/
|
|
@@ -20,15 +28,42 @@ export type UseOutsideClickOptions = {
|
|
|
20
28
|
* Composable for listening to click events that occur outside of a component.
|
|
21
29
|
* Useful to e.g. close flyouts or tooltips.
|
|
22
30
|
*/
|
|
23
|
-
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
|
+
|
|
24
44
|
/**
|
|
25
45
|
* Document click handle that closes then tooltip when clicked outside.
|
|
26
46
|
* Should only be called when trigger is "click".
|
|
27
47
|
*/
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
if (isOutsideClick) onOutsideClick();
|
|
48
|
+
const clickListener = (event: MouseEvent) => {
|
|
49
|
+
if (isOutsideClick(event.target)) onOutsideClick(event);
|
|
31
50
|
};
|
|
32
51
|
|
|
33
|
-
useGlobalEventListener({ type: "
|
|
52
|
+
useGlobalEventListener({ type: "mousedown", 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
|
+
}
|
|
34
69
|
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { isPrintableCharacter } from "../../utils/keyboard";
|
|
2
|
-
import { debounce } from "../../utils/timer";
|
|
1
|
+
import { isPrintableCharacter } from "../../utils/keyboard.js";
|
|
2
|
+
import { debounce } from "../../utils/timer.js";
|
|
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.testing";
|
|
3
|
+
import { listboxTesting } from "./createListbox.testing.js";
|
|
4
4
|
|
|
5
5
|
test("listbox", async ({ mount, page }) => {
|
|
6
6
|
await mount(<TestListbox />);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<script lang="ts" setup>
|
|
2
2
|
import { ref } from "vue";
|
|
3
|
-
import { createListbox } from "./createListbox";
|
|
3
|
+
import { createListbox } from "./createListbox.js";
|
|
4
4
|
|
|
5
5
|
type Options = (typeof options)[number];
|
|
6
6
|
|
|
@@ -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
|
},
|