@sit-onyx/headless 1.0.0-beta.2 → 1.0.0-beta.20
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 +8 -3
- package/src/composables/comboBox/SelectOnlyCombobox.vue +14 -7
- package/src/composables/comboBox/TestCombobox.vue +12 -9
- package/src/composables/comboBox/createComboBox.ts +29 -23
- package/src/composables/helpers/useDismissible.ts +19 -0
- package/src/composables/helpers/useGlobalListener.ts +1 -1
- package/src/composables/helpers/useOutsideClick.spec.ts +117 -0
- package/src/composables/helpers/useOutsideClick.ts +44 -9
- package/src/composables/listbox/TestListbox.vue +2 -0
- package/src/composables/listbox/createListbox.ts +27 -9
- package/src/composables/menuButton/TestMenuButton.vue +3 -2
- package/src/composables/menuButton/createMenuButton.testing.ts +0 -19
- package/src/composables/menuButton/createMenuButton.ts +172 -117
- package/src/composables/navigationMenu/createMenu.testing.ts +2 -13
- package/src/composables/navigationMenu/createMenu.ts +4 -5
- 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 +38 -96
- package/src/index.ts +4 -1
- package/src/playwright.ts +2 -0
- package/src/utils/builder.ts +107 -11
- 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,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.20",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "Schwarz IT KG",
|
|
7
7
|
"license": "Apache-2.0",
|
|
@@ -24,11 +24,16 @@
|
|
|
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.16",
|
|
31
|
+
"vue": "3.5.16",
|
|
32
|
+
"@sit-onyx/shared": "^1.0.0-beta.3"
|
|
28
33
|
},
|
|
29
34
|
"scripts": {
|
|
30
35
|
"build": "vue-tsc --build --force",
|
|
31
36
|
"test": "vitest",
|
|
32
|
-
"test:
|
|
37
|
+
"test:playwright": "playwright install && playwright test"
|
|
33
38
|
}
|
|
34
39
|
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { computed, ref } from "vue";
|
|
2
|
+
import { computed, ref, useTemplateRef } from "vue";
|
|
3
3
|
import { createComboBox } from "./createComboBox";
|
|
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,11 +1,11 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { computed, ref } from "vue";
|
|
2
|
+
import { computed, ref, useTemplateRef } from "vue";
|
|
3
3
|
import { createComboBox } from "./createComboBox";
|
|
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,7 +1,7 @@
|
|
|
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 type { Nullable } from "../../utils/types";
|
|
5
5
|
import { useOutsideClick } from "../helpers/useOutsideClick";
|
|
6
6
|
import { useTypeAhead } from "../helpers/useTypeAhead";
|
|
7
7
|
import {
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
type ListboxValue,
|
|
11
11
|
} from "../listbox/createListbox";
|
|
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";
|
|
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
|
+
});
|
|
@@ -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";
|
|
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", 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 {
|
|
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";
|
|
2
4
|
import { useGlobalEventListener } from "./useGlobalListener";
|
|
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
|
};
|
|
@@ -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,6 +1,6 @@
|
|
|
1
|
-
import { computed, ref, unref, watchEffect, type MaybeRef, type Ref } from "vue";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { computed, nextTick, ref, unref, useId, watchEffect, type MaybeRef, type Ref } from "vue";
|
|
2
|
+
import { createBuilder, type VBindAttributes } from "../../utils/builder";
|
|
3
|
+
import type { Nullable } from "../../utils/types";
|
|
4
4
|
import { useTypeAhead } from "../helpers/useTypeAhead";
|
|
5
5
|
|
|
6
6
|
export type ListboxValue = string | number | boolean;
|
|
@@ -10,19 +10,27 @@ export type CreateListboxOptions<TValue extends ListboxValue, TMultiple extends
|
|
|
10
10
|
* Aria label for the listbox.
|
|
11
11
|
*/
|
|
12
12
|
label: MaybeRef<string>;
|
|
13
|
+
/**
|
|
14
|
+
* Aria description for the listbox.
|
|
15
|
+
*/
|
|
16
|
+
description?: MaybeRef<Nullable<string>>;
|
|
13
17
|
/**
|
|
14
18
|
* Value of currently (visually) active option.
|
|
15
19
|
*/
|
|
16
|
-
activeOption: Ref<TValue
|
|
20
|
+
activeOption: Ref<Nullable<TValue>>;
|
|
17
21
|
/**
|
|
18
22
|
* Wether the listbox is controlled from the outside, e.g. by a combobox.
|
|
19
23
|
* This disables keyboard events and makes the listbox not focusable.
|
|
20
24
|
*/
|
|
21
25
|
controlled?: boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Controls the opened/visible state of the listbox. When expanded the activeOption can be controlled via the keyboard.
|
|
28
|
+
*/
|
|
29
|
+
isExpanded?: MaybeRef<boolean>;
|
|
22
30
|
/**
|
|
23
31
|
* Whether the listbox is multiselect.
|
|
24
32
|
*/
|
|
25
|
-
multiple?: MaybeRef<TMultiple
|
|
33
|
+
multiple?: MaybeRef<Nullable<TMultiple>>;
|
|
26
34
|
/**
|
|
27
35
|
* Hook when an option is selected.
|
|
28
36
|
*/
|
|
@@ -77,6 +85,7 @@ export const createListbox = createBuilder(
|
|
|
77
85
|
options: CreateListboxOptions<TValue, TMultiple>,
|
|
78
86
|
) => {
|
|
79
87
|
const isMultiselect = computed(() => unref(options.multiple) ?? false);
|
|
88
|
+
const isExpanded = computed(() => unref(options.isExpanded) ?? false);
|
|
80
89
|
|
|
81
90
|
/**
|
|
82
91
|
* Map for option IDs. key = option value, key = ID for the HTML element
|
|
@@ -85,7 +94,7 @@ export const createListbox = createBuilder(
|
|
|
85
94
|
|
|
86
95
|
const getOptionId = (value: TValue) => {
|
|
87
96
|
if (!descendantKeyIdMap.has(value)) {
|
|
88
|
-
descendantKeyIdMap.set(value,
|
|
97
|
+
descendantKeyIdMap.set(value, useId());
|
|
89
98
|
}
|
|
90
99
|
return descendantKeyIdMap.get(value)!;
|
|
91
100
|
};
|
|
@@ -96,10 +105,17 @@ export const createListbox = createBuilder(
|
|
|
96
105
|
const isFocused = ref(false);
|
|
97
106
|
|
|
98
107
|
// scroll currently active option into view if needed
|
|
99
|
-
watchEffect(() => {
|
|
100
|
-
if (
|
|
108
|
+
watchEffect(async () => {
|
|
109
|
+
if (
|
|
110
|
+
!isExpanded.value ||
|
|
111
|
+
options.activeOption.value == undefined ||
|
|
112
|
+
(!isFocused.value && !options.controlled)
|
|
113
|
+
) {
|
|
101
114
|
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
102
117
|
const id = getOptionId(options.activeOption.value);
|
|
118
|
+
await nextTick();
|
|
103
119
|
document.getElementById(id)?.scrollIntoView({ block: "nearest", inline: "nearest" });
|
|
104
120
|
});
|
|
105
121
|
|
|
@@ -152,18 +168,20 @@ export const createListbox = createBuilder(
|
|
|
152
168
|
}
|
|
153
169
|
};
|
|
154
170
|
|
|
155
|
-
const listbox = computed<
|
|
171
|
+
const listbox = computed<VBindAttributes>(() =>
|
|
156
172
|
options.controlled
|
|
157
173
|
? {
|
|
158
174
|
role: "listbox",
|
|
159
175
|
"aria-multiselectable": isMultiselect.value,
|
|
160
176
|
"aria-label": unref(options.label),
|
|
177
|
+
"aria-description": options.description,
|
|
161
178
|
tabindex: "-1",
|
|
162
179
|
}
|
|
163
180
|
: {
|
|
164
181
|
role: "listbox",
|
|
165
182
|
"aria-multiselectable": isMultiselect.value,
|
|
166
183
|
"aria-label": unref(options.label),
|
|
184
|
+
"aria-description": options.description,
|
|
167
185
|
tabindex: "0",
|
|
168
186
|
"aria-activedescendant":
|
|
169
187
|
options.activeOption.value != undefined
|
|
@@ -10,15 +10,16 @@ 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 = ref<"click" | "hover">("hover");
|
|
13
14
|
|
|
14
15
|
const {
|
|
15
16
|
elements: { root, button, menu, menuItem, listItem },
|
|
16
|
-
} = createMenuButton({ isExpanded, onToggle });
|
|
17
|
+
} = createMenuButton({ isExpanded, onToggle, trigger });
|
|
17
18
|
</script>
|
|
18
19
|
|
|
19
20
|
<template>
|
|
20
21
|
<div v-bind="root">
|
|
21
|
-
<button v-bind="button">Toggle nav menu</button>
|
|
22
|
+
<button v-bind="button" type="button">Toggle nav menu</button>
|
|
22
23
|
<ul v-show="isExpanded" v-bind="menu">
|
|
23
24
|
<li v-for="item in items" v-bind="listItem" :key="item.value">
|
|
24
25
|
<a v-bind="menuItem({ active: activeItem === item.value })" href="#">{{ item.label }}</a>
|