@sit-onyx/headless 0.1.0-alpha.7 → 1.0.0-alpha.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/composables/comboBox/SelectOnlyCombobox.vue +83 -0
- package/src/composables/comboBox/TestCombobox.ct.tsx +12 -1
- package/src/composables/comboBox/TestCombobox.vue +26 -21
- package/src/composables/comboBox/createComboBox.ct.ts +100 -4
- package/src/composables/comboBox/createComboBox.ts +156 -50
- package/src/composables/listbox/TestListbox.vue +0 -1
- package/src/composables/listbox/createListbox.ts +28 -21
- package/src/composables/menuButton/TestMenuButton.ct.tsx +14 -0
- package/src/composables/menuButton/TestMenuButton.vue +33 -0
- package/src/composables/menuButton/createMenuButton.ct.ts +54 -0
- package/src/composables/menuButton/createMenuButton.ts +69 -0
- package/src/composables/outsideClick.ts +52 -0
- package/src/composables/tooltip/createTooltip.ts +9 -34
- package/src/index.ts +2 -0
- package/src/playwright.ts +1 -0
- package/src/utils/keyboard.spec.ts +36 -1
- package/src/utils/keyboard.ts +23 -0
- package/src/utils/object.spec.ts +33 -0
- package/src/utils/object.ts +8 -0
- package/src/utils/types.ts +21 -6
package/package.json
CHANGED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, ref } from "vue";
|
|
3
|
+
import { createComboBox } from "./createComboBox";
|
|
4
|
+
|
|
5
|
+
const options = ["a", "b", "c", "d"];
|
|
6
|
+
const isExpanded = ref(false);
|
|
7
|
+
const comboboxRef = ref<HTMLElement>();
|
|
8
|
+
const activeOption = ref("");
|
|
9
|
+
const selectedOption = ref("");
|
|
10
|
+
const selectedIndex = computed<number | undefined>(() => {
|
|
11
|
+
const index = options.indexOf(activeOption.value);
|
|
12
|
+
return index !== -1 ? index : undefined;
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const onActivateFirst = () => (activeOption.value = options[0]);
|
|
16
|
+
const onActivateLast = () => (activeOption.value = options[options.length - 1]);
|
|
17
|
+
const onActivateNext = () => {
|
|
18
|
+
if (selectedIndex.value === undefined) {
|
|
19
|
+
return onActivateFirst();
|
|
20
|
+
}
|
|
21
|
+
activeOption.value = options[selectedIndex.value + (1 % (options.length - 1))];
|
|
22
|
+
};
|
|
23
|
+
const onActivatePrevious = () => (activeOption.value = options[(selectedIndex.value ?? 0) - 1]);
|
|
24
|
+
const onSelect = (newValue: string) => (selectedOption.value = newValue);
|
|
25
|
+
const onToggle = () => (isExpanded.value = !isExpanded.value);
|
|
26
|
+
const onTypeAhead = () => {};
|
|
27
|
+
|
|
28
|
+
const comboBox = createComboBox({
|
|
29
|
+
inputValue: selectedOption,
|
|
30
|
+
autocomplete: "none",
|
|
31
|
+
label: "some label",
|
|
32
|
+
listLabel: "List",
|
|
33
|
+
activeOption,
|
|
34
|
+
isExpanded,
|
|
35
|
+
templateRef: comboboxRef,
|
|
36
|
+
onToggle,
|
|
37
|
+
onTypeAhead,
|
|
38
|
+
onActivateFirst,
|
|
39
|
+
onActivateLast,
|
|
40
|
+
onActivateNext,
|
|
41
|
+
onActivatePrevious,
|
|
42
|
+
onSelect,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const {
|
|
46
|
+
elements: { input, listbox, button, option },
|
|
47
|
+
} = comboBox;
|
|
48
|
+
|
|
49
|
+
defineExpose({ comboBox });
|
|
50
|
+
</script>
|
|
51
|
+
|
|
52
|
+
<template>
|
|
53
|
+
<div ref="comboboxRef">
|
|
54
|
+
<input v-bind="input" readonly @keydown.arrow-down="isExpanded = true" />
|
|
55
|
+
|
|
56
|
+
<button v-bind="button">
|
|
57
|
+
<template v-if="isExpanded">⬆️</template>
|
|
58
|
+
<template v-else>⬇️</template>
|
|
59
|
+
</button>
|
|
60
|
+
<ul v-bind="listbox" :class="{ hidden: !isExpanded }" style="width: 400px">
|
|
61
|
+
<li
|
|
62
|
+
v-for="e in options"
|
|
63
|
+
:key="e"
|
|
64
|
+
v-bind="option({ value: e, label: e, disabled: false, selected: e === selectedOption })"
|
|
65
|
+
:class="{ active: e === activeOption }"
|
|
66
|
+
>
|
|
67
|
+
{{ e }}
|
|
68
|
+
</li>
|
|
69
|
+
</ul>
|
|
70
|
+
</div>
|
|
71
|
+
</template>
|
|
72
|
+
|
|
73
|
+
<style>
|
|
74
|
+
.hidden {
|
|
75
|
+
display: none;
|
|
76
|
+
}
|
|
77
|
+
.active {
|
|
78
|
+
outline: 2px solid black;
|
|
79
|
+
}
|
|
80
|
+
[aria-selected="true"] {
|
|
81
|
+
background-color: red;
|
|
82
|
+
}
|
|
83
|
+
</style>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { test } from "@playwright/experimental-ct-vue";
|
|
2
2
|
import TestCombobox from "./TestCombobox.vue";
|
|
3
|
-
import { comboboxTesting } from "./createComboBox.ct";
|
|
3
|
+
import { comboboxTesting, comboboxSelectOnlyTesting } from "./createComboBox.ct";
|
|
4
|
+
import SelectOnlyCombobox from "./SelectOnlyCombobox.vue";
|
|
4
5
|
|
|
5
6
|
test("combobox", async ({ mount, page }) => {
|
|
6
7
|
await mount(<TestCombobox />);
|
|
@@ -11,3 +12,13 @@ test("combobox", async ({ mount, page }) => {
|
|
|
11
12
|
|
|
12
13
|
await comboboxTesting(page, listbox, combobox, button, options);
|
|
13
14
|
});
|
|
15
|
+
|
|
16
|
+
test("select only combobox", async ({ mount, page }) => {
|
|
17
|
+
await mount(<SelectOnlyCombobox />);
|
|
18
|
+
const listbox = page.getByRole("listbox");
|
|
19
|
+
const combobox = page.getByRole("combobox");
|
|
20
|
+
|
|
21
|
+
await comboboxSelectOnlyTesting(page, listbox, combobox, (loc) =>
|
|
22
|
+
loc.evaluate((e) => e.classList.contains("active")),
|
|
23
|
+
);
|
|
24
|
+
});
|
|
@@ -2,33 +2,43 @@
|
|
|
2
2
|
import { computed, ref } from "vue";
|
|
3
3
|
import { createComboBox } from "./createComboBox";
|
|
4
4
|
|
|
5
|
-
const
|
|
5
|
+
const options = ["a", "b", "c", "d"];
|
|
6
6
|
const isExpanded = ref(false);
|
|
7
|
-
const
|
|
7
|
+
const searchTerm = ref("");
|
|
8
|
+
const comboboxRef = ref<HTMLElement>();
|
|
8
9
|
const activeOption = ref("");
|
|
10
|
+
const filteredOptions = computed(() => options.filter((v) => v.includes(searchTerm.value)));
|
|
9
11
|
const selectedIndex = computed<number | undefined>(() => {
|
|
10
|
-
const index =
|
|
12
|
+
const index = filteredOptions.value.indexOf(activeOption.value);
|
|
11
13
|
return index !== -1 ? index : undefined;
|
|
12
14
|
});
|
|
13
15
|
|
|
14
|
-
const onActivateFirst = () => (activeOption.value =
|
|
15
|
-
const onActivateLast = () =>
|
|
16
|
+
const onActivateFirst = () => (activeOption.value = filteredOptions.value[0]);
|
|
17
|
+
const onActivateLast = () =>
|
|
18
|
+
(activeOption.value = filteredOptions.value[filteredOptions.value.length - 1]);
|
|
16
19
|
const onActivateNext = () => {
|
|
17
20
|
if (selectedIndex.value === undefined) {
|
|
18
21
|
return onActivateFirst();
|
|
19
22
|
}
|
|
20
|
-
activeOption.value =
|
|
23
|
+
activeOption.value =
|
|
24
|
+
filteredOptions.value[selectedIndex.value + (1 % (filteredOptions.value.length - 1))];
|
|
21
25
|
};
|
|
22
|
-
const onActivatePrevious = () =>
|
|
23
|
-
|
|
26
|
+
const onActivatePrevious = () =>
|
|
27
|
+
(activeOption.value = filteredOptions.value[(selectedIndex.value ?? 0) - 1]);
|
|
28
|
+
const onSelect = (newValue: string) => (searchTerm.value = newValue);
|
|
29
|
+
const onAutocomplete = (input: string) => (searchTerm.value = input);
|
|
24
30
|
const onToggle = () => (isExpanded.value = !isExpanded.value);
|
|
25
31
|
|
|
26
32
|
const comboBox = createComboBox({
|
|
33
|
+
inputValue: searchTerm,
|
|
34
|
+
autocomplete: "list",
|
|
35
|
+
label: "some label",
|
|
27
36
|
listLabel: "List",
|
|
28
37
|
activeOption,
|
|
29
|
-
inputValue,
|
|
30
38
|
isExpanded,
|
|
39
|
+
templateRef: comboboxRef,
|
|
31
40
|
onToggle,
|
|
41
|
+
onAutocomplete,
|
|
32
42
|
onActivateFirst,
|
|
33
43
|
onActivateLast,
|
|
34
44
|
onActivateNext,
|
|
@@ -37,29 +47,23 @@ const comboBox = createComboBox({
|
|
|
37
47
|
});
|
|
38
48
|
|
|
39
49
|
const {
|
|
40
|
-
elements: { input,
|
|
50
|
+
elements: { input, listbox, button, option },
|
|
41
51
|
} = comboBox;
|
|
42
52
|
|
|
43
53
|
defineExpose({ comboBox });
|
|
44
54
|
</script>
|
|
55
|
+
|
|
45
56
|
<template>
|
|
46
|
-
<div>
|
|
47
|
-
<
|
|
48
|
-
some label:
|
|
49
|
-
<input
|
|
50
|
-
v-bind="input"
|
|
51
|
-
@keydown.arrow-down="isExpanded = true"
|
|
52
|
-
@keydown.esc="isExpanded = false"
|
|
53
|
-
/>
|
|
54
|
-
</label>
|
|
57
|
+
<div ref="comboboxRef">
|
|
58
|
+
<input v-bind="input" @keydown.arrow-down="isExpanded = true" />
|
|
55
59
|
|
|
56
60
|
<button v-bind="button">
|
|
57
61
|
<template v-if="isExpanded">⬆️</template>
|
|
58
62
|
<template v-else>⬇️</template>
|
|
59
63
|
</button>
|
|
60
|
-
<ul v-bind="
|
|
64
|
+
<ul v-bind="listbox" :class="{ hidden: !isExpanded }" style="width: 400px">
|
|
61
65
|
<li
|
|
62
|
-
v-for="e in
|
|
66
|
+
v-for="e in filteredOptions"
|
|
63
67
|
:key="e"
|
|
64
68
|
v-bind="option({ value: e, label: e, disabled: false })"
|
|
65
69
|
:style="{ 'background-color': e === activeOption ? 'red' : undefined }"
|
|
@@ -69,6 +73,7 @@ defineExpose({ comboBox });
|
|
|
69
73
|
</ul>
|
|
70
74
|
</div>
|
|
71
75
|
</template>
|
|
76
|
+
|
|
72
77
|
<style>
|
|
73
78
|
.hidden {
|
|
74
79
|
display: none;
|
|
@@ -1,11 +1,42 @@
|
|
|
1
|
-
import { expect } from "@playwright/experimental-ct-vue";
|
|
1
|
+
import { expect, test } from "@playwright/experimental-ct-vue";
|
|
2
2
|
import type { Locator, Page } from "@playwright/test";
|
|
3
3
|
|
|
4
|
+
const expectToOpen = async (
|
|
5
|
+
keyCombo: string,
|
|
6
|
+
combobox: Locator,
|
|
7
|
+
listbox: Locator,
|
|
8
|
+
checkActive?: () => Promise<boolean>,
|
|
9
|
+
) => {
|
|
10
|
+
await closeCombobox(combobox, listbox);
|
|
11
|
+
await combobox.press(keyCombo);
|
|
12
|
+
await expect(listbox, `Listbox should be opened after pressing ${keyCombo}.`).toBeVisible();
|
|
13
|
+
if (checkActive) {
|
|
14
|
+
const active = await checkActive();
|
|
15
|
+
expect(active, "Given option should be active").toBeTruthy();
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const expectToClose = async (
|
|
20
|
+
keyCombo: string,
|
|
21
|
+
combobox: Locator,
|
|
22
|
+
listbox: Locator,
|
|
23
|
+
selectedLocator?: () => Locator,
|
|
24
|
+
) => {
|
|
25
|
+
await openCombobox(combobox, listbox);
|
|
26
|
+
await combobox.press(keyCombo);
|
|
27
|
+
await expect(listbox, `Listbox should be closed after pressing ${keyCombo}.`).toBeHidden();
|
|
28
|
+
await expect(combobox).toBeFocused();
|
|
29
|
+
await openCombobox(combobox, listbox);
|
|
30
|
+
if (selectedLocator) {
|
|
31
|
+
await expectToBeSelected(selectedLocator());
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
|
|
4
35
|
/**
|
|
5
36
|
* Test an implementation of the combobox based on https://w3c.github.io/aria/#combobox
|
|
6
37
|
*/
|
|
7
38
|
export const comboboxTesting = async (
|
|
8
|
-
|
|
39
|
+
_page: Page,
|
|
9
40
|
listbox: Locator,
|
|
10
41
|
combobox: Locator,
|
|
11
42
|
button: Locator,
|
|
@@ -56,8 +87,7 @@ export const comboboxTesting = async (
|
|
|
56
87
|
|
|
57
88
|
// open and select first option
|
|
58
89
|
await combobox.focus();
|
|
59
|
-
|
|
60
|
-
await page.keyboard.press("ArrowDown");
|
|
90
|
+
expectToOpen("ArrowDown", combobox, listbox);
|
|
61
91
|
|
|
62
92
|
const firstId = await (await firstElement.elementHandle())!.getAttribute("id");
|
|
63
93
|
expect(typeof firstId).toBe("string");
|
|
@@ -69,4 +99,70 @@ export const comboboxTesting = async (
|
|
|
69
99
|
combobox,
|
|
70
100
|
"When a descendant of the popup element is active, authors MAY ensure that the focus remains on the combobox element",
|
|
71
101
|
).toBeFocused();
|
|
102
|
+
|
|
103
|
+
// Single Select Pattern
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const closeCombobox = async (combobox: Locator, listbox: Locator) => {
|
|
107
|
+
await combobox.press("Escape");
|
|
108
|
+
return expect(listbox, "Listbox should be collapsed again").toBeHidden();
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const openCombobox = async (combobox: Locator, listbox: Locator) => {
|
|
112
|
+
await combobox.press("Home");
|
|
113
|
+
return expect(listbox, "Listbox should be open again").toBeVisible();
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const expectToBeSelected = async (selectedItem: Locator) =>
|
|
117
|
+
expect(selectedItem, "Option should be selected").toHaveAttribute("aria-selected", "true");
|
|
118
|
+
|
|
119
|
+
export type CheckLocator = (option: Locator) => Promise<boolean>;
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Test an implementation of the combobox based on https://www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/combobox-select-only/
|
|
123
|
+
*/
|
|
124
|
+
export const comboboxSelectOnlyTesting = async (
|
|
125
|
+
page: Page,
|
|
126
|
+
listbox: Locator,
|
|
127
|
+
combobox: Locator,
|
|
128
|
+
isActive: CheckLocator,
|
|
129
|
+
) => {
|
|
130
|
+
await expect(listbox, "Initial state of a combobox is collapsed.").toBeHidden();
|
|
131
|
+
|
|
132
|
+
await combobox.focus();
|
|
133
|
+
|
|
134
|
+
await test.step("Test opening keys", async () => {
|
|
135
|
+
await expectToOpen("ArrowUp", combobox, listbox, () =>
|
|
136
|
+
isActive(page.getByRole("option").first()),
|
|
137
|
+
);
|
|
138
|
+
await expectToOpen("Alt+ArrowDown", combobox, listbox);
|
|
139
|
+
await expectToOpen("Space", combobox, listbox);
|
|
140
|
+
await expectToOpen("Enter", combobox, listbox);
|
|
141
|
+
await expectToOpen("Home", combobox, listbox, () => isActive(page.getByRole("option").first()));
|
|
142
|
+
await expectToOpen("End", combobox, listbox, () => isActive(page.getByRole("option").last()));
|
|
143
|
+
await expectToOpen("ArrowDown", combobox, listbox);
|
|
144
|
+
await expectToOpen("a", combobox, listbox);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
await test.step("Selecting with Enter", async () => {
|
|
148
|
+
await expectToClose("Enter", combobox, listbox, () => page.getByRole("option").first());
|
|
149
|
+
await expectToClose(" ", combobox, listbox, () => page.getByRole("option").first());
|
|
150
|
+
await expectToClose("Escape", combobox, listbox, () => page.getByRole("option").first());
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
await test.step("Activating with End", async () => {
|
|
154
|
+
await openCombobox(combobox, listbox);
|
|
155
|
+
await combobox.press("End");
|
|
156
|
+
const active = await isActive(listbox.getByRole("option").last());
|
|
157
|
+
expect(active, "Given option should be active").toBeTruthy();
|
|
158
|
+
await expect(combobox).toBeFocused();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
await test.step("Activating with Home", async () => {
|
|
162
|
+
await openCombobox(combobox, listbox);
|
|
163
|
+
await combobox.press("Home");
|
|
164
|
+
const active = await isActive(listbox.getByRole("option").first());
|
|
165
|
+
expect(active, "Given option should be active").toBeTruthy();
|
|
166
|
+
await expect(combobox).toBeFocused();
|
|
167
|
+
});
|
|
72
168
|
};
|
|
@@ -1,16 +1,42 @@
|
|
|
1
|
-
import { computed,
|
|
1
|
+
import { computed, unref, type MaybeRef, type Ref } from "vue";
|
|
2
2
|
import { createBuilder } from "../../utils/builder";
|
|
3
3
|
import { createId } from "../../utils/id";
|
|
4
|
+
import { isPrintableCharacter, wasKeyPressed, type PressedKey } from "../../utils/keyboard";
|
|
4
5
|
import {
|
|
5
6
|
createListbox,
|
|
6
7
|
type CreateListboxOptions,
|
|
7
8
|
type ListboxValue,
|
|
8
9
|
} from "../listbox/createListbox";
|
|
10
|
+
import { useOutsideClick } from "../outsideClick";
|
|
11
|
+
import { useTypeAhead } from "../typeAhead";
|
|
12
|
+
|
|
13
|
+
export type ComboboxAutoComplete = "none" | "list" | "both";
|
|
14
|
+
|
|
15
|
+
export const OPENING_KEYS: PressedKey[] = ["ArrowDown", "ArrowUp", " ", "Enter", "Home", "End"];
|
|
16
|
+
export const CLOSING_KEYS: PressedKey[] = [
|
|
17
|
+
"Escape",
|
|
18
|
+
{ key: "ArrowUp", altKey: true },
|
|
19
|
+
"Enter",
|
|
20
|
+
"Tab",
|
|
21
|
+
];
|
|
22
|
+
const SELECTING_KEYS_SINGLE: PressedKey[] = ["Enter", " "];
|
|
23
|
+
const SELECTING_KEYS_MULTIPLE: PressedKey[] = ["Enter"];
|
|
24
|
+
|
|
25
|
+
const isSelectingKey = (event: KeyboardEvent, isMultiselect?: boolean) => {
|
|
26
|
+
const selectingKeys = isMultiselect ? SELECTING_KEYS_MULTIPLE : SELECTING_KEYS_SINGLE;
|
|
27
|
+
return isKeyOfGroup(event, selectingKeys);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const isKeyOfGroup = (event: KeyboardEvent, group: PressedKey[]) =>
|
|
31
|
+
group.some((key) => wasKeyPressed(event, key));
|
|
9
32
|
|
|
10
33
|
export type CreateComboboxOptions<
|
|
11
|
-
TValue extends ListboxValue
|
|
34
|
+
TValue extends ListboxValue,
|
|
35
|
+
TAutoComplete extends ComboboxAutoComplete,
|
|
12
36
|
TMultiple extends boolean = false,
|
|
13
37
|
> = {
|
|
38
|
+
autocomplete: MaybeRef<TAutoComplete>;
|
|
39
|
+
label: MaybeRef<string>;
|
|
14
40
|
/**
|
|
15
41
|
* Labels the listbox which displays the available options. E.g. the list label could be "Countries" for a combobox which is labelled "Country".
|
|
16
42
|
*/
|
|
@@ -18,33 +44,75 @@ export type CreateComboboxOptions<
|
|
|
18
44
|
/**
|
|
19
45
|
* The current value of the combobox. Is updated when an option from the controlled listbox is selected or by typing into it.
|
|
20
46
|
*/
|
|
21
|
-
inputValue: Ref<
|
|
47
|
+
inputValue: Ref<string | undefined>;
|
|
22
48
|
/**
|
|
23
49
|
* Controls the opened/visible state of the associated pop-up. When expanded the activeOption can be controlled via the keyboard.
|
|
24
50
|
*/
|
|
25
|
-
isExpanded:
|
|
51
|
+
isExpanded: MaybeRef<boolean>;
|
|
26
52
|
/**
|
|
27
53
|
* If expanded, the active option is the currently highlighted option of the controlled listbox.
|
|
28
54
|
*/
|
|
29
55
|
activeOption: Ref<TValue | undefined>;
|
|
56
|
+
/**
|
|
57
|
+
* Template ref to the component root (required to close combobox on outside click).
|
|
58
|
+
*/
|
|
59
|
+
templateRef: Ref<HTMLElement | undefined>;
|
|
30
60
|
/**
|
|
31
61
|
* Hook when the popover should toggle.
|
|
62
|
+
*
|
|
63
|
+
* @param preventFocus If `true`, the parent combobox should not be focused (e.g. on outside click).
|
|
32
64
|
*/
|
|
33
|
-
onToggle?: () => void;
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
65
|
+
onToggle?: (preventFocus?: boolean) => void;
|
|
66
|
+
/**
|
|
67
|
+
* Hook when an option is (un-)selected.
|
|
68
|
+
*/
|
|
69
|
+
onSelect?: (value: TValue) => void;
|
|
70
|
+
/**
|
|
71
|
+
* Hook when the first option should be activated.
|
|
72
|
+
*/
|
|
73
|
+
onActivateFirst?: () => void;
|
|
74
|
+
/**
|
|
75
|
+
* Hook when the last option should be activated.
|
|
76
|
+
*/
|
|
77
|
+
onActivateLast?: () => void;
|
|
78
|
+
/**
|
|
79
|
+
* Hook when the next option should be activated.
|
|
80
|
+
*/
|
|
81
|
+
onActivateNext?: (currentValue: TValue) => void;
|
|
82
|
+
/**
|
|
83
|
+
* Hook when the previous option should be activated.
|
|
84
|
+
*/
|
|
85
|
+
onActivatePrevious?: (currentValue: TValue) => void;
|
|
86
|
+
} & (TAutoComplete extends Exclude<ComboboxAutoComplete, "none">
|
|
87
|
+
? { onAutocomplete: (input: string) => void }
|
|
88
|
+
: { onAutocomplete?: undefined }) &
|
|
89
|
+
(TAutoComplete extends "none"
|
|
90
|
+
? { onTypeAhead: (input: string) => void }
|
|
91
|
+
: { onTypeAhead?: undefined }) &
|
|
92
|
+
Pick<
|
|
93
|
+
CreateListboxOptions<TValue, TMultiple>,
|
|
94
|
+
| "onActivateFirst"
|
|
95
|
+
| "onActivateLast"
|
|
96
|
+
| "onActivateNext"
|
|
97
|
+
| "onActivatePrevious"
|
|
98
|
+
| "onSelect"
|
|
99
|
+
| "multiple"
|
|
100
|
+
>;
|
|
42
101
|
|
|
43
102
|
export const createComboBox = createBuilder(
|
|
44
|
-
<
|
|
45
|
-
|
|
103
|
+
<
|
|
104
|
+
TValue extends ListboxValue,
|
|
105
|
+
TAutoComplete extends ComboboxAutoComplete,
|
|
106
|
+
TMultiple extends boolean = false,
|
|
107
|
+
>({
|
|
46
108
|
inputValue,
|
|
47
|
-
|
|
109
|
+
autocomplete: autocompleteRef,
|
|
110
|
+
onAutocomplete,
|
|
111
|
+
onTypeAhead,
|
|
112
|
+
multiple: multipleRef,
|
|
113
|
+
label,
|
|
114
|
+
listLabel,
|
|
115
|
+
isExpanded: isExpandedRef,
|
|
48
116
|
activeOption,
|
|
49
117
|
onToggle,
|
|
50
118
|
onSelect,
|
|
@@ -52,49 +120,43 @@ export const createComboBox = createBuilder(
|
|
|
52
120
|
onActivateLast,
|
|
53
121
|
onActivateNext,
|
|
54
122
|
onActivatePrevious,
|
|
55
|
-
|
|
56
|
-
|
|
123
|
+
templateRef,
|
|
124
|
+
}: CreateComboboxOptions<TValue, TAutoComplete, TMultiple>) => {
|
|
57
125
|
const controlsId = createId("comboBox-control");
|
|
58
|
-
|
|
126
|
+
|
|
127
|
+
const autocomplete = computed(() => unref(autocompleteRef));
|
|
128
|
+
const isExpanded = computed(() => unref(isExpandedRef));
|
|
129
|
+
const multiple = computed(() => unref(multipleRef));
|
|
59
130
|
|
|
60
131
|
const handleInput = (event: Event) => {
|
|
61
132
|
const inputElement = event.target as HTMLInputElement;
|
|
62
|
-
|
|
63
|
-
|
|
133
|
+
|
|
134
|
+
if (autocomplete.value !== "none") {
|
|
135
|
+
onAutocomplete?.(inputElement.value);
|
|
136
|
+
}
|
|
64
137
|
};
|
|
65
138
|
|
|
66
|
-
const
|
|
67
|
-
|
|
139
|
+
const typeAhead = useTypeAhead((inputString) => onTypeAhead?.(inputString));
|
|
140
|
+
|
|
141
|
+
const handleSelect = (value: TValue) => {
|
|
142
|
+
onSelect?.(value);
|
|
143
|
+
if (!unref(multiple)) {
|
|
68
144
|
onToggle?.();
|
|
69
145
|
}
|
|
70
146
|
};
|
|
71
147
|
|
|
72
|
-
const
|
|
73
|
-
if (!isExpanded.value) {
|
|
74
|
-
return;
|
|
75
|
-
}
|
|
148
|
+
const handleNavigation = (event: KeyboardEvent) => {
|
|
76
149
|
switch (event.key) {
|
|
77
|
-
case "Enter":
|
|
78
|
-
event.preventDefault();
|
|
79
|
-
if (activeOption.value) {
|
|
80
|
-
onSelect?.(activeOption.value);
|
|
81
|
-
inputValue.value = activeOption.value;
|
|
82
|
-
}
|
|
83
|
-
break;
|
|
84
|
-
case "Escape":
|
|
85
|
-
event.preventDefault();
|
|
86
|
-
onToggle?.();
|
|
87
|
-
break;
|
|
88
150
|
case "ArrowUp":
|
|
89
151
|
event.preventDefault();
|
|
90
|
-
if (
|
|
152
|
+
if (activeOption.value == undefined) {
|
|
91
153
|
return onActivateLast?.();
|
|
92
154
|
}
|
|
93
155
|
onActivatePrevious?.(activeOption.value);
|
|
94
156
|
break;
|
|
95
157
|
case "ArrowDown":
|
|
96
158
|
event.preventDefault();
|
|
97
|
-
if (
|
|
159
|
+
if (activeOption.value == undefined) {
|
|
98
160
|
return onActivateFirst?.();
|
|
99
161
|
}
|
|
100
162
|
onActivateNext?.(activeOption.value);
|
|
@@ -110,30 +172,73 @@ export const createComboBox = createBuilder(
|
|
|
110
172
|
}
|
|
111
173
|
};
|
|
112
174
|
|
|
175
|
+
const handleKeydown = (event: KeyboardEvent) => {
|
|
176
|
+
if (!isExpanded.value && isKeyOfGroup(event, OPENING_KEYS)) {
|
|
177
|
+
onToggle?.();
|
|
178
|
+
if (event.key === " ") {
|
|
179
|
+
event.preventDefault();
|
|
180
|
+
}
|
|
181
|
+
if (event.key === "End") {
|
|
182
|
+
return onActivateLast?.();
|
|
183
|
+
}
|
|
184
|
+
return onActivateFirst?.();
|
|
185
|
+
}
|
|
186
|
+
if (isSelectingKey(event, multiple.value)) {
|
|
187
|
+
return handleSelect(activeOption.value!);
|
|
188
|
+
}
|
|
189
|
+
if (isExpanded.value && isKeyOfGroup(event, CLOSING_KEYS)) {
|
|
190
|
+
return onToggle?.();
|
|
191
|
+
}
|
|
192
|
+
if (autocomplete.value === "none" && isPrintableCharacter(event.key)) {
|
|
193
|
+
!isExpanded.value && onToggle?.();
|
|
194
|
+
return typeAhead(event);
|
|
195
|
+
}
|
|
196
|
+
if (autocomplete.value !== "none" && isPrintableCharacter(event.key)) {
|
|
197
|
+
!isExpanded.value && onToggle?.();
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
return handleNavigation(event);
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const autocompleteInput =
|
|
204
|
+
autocomplete.value !== "none"
|
|
205
|
+
? {
|
|
206
|
+
"aria-autocomplete": autocomplete.value,
|
|
207
|
+
type: "text",
|
|
208
|
+
}
|
|
209
|
+
: null;
|
|
210
|
+
|
|
113
211
|
const {
|
|
114
212
|
elements: { option, group, listbox },
|
|
115
213
|
internals: { getOptionId },
|
|
116
214
|
} = createListbox({
|
|
117
215
|
label: listLabel,
|
|
216
|
+
multiple,
|
|
118
217
|
controlled: true,
|
|
119
218
|
activeOption,
|
|
120
|
-
|
|
121
|
-
|
|
219
|
+
onSelect: handleSelect,
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
useOutsideClick({
|
|
223
|
+
queryComponent: () => templateRef.value,
|
|
224
|
+
onOutsideClick() {
|
|
225
|
+
if (!isExpanded.value) return;
|
|
226
|
+
onToggle?.(true);
|
|
227
|
+
},
|
|
122
228
|
});
|
|
123
229
|
|
|
124
230
|
return {
|
|
125
231
|
elements: {
|
|
126
232
|
option,
|
|
127
233
|
group,
|
|
128
|
-
label: {
|
|
129
|
-
id: labelId,
|
|
130
|
-
},
|
|
131
234
|
/**
|
|
132
235
|
* The listbox associated with the combobox.
|
|
133
236
|
*/
|
|
134
|
-
|
|
237
|
+
listbox: computed(() => ({
|
|
135
238
|
...listbox.value,
|
|
136
239
|
id: controlsId,
|
|
240
|
+
// preventDefault to not lose focus of the combobox
|
|
241
|
+
onMousedown: (e) => e.preventDefault(),
|
|
137
242
|
})),
|
|
138
243
|
/**
|
|
139
244
|
* An input that controls another element, that can dynamically pop-up to help the user set the value of the input.
|
|
@@ -144,18 +249,19 @@ export const createComboBox = createBuilder(
|
|
|
144
249
|
role: "combobox",
|
|
145
250
|
"aria-expanded": isExpanded.value,
|
|
146
251
|
"aria-controls": controlsId,
|
|
147
|
-
"aria-
|
|
148
|
-
"aria-activedescendant":
|
|
252
|
+
"aria-label": unref(label),
|
|
253
|
+
"aria-activedescendant":
|
|
254
|
+
activeOption.value != undefined ? getOptionId(activeOption.value) : undefined,
|
|
149
255
|
onInput: handleInput,
|
|
150
256
|
onKeydown: handleKeydown,
|
|
151
|
-
|
|
257
|
+
...autocompleteInput,
|
|
152
258
|
})),
|
|
153
259
|
/**
|
|
154
260
|
* An optional button to control the visibility of the popup.
|
|
155
261
|
*/
|
|
156
262
|
button: computed(() => ({
|
|
157
263
|
tabindex: "-1",
|
|
158
|
-
onClick: onToggle,
|
|
264
|
+
onClick: () => onToggle?.(),
|
|
159
265
|
})),
|
|
160
266
|
},
|
|
161
267
|
};
|
|
@@ -5,23 +5,11 @@ import { useTypeAhead } from "../typeAhead";
|
|
|
5
5
|
|
|
6
6
|
export type ListboxValue = string | number | boolean;
|
|
7
7
|
|
|
8
|
-
export type
|
|
9
|
-
TValue extends ListboxValue = ListboxValue,
|
|
10
|
-
TMultiple extends boolean = false,
|
|
11
|
-
> = TMultiple extends true ? TValue[] : TValue;
|
|
12
|
-
|
|
13
|
-
export type CreateListboxOptions<
|
|
14
|
-
TValue extends ListboxValue = ListboxValue,
|
|
15
|
-
TMultiple extends boolean = false,
|
|
16
|
-
> = {
|
|
8
|
+
export type CreateListboxOptions<TValue extends ListboxValue, TMultiple extends boolean = false> = {
|
|
17
9
|
/**
|
|
18
10
|
* Aria label for the listbox.
|
|
19
11
|
*/
|
|
20
12
|
label: MaybeRef<string>;
|
|
21
|
-
/**
|
|
22
|
-
* Value of currently selected option.
|
|
23
|
-
*/
|
|
24
|
-
selectedOption: Ref<ListboxModelValue<TValue, TMultiple> | undefined>;
|
|
25
13
|
/**
|
|
26
14
|
* Value of currently (visually) active option.
|
|
27
15
|
*/
|
|
@@ -58,15 +46,34 @@ export type CreateListboxOptions<
|
|
|
58
46
|
/**
|
|
59
47
|
* Hook when the first option starting with the given label should be activated.
|
|
60
48
|
*/
|
|
61
|
-
onTypeAhead?: (
|
|
62
|
-
}
|
|
49
|
+
onTypeAhead?: (key: string) => void;
|
|
50
|
+
} & (
|
|
51
|
+
| {
|
|
52
|
+
/**
|
|
53
|
+
* Optional aria label for the listbox.
|
|
54
|
+
*/
|
|
55
|
+
label?: MaybeRef<string>;
|
|
56
|
+
/**
|
|
57
|
+
* Wether the listbox is controlled from the outside, e.g. by a combobox.
|
|
58
|
+
* This disables keyboard events and makes the listbox not focusable.
|
|
59
|
+
*/
|
|
60
|
+
controlled: true;
|
|
61
|
+
}
|
|
62
|
+
| {
|
|
63
|
+
/**
|
|
64
|
+
* Aria label for the listbox.
|
|
65
|
+
*/
|
|
66
|
+
label: MaybeRef<string>;
|
|
67
|
+
controlled?: false;
|
|
68
|
+
}
|
|
69
|
+
);
|
|
63
70
|
|
|
64
71
|
/**
|
|
65
72
|
* Composable for creating a accessibility-conform listbox.
|
|
66
73
|
* For supported keyboard shortcuts, see: https://www.w3.org/WAI/ARIA/apg/patterns/listbox/examples/listbox-scrollable/
|
|
67
74
|
*/
|
|
68
75
|
export const createListbox = createBuilder(
|
|
69
|
-
<TValue extends ListboxValue
|
|
76
|
+
<TValue extends ListboxValue, TMultiple extends boolean = false>(
|
|
70
77
|
options: CreateListboxOptions<TValue, TMultiple>,
|
|
71
78
|
) => {
|
|
72
79
|
const isMultiselect = computed(() => unref(options.multiple) ?? false);
|
|
@@ -103,7 +110,6 @@ export const createListbox = createBuilder(
|
|
|
103
110
|
case " ":
|
|
104
111
|
event.preventDefault();
|
|
105
112
|
if (options.activeOption.value != undefined) {
|
|
106
|
-
// TODO: don't call onSelect if the option is disabled
|
|
107
113
|
options.onSelect?.(options.activeOption.value);
|
|
108
114
|
}
|
|
109
115
|
break;
|
|
@@ -182,17 +188,18 @@ export const createListbox = createBuilder(
|
|
|
182
188
|
return (data: {
|
|
183
189
|
label: string;
|
|
184
190
|
value: TValue;
|
|
185
|
-
selected?: boolean;
|
|
186
191
|
disabled?: boolean;
|
|
192
|
+
selected?: boolean;
|
|
187
193
|
}) => {
|
|
188
|
-
const
|
|
194
|
+
const selected = data.selected ?? false;
|
|
195
|
+
|
|
189
196
|
return {
|
|
190
197
|
id: getOptionId(data.value),
|
|
191
198
|
role: "option",
|
|
192
199
|
"aria-label": data.label,
|
|
193
|
-
"aria-checked": isMultiselect.value ? isSelected : undefined,
|
|
194
|
-
"aria-selected": !isMultiselect.value ? isSelected : undefined,
|
|
195
200
|
"aria-disabled": data.disabled,
|
|
201
|
+
"aria-checked": isMultiselect.value ? selected : undefined,
|
|
202
|
+
"aria-selected": !isMultiselect.value ? selected : undefined,
|
|
196
203
|
onClick: () => !data.disabled && options.onSelect?.(data.value),
|
|
197
204
|
} as const;
|
|
198
205
|
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { test } from "@playwright/experimental-ct-vue";
|
|
2
|
+
import { menuButtonTesting } from "./createMenuButton.ct";
|
|
3
|
+
import TestMenuButton from "./TestMenuButton.vue";
|
|
4
|
+
|
|
5
|
+
test("menuButton", async ({ mount, page }) => {
|
|
6
|
+
await mount(<TestMenuButton />);
|
|
7
|
+
|
|
8
|
+
await menuButtonTesting({
|
|
9
|
+
page,
|
|
10
|
+
button: page.getByRole("button"),
|
|
11
|
+
menu: page.locator("ul"),
|
|
12
|
+
menuItems: await page.locator("li").all(),
|
|
13
|
+
});
|
|
14
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
<script lang="ts" setup>
|
|
2
|
+
import { createMenuButton } from "./createMenuButton";
|
|
3
|
+
import { ref } from "vue";
|
|
4
|
+
|
|
5
|
+
const items = Array.from({ length: 10 }, (_, index) => {
|
|
6
|
+
const id = index + 1;
|
|
7
|
+
return { label: `Item ${id}`, value: `/href-${id}` };
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
const activeItem = ref<string>();
|
|
11
|
+
|
|
12
|
+
const {
|
|
13
|
+
elements: { button, menu, menuItem, listItem, flyout },
|
|
14
|
+
state: { isExpanded },
|
|
15
|
+
} = createMenuButton({
|
|
16
|
+
onSelect: (value) => {
|
|
17
|
+
activeItem.value = value;
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
</script>
|
|
21
|
+
|
|
22
|
+
<template>
|
|
23
|
+
<button v-bind="button">Toggle nav menu</button>
|
|
24
|
+
<div v-bind="flyout">
|
|
25
|
+
<ul v-show="isExpanded" v-bind="menu">
|
|
26
|
+
<li v-for="item in items" v-bind="listItem" :key="item.value" title="item">
|
|
27
|
+
<a v-bind="menuItem({ active: activeItem === item.value, value: item.value })">{{
|
|
28
|
+
item.label
|
|
29
|
+
}}</a>
|
|
30
|
+
</li>
|
|
31
|
+
</ul>
|
|
32
|
+
</div>
|
|
33
|
+
</template>
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { expect } from "@playwright/experimental-ct-vue";
|
|
2
|
+
import type { Locator, Page } from "@playwright/test";
|
|
3
|
+
|
|
4
|
+
export type MenuButtonTestingOptions = {
|
|
5
|
+
/**
|
|
6
|
+
* Playwright page.
|
|
7
|
+
*/
|
|
8
|
+
page: Page;
|
|
9
|
+
/**
|
|
10
|
+
* Locator for the button element.
|
|
11
|
+
*/
|
|
12
|
+
button: Locator;
|
|
13
|
+
/**
|
|
14
|
+
* Menu, e.g. a `<ul>` element.
|
|
15
|
+
*/
|
|
16
|
+
menu: Locator;
|
|
17
|
+
/**
|
|
18
|
+
* List items (at least 3).
|
|
19
|
+
*/
|
|
20
|
+
menuItems: Locator[];
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Playwright utility for executing accessibility testing for a navigation menu.
|
|
25
|
+
* Will check aria attributes and keyboard shortcuts as defined in https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/examples/menu-button-links.
|
|
26
|
+
*/
|
|
27
|
+
export const menuButtonTesting = async ({ button, menu }: MenuButtonTestingOptions) => {
|
|
28
|
+
const menuId = await menu.getAttribute("id");
|
|
29
|
+
expect(menuId).toBeDefined();
|
|
30
|
+
await expect(
|
|
31
|
+
button,
|
|
32
|
+
"navigation menu should have set the list ID to the aria-controls",
|
|
33
|
+
).toHaveAttribute("aria-controls", menuId!);
|
|
34
|
+
|
|
35
|
+
await expect(
|
|
36
|
+
button,
|
|
37
|
+
'navigation menu should have an "aria-haspopup" attribute set to true',
|
|
38
|
+
).toHaveAttribute("aria-haspopup", "true");
|
|
39
|
+
|
|
40
|
+
await expect(button).toBeVisible();
|
|
41
|
+
|
|
42
|
+
// ensure correct navigation menu aria attributes
|
|
43
|
+
await expect(
|
|
44
|
+
button,
|
|
45
|
+
'flyout menu must have an "aria-expanded" attribute set to false',
|
|
46
|
+
).toHaveAttribute("aria-expanded", "false");
|
|
47
|
+
|
|
48
|
+
button.hover();
|
|
49
|
+
|
|
50
|
+
await expect(
|
|
51
|
+
button,
|
|
52
|
+
'flyout menu must have an "aria-expanded" attribute set to true',
|
|
53
|
+
).toHaveAttribute("aria-expanded", "true");
|
|
54
|
+
};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { computed, ref } from "vue";
|
|
2
|
+
import { createBuilder } from "../../utils/builder";
|
|
3
|
+
import { createId } from "../../utils/id";
|
|
4
|
+
import { debounce } from "../../utils/timer";
|
|
5
|
+
|
|
6
|
+
export type CreateMenuButtonOptions = {
|
|
7
|
+
/**
|
|
8
|
+
* Called when a menu item is selected (via mouse or keyboard).
|
|
9
|
+
*/
|
|
10
|
+
onSelect: (value: string) => void;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const createMenuButton = createBuilder((options: CreateMenuButtonOptions) => {
|
|
14
|
+
const menuId = createId("menu");
|
|
15
|
+
const buttonId = createId("menu-button");
|
|
16
|
+
const isExpanded = ref<boolean>(false);
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Debounced expanded state that will only be toggled after a given timeout.
|
|
20
|
+
*/
|
|
21
|
+
const updateDebouncedExpanded = debounce(
|
|
22
|
+
(expanded: boolean) => (isExpanded.value = expanded),
|
|
23
|
+
200,
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
const hoverEvents = computed(() => {
|
|
27
|
+
return {
|
|
28
|
+
onMouseover: () => updateDebouncedExpanded(true),
|
|
29
|
+
onMouseout: () => updateDebouncedExpanded(false),
|
|
30
|
+
onFocusin: () => (isExpanded.value = true),
|
|
31
|
+
onFocusout: () => (isExpanded.value = false),
|
|
32
|
+
};
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
state: { isExpanded },
|
|
37
|
+
elements: {
|
|
38
|
+
button: computed(
|
|
39
|
+
() =>
|
|
40
|
+
({
|
|
41
|
+
"aria-controls": menuId,
|
|
42
|
+
"aria-expanded": isExpanded.value,
|
|
43
|
+
"aria-haspopup": true,
|
|
44
|
+
id: buttonId,
|
|
45
|
+
...hoverEvents.value,
|
|
46
|
+
}) as const,
|
|
47
|
+
),
|
|
48
|
+
listItem: {
|
|
49
|
+
role: "none",
|
|
50
|
+
},
|
|
51
|
+
flyout: {
|
|
52
|
+
...hoverEvents.value,
|
|
53
|
+
},
|
|
54
|
+
menu: {
|
|
55
|
+
id: menuId,
|
|
56
|
+
role: "menu",
|
|
57
|
+
"aria-labelledby": buttonId,
|
|
58
|
+
},
|
|
59
|
+
menuItem: (data: { active?: boolean; value: string }) => ({
|
|
60
|
+
"aria-current": data.active ? "page" : undefined,
|
|
61
|
+
role: "menuitem",
|
|
62
|
+
tabindex: -1,
|
|
63
|
+
onClick: () => {
|
|
64
|
+
options.onSelect(data.value);
|
|
65
|
+
},
|
|
66
|
+
}),
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { onBeforeMount, onBeforeUnmount, watchEffect, type Ref } from "vue";
|
|
2
|
+
|
|
3
|
+
export type UseOutsideClickOptions = {
|
|
4
|
+
/**
|
|
5
|
+
* Function that returns the HTML element of the component where outside clicks should be listened to.
|
|
6
|
+
*/
|
|
7
|
+
queryComponent: () => ReturnType<typeof document.querySelector> | undefined;
|
|
8
|
+
/**
|
|
9
|
+
* Callback when an outside click occurred.
|
|
10
|
+
*/
|
|
11
|
+
onOutsideClick: () => void;
|
|
12
|
+
/**
|
|
13
|
+
* If `true`, event listeners will be removed and no outside clicks will be captured.
|
|
14
|
+
*/
|
|
15
|
+
disabled?: Ref<boolean>;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Composable for listening to click events that occur outside of a component.
|
|
20
|
+
* Useful to e.g. close flyouts or tooltips.
|
|
21
|
+
*/
|
|
22
|
+
export const useOutsideClick = (options: UseOutsideClickOptions) => {
|
|
23
|
+
/**
|
|
24
|
+
* Document click handle that closes then tooltip when clicked outside.
|
|
25
|
+
* Should only be called when trigger is "click".
|
|
26
|
+
*/
|
|
27
|
+
const handleDocumentClick = (event: MouseEvent) => {
|
|
28
|
+
const component = options.queryComponent();
|
|
29
|
+
if (!component || !(event.target instanceof Node)) return;
|
|
30
|
+
|
|
31
|
+
const isOutsideClick = !event.composedPath().includes(component);
|
|
32
|
+
if (isOutsideClick) options.onOutsideClick();
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// add global document event listeners only on/before mounted to also work in server side rendering
|
|
36
|
+
onBeforeMount(() => {
|
|
37
|
+
watchEffect(() => {
|
|
38
|
+
if (options.disabled?.value) {
|
|
39
|
+
document.removeEventListener("click", handleDocumentClick);
|
|
40
|
+
} else {
|
|
41
|
+
document.addEventListener("click", handleDocumentClick);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Clean up global event listeners to prevent dangling events.
|
|
48
|
+
*/
|
|
49
|
+
onBeforeUnmount(() => {
|
|
50
|
+
document.removeEventListener("click", handleDocumentClick);
|
|
51
|
+
});
|
|
52
|
+
};
|
|
@@ -1,14 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
computed,
|
|
3
|
-
onBeforeMount,
|
|
4
|
-
onBeforeUnmount,
|
|
5
|
-
ref,
|
|
6
|
-
unref,
|
|
7
|
-
watchEffect,
|
|
8
|
-
type MaybeRef,
|
|
9
|
-
} from "vue";
|
|
1
|
+
import { computed, onBeforeMount, onBeforeUnmount, ref, unref, type MaybeRef } from "vue";
|
|
10
2
|
import { createId } from "../..";
|
|
11
3
|
import { createBuilder } from "../../utils/builder";
|
|
4
|
+
import { useOutsideClick } from "../outsideClick";
|
|
12
5
|
|
|
13
6
|
export type CreateTooltipOptions = {
|
|
14
7
|
open: MaybeRef<TooltipOpen>;
|
|
@@ -92,41 +85,23 @@ export const createTooltip = createBuilder((options: CreateTooltipOptions) => {
|
|
|
92
85
|
_isVisible.value = false;
|
|
93
86
|
};
|
|
94
87
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
if (!tooltipParent || !(event.target instanceof Node)) return;
|
|
102
|
-
|
|
103
|
-
const isOutsideClick = !tooltipParent.contains(event.target);
|
|
104
|
-
if (isOutsideClick) _isVisible.value = false;
|
|
105
|
-
};
|
|
88
|
+
// close tooltip on outside click
|
|
89
|
+
useOutsideClick({
|
|
90
|
+
queryComponent: () => document.getElementById(tooltipId)?.parentElement,
|
|
91
|
+
onOutsideClick: () => (_isVisible.value = false),
|
|
92
|
+
disabled: computed(() => openType.value !== "click"),
|
|
93
|
+
});
|
|
106
94
|
|
|
107
95
|
// add global document event listeners only on/before mounted to also work in server side rendering
|
|
108
96
|
onBeforeMount(() => {
|
|
109
97
|
document.addEventListener("keydown", handleDocumentKeydown);
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* Registers keydown and click handlers when trigger is "click" to close
|
|
113
|
-
* the tooltip.
|
|
114
|
-
*/
|
|
115
|
-
watchEffect(() => {
|
|
116
|
-
if (openType.value === "click") {
|
|
117
|
-
document.addEventListener("click", handleDocumentClick);
|
|
118
|
-
} else {
|
|
119
|
-
document.removeEventListener("click", handleDocumentClick);
|
|
120
|
-
}
|
|
121
|
-
});
|
|
122
98
|
});
|
|
123
99
|
|
|
124
100
|
/**
|
|
125
101
|
* Clean up global event listeners to prevent dangling events.
|
|
126
102
|
*/
|
|
127
103
|
onBeforeUnmount(() => {
|
|
128
|
-
document.
|
|
129
|
-
document.addEventListener("click", handleDocumentClick);
|
|
104
|
+
document.removeEventListener("keydown", handleDocumentKeydown);
|
|
130
105
|
});
|
|
131
106
|
|
|
132
107
|
return {
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export * from "./composables/comboBox/createComboBox";
|
|
2
2
|
export * from "./composables/listbox/createListbox";
|
|
3
3
|
export * from "./composables/tooltip/createTooltip";
|
|
4
|
+
export * from "./composables/menuButton/createMenuButton";
|
|
4
5
|
export { createId } from "./utils/id";
|
|
6
|
+
export { isPrintableCharacter, wasKeyPressed } from "./utils/keyboard";
|
package/src/playwright.ts
CHANGED
|
@@ -1,5 +1,40 @@
|
|
|
1
1
|
import { expect, test } from "vitest";
|
|
2
|
-
import { isPrintableCharacter } from "./keyboard";
|
|
2
|
+
import { isPrintableCharacter, wasKeyPressed } from "./keyboard";
|
|
3
|
+
|
|
4
|
+
test.each([
|
|
5
|
+
// ARRANGE
|
|
6
|
+
{ input: [{ key: "m", code: "KeyM" }, "m"], expected: true },
|
|
7
|
+
{ input: [{ key: "m", code: "KeyM" }, "m"], expected: true },
|
|
8
|
+
{ input: [{ key: "m", code: "KeyM" }, { key: "m" }], expected: true },
|
|
9
|
+
{ input: [{ key: "m", code: "KeyM" }, { code: "KeyM" }], expected: true },
|
|
10
|
+
{
|
|
11
|
+
input: [{ key: "m", code: "KeyM", altKey: false }, { code: "KeyM" }],
|
|
12
|
+
expected: true,
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
input: [
|
|
16
|
+
{ key: "m", code: "KeyM" },
|
|
17
|
+
{ code: "KeyM", altKey: true },
|
|
18
|
+
],
|
|
19
|
+
expected: false,
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
input: [
|
|
23
|
+
{ key: "m", code: "KeyM", shiftKey: true },
|
|
24
|
+
{ code: "KeyM", shiftKey: false },
|
|
25
|
+
],
|
|
26
|
+
expected: false,
|
|
27
|
+
},
|
|
28
|
+
] as { input: Parameters<typeof wasKeyPressed>; expected: boolean }[])(
|
|
29
|
+
"should return $expected for event $input.0 and pressed key $input.1",
|
|
30
|
+
({ input: [event, wasPressed], expected }) => {
|
|
31
|
+
// ACT
|
|
32
|
+
const result = wasKeyPressed(new KeyboardEvent("keydown", event), wasPressed);
|
|
33
|
+
|
|
34
|
+
// ASSERT
|
|
35
|
+
expect(result).toBe(expected);
|
|
36
|
+
},
|
|
37
|
+
);
|
|
3
38
|
|
|
4
39
|
test.each([
|
|
5
40
|
// ARRANGE
|
package/src/utils/keyboard.ts
CHANGED
|
@@ -1,3 +1,26 @@
|
|
|
1
|
+
import { isSubsetMatching } from "./object";
|
|
2
|
+
|
|
3
|
+
export type PressedKey =
|
|
4
|
+
| string
|
|
5
|
+
| Partial<Pick<KeyboardEvent, "altKey" | "key" | "ctrlKey" | "metaKey" | "shiftKey" | "code">>;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Check if a specified key was pressed.
|
|
9
|
+
* @param event The KeyboardEvent
|
|
10
|
+
* @param key The key, either the [key property](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key) as a string (e.g. "m")
|
|
11
|
+
* or an object with the relevant key parameters, e.g. `{ key: "m", altKey: true }`
|
|
12
|
+
* @returns true, if the key was pressed with the specified parameters
|
|
13
|
+
*/
|
|
14
|
+
export const wasKeyPressed = (event: KeyboardEvent, key: PressedKey) => {
|
|
15
|
+
if (typeof key === "string") {
|
|
16
|
+
return event.key === key;
|
|
17
|
+
}
|
|
18
|
+
return isSubsetMatching(
|
|
19
|
+
{ altKey: false, ctrlKey: false, metaKey: false, shiftKey: false, ...key },
|
|
20
|
+
event,
|
|
21
|
+
);
|
|
22
|
+
};
|
|
23
|
+
|
|
1
24
|
/**
|
|
2
25
|
* Check if the `key` property of a KeyboardEvent is a printable character.
|
|
3
26
|
*
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { expect, test } from "vitest";
|
|
2
|
+
import { isSubsetMatching } from "./object";
|
|
3
|
+
|
|
4
|
+
const referenceObj = { a: 42, b: "foo", c: null, d: true };
|
|
5
|
+
|
|
6
|
+
test.each([
|
|
7
|
+
// ARRANGE
|
|
8
|
+
{ label: "with the same key-values", compareObj: { ...referenceObj } },
|
|
9
|
+
{
|
|
10
|
+
label: "with additional key-values in compared object",
|
|
11
|
+
compareObj: { d: true, b: "foo", c: null, a: 42, f: 23 },
|
|
12
|
+
},
|
|
13
|
+
{ label: "with undefined keys", compareObj: { ...referenceObj, e: undefined } },
|
|
14
|
+
])("should return true for objects $label", ({ compareObj }) => {
|
|
15
|
+
// ACT
|
|
16
|
+
const result = isSubsetMatching(referenceObj, compareObj);
|
|
17
|
+
|
|
18
|
+
// ASSERT
|
|
19
|
+
expect(result).toBeTruthy();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test.each([
|
|
23
|
+
// ARRANGE
|
|
24
|
+
{ label: "only having a single entry", compareObj: { foo: 42 } },
|
|
25
|
+
{ label: "a value", compareObj: { ...referenceObj, a: 0 } },
|
|
26
|
+
{ label: "a value", compareObj: { ...referenceObj, a: undefined } },
|
|
27
|
+
])("should return false when objects differ by $label", ({ compareObj }) => {
|
|
28
|
+
// ACT
|
|
29
|
+
const result = isSubsetMatching(referenceObj, compareObj);
|
|
30
|
+
|
|
31
|
+
// ASSERT
|
|
32
|
+
expect(result).toBeFalsy();
|
|
33
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Check if every entry of a subset exists and matches the entry of a target object.
|
|
3
|
+
* @returns `true`, if target contains the subset
|
|
4
|
+
*/
|
|
5
|
+
export const isSubsetMatching = (subset: object, target: object) =>
|
|
6
|
+
Object.entries(subset).every(
|
|
7
|
+
([key, value]) => (target as Record<string, unknown>)[key] === value,
|
|
8
|
+
);
|
package/src/utils/types.ts
CHANGED
|
@@ -1,8 +1,23 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Adds the entry with the key `Key` and the value of type `TValue` to a record when it is defined.
|
|
3
|
+
* Then the entry is either undefined or exists without being optional.
|
|
4
|
+
* @example
|
|
5
|
+
* ```ts
|
|
6
|
+
* const _no_error: IfDefined<"b", number> & { a: number } = { a: 1, b: 2 };
|
|
7
|
+
* // Error: Object literal may only specify known properties, and 'b' does not exist in type '{ a: number; }'.
|
|
8
|
+
* const _error: IfDefined<"b", undefined> & { a: number } = { a: 1, b: 2 };
|
|
9
|
+
* ```
|
|
10
|
+
*/
|
|
11
|
+
export type IfDefined<Key extends string, TValue> =
|
|
12
|
+
TValue extends NonNullable<unknown>
|
|
3
13
|
? {
|
|
4
|
-
[key in Key]:
|
|
14
|
+
[key in Key]: TValue;
|
|
5
15
|
}
|
|
6
|
-
:
|
|
7
|
-
|
|
8
|
-
|
|
16
|
+
: unknown;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Wraps type `TValue` in an array, if `TMultiple` is true.
|
|
20
|
+
*/
|
|
21
|
+
export type IsArray<TValue, TMultiple extends boolean = false> = TMultiple extends true
|
|
22
|
+
? TValue[]
|
|
23
|
+
: TValue;
|