@sit-onyx/headless 0.1.0-alpha.7 → 1.0.0-alpha.8
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 +99 -3
- package/src/composables/comboBox/createComboBox.ts +144 -46
- package/src/composables/listbox/TestListbox.vue +0 -1
- package/src/composables/listbox/createListbox.ts +27 -29
- package/src/composables/outsideClick.ts +52 -0
- package/src/composables/tooltip/createTooltip.ts +9 -34
- package/src/index.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,6 +1,37 @@
|
|
|
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
|
*/
|
|
@@ -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,37 @@
|
|
|
1
|
-
import { computed, ref, type MaybeRef, type Ref } from "vue";
|
|
1
|
+
import { computed, ref, 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
|
+
const OPENING_KEYS: PressedKey[] = ["ArrowDown", "ArrowUp", " ", "Enter", "Home", "End"];
|
|
16
|
+
const CLOSING_KEYS: PressedKey[] = ["Escape", { key: "ArrowUp", altKey: true }, "Enter", "Tab"];
|
|
17
|
+
const SELECTING_KEYS_SINGLE: PressedKey[] = ["Enter", " "];
|
|
18
|
+
const SELECTING_KEYS_MULTIPLE: PressedKey[] = ["Enter"];
|
|
19
|
+
|
|
20
|
+
const isSelectingKey = (event: KeyboardEvent, isMultiselect?: boolean) => {
|
|
21
|
+
const selectingKeys = isMultiselect ? SELECTING_KEYS_MULTIPLE : SELECTING_KEYS_SINGLE;
|
|
22
|
+
return isKeyOfGroup(event, selectingKeys);
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const isKeyOfGroup = (event: KeyboardEvent, group: PressedKey[]) =>
|
|
26
|
+
group.some((key) => wasKeyPressed(event, key));
|
|
9
27
|
|
|
10
28
|
export type CreateComboboxOptions<
|
|
11
|
-
TValue extends ListboxValue
|
|
29
|
+
TValue extends ListboxValue,
|
|
30
|
+
TAutoComplete extends ComboboxAutoComplete,
|
|
12
31
|
TMultiple extends boolean = false,
|
|
13
32
|
> = {
|
|
33
|
+
autocomplete: MaybeRef<TAutoComplete>;
|
|
34
|
+
label: MaybeRef<string>;
|
|
14
35
|
/**
|
|
15
36
|
* Labels the listbox which displays the available options. E.g. the list label could be "Countries" for a combobox which is labelled "Country".
|
|
16
37
|
*/
|
|
@@ -18,33 +39,73 @@ export type CreateComboboxOptions<
|
|
|
18
39
|
/**
|
|
19
40
|
* The current value of the combobox. Is updated when an option from the controlled listbox is selected or by typing into it.
|
|
20
41
|
*/
|
|
21
|
-
inputValue: Ref<
|
|
42
|
+
inputValue: Ref<string | undefined>;
|
|
22
43
|
/**
|
|
23
44
|
* Controls the opened/visible state of the associated pop-up. When expanded the activeOption can be controlled via the keyboard.
|
|
24
45
|
*/
|
|
25
|
-
isExpanded:
|
|
46
|
+
isExpanded: MaybeRef<boolean>;
|
|
26
47
|
/**
|
|
27
48
|
* If expanded, the active option is the currently highlighted option of the controlled listbox.
|
|
28
49
|
*/
|
|
29
50
|
activeOption: Ref<TValue | undefined>;
|
|
51
|
+
/**
|
|
52
|
+
* Template ref to the component root (required to close combobox on outside click).
|
|
53
|
+
*/
|
|
54
|
+
templateRef: Ref<HTMLElement | undefined>;
|
|
30
55
|
/**
|
|
31
56
|
* Hook when the popover should toggle.
|
|
32
57
|
*/
|
|
33
58
|
onToggle?: () => void;
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
59
|
+
/**
|
|
60
|
+
* Hook when an option is (un-)selected.
|
|
61
|
+
*/
|
|
62
|
+
onSelect?: (value: TValue) => void;
|
|
63
|
+
/**
|
|
64
|
+
* Hook when the first option should be activated.
|
|
65
|
+
*/
|
|
66
|
+
onActivateFirst?: () => void;
|
|
67
|
+
/**
|
|
68
|
+
* Hook when the last option should be activated.
|
|
69
|
+
*/
|
|
70
|
+
onActivateLast?: () => void;
|
|
71
|
+
/**
|
|
72
|
+
* Hook when the next option should be activated.
|
|
73
|
+
*/
|
|
74
|
+
onActivateNext?: (currentValue: TValue) => void;
|
|
75
|
+
/**
|
|
76
|
+
* Hook when the previous option should be activated.
|
|
77
|
+
*/
|
|
78
|
+
onActivatePrevious?: (currentValue: TValue) => void;
|
|
79
|
+
} & (TAutoComplete extends Exclude<ComboboxAutoComplete, "none">
|
|
80
|
+
? { onAutocomplete: (input: string) => void }
|
|
81
|
+
: { onAutocomplete?: undefined }) &
|
|
82
|
+
(TAutoComplete extends "none"
|
|
83
|
+
? { onTypeAhead: (input: string) => void }
|
|
84
|
+
: { onTypeAhead?: undefined }) &
|
|
85
|
+
Pick<
|
|
86
|
+
CreateListboxOptions<TValue, TMultiple>,
|
|
87
|
+
| "onActivateFirst"
|
|
88
|
+
| "onActivateLast"
|
|
89
|
+
| "onActivateNext"
|
|
90
|
+
| "onActivatePrevious"
|
|
91
|
+
| "onSelect"
|
|
92
|
+
| "multiple"
|
|
93
|
+
>;
|
|
42
94
|
|
|
43
95
|
export const createComboBox = createBuilder(
|
|
44
|
-
<
|
|
45
|
-
|
|
96
|
+
<
|
|
97
|
+
TValue extends ListboxValue,
|
|
98
|
+
TAutoComplete extends ComboboxAutoComplete,
|
|
99
|
+
TMultiple extends boolean = false,
|
|
100
|
+
>({
|
|
46
101
|
inputValue,
|
|
47
|
-
|
|
102
|
+
autocomplete: autocompleteRef,
|
|
103
|
+
onAutocomplete,
|
|
104
|
+
onTypeAhead,
|
|
105
|
+
multiple: multipleRef,
|
|
106
|
+
label,
|
|
107
|
+
listLabel,
|
|
108
|
+
isExpanded: isExpandedRef,
|
|
48
109
|
activeOption,
|
|
49
110
|
onToggle,
|
|
50
111
|
onSelect,
|
|
@@ -52,49 +113,48 @@ export const createComboBox = createBuilder(
|
|
|
52
113
|
onActivateLast,
|
|
53
114
|
onActivateNext,
|
|
54
115
|
onActivatePrevious,
|
|
55
|
-
|
|
116
|
+
templateRef,
|
|
117
|
+
}: CreateComboboxOptions<TValue, TAutoComplete, TMultiple>) => {
|
|
56
118
|
const inputValid = ref(true);
|
|
57
119
|
const controlsId = createId("comboBox-control");
|
|
58
|
-
|
|
120
|
+
|
|
121
|
+
const autocomplete = computed(() => unref(autocompleteRef));
|
|
122
|
+
const isExpanded = computed(() => unref(isExpandedRef));
|
|
123
|
+
const multiple = computed(() => unref(multipleRef));
|
|
59
124
|
|
|
60
125
|
const handleInput = (event: Event) => {
|
|
61
126
|
const inputElement = event.target as HTMLInputElement;
|
|
62
|
-
inputValue.value = inputElement.value as TValue;
|
|
63
127
|
inputValid.value = inputElement.validity.valid;
|
|
128
|
+
if (!unref(isExpanded)) {
|
|
129
|
+
onToggle?.();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (autocomplete.value !== "none") {
|
|
133
|
+
onAutocomplete?.(inputElement.value);
|
|
134
|
+
}
|
|
64
135
|
};
|
|
65
136
|
|
|
66
|
-
const
|
|
67
|
-
|
|
137
|
+
const typeAhead = useTypeAhead((inputString) => onTypeAhead?.(inputString));
|
|
138
|
+
|
|
139
|
+
const handleSelect = (value: TValue) => {
|
|
140
|
+
onSelect?.(value);
|
|
141
|
+
if (!unref(multiple)) {
|
|
68
142
|
onToggle?.();
|
|
69
143
|
}
|
|
70
144
|
};
|
|
71
145
|
|
|
72
|
-
const
|
|
73
|
-
if (!isExpanded.value) {
|
|
74
|
-
return;
|
|
75
|
-
}
|
|
146
|
+
const handleNavigation = (event: KeyboardEvent) => {
|
|
76
147
|
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
148
|
case "ArrowUp":
|
|
89
149
|
event.preventDefault();
|
|
90
|
-
if (
|
|
150
|
+
if (activeOption.value == undefined) {
|
|
91
151
|
return onActivateLast?.();
|
|
92
152
|
}
|
|
93
153
|
onActivatePrevious?.(activeOption.value);
|
|
94
154
|
break;
|
|
95
155
|
case "ArrowDown":
|
|
96
156
|
event.preventDefault();
|
|
97
|
-
if (
|
|
157
|
+
if (activeOption.value == undefined) {
|
|
98
158
|
return onActivateFirst?.();
|
|
99
159
|
}
|
|
100
160
|
onActivateNext?.(activeOption.value);
|
|
@@ -110,28 +170,65 @@ export const createComboBox = createBuilder(
|
|
|
110
170
|
}
|
|
111
171
|
};
|
|
112
172
|
|
|
173
|
+
const handleKeydown = (event: KeyboardEvent) => {
|
|
174
|
+
if (!isExpanded.value && isKeyOfGroup(event, OPENING_KEYS)) {
|
|
175
|
+
onToggle?.();
|
|
176
|
+
if (event.key === " ") {
|
|
177
|
+
event.preventDefault();
|
|
178
|
+
}
|
|
179
|
+
if (event.key === "End") {
|
|
180
|
+
return onActivateLast?.();
|
|
181
|
+
}
|
|
182
|
+
return onActivateFirst?.();
|
|
183
|
+
}
|
|
184
|
+
if (isSelectingKey(event, multiple.value)) {
|
|
185
|
+
return handleSelect(activeOption.value!);
|
|
186
|
+
}
|
|
187
|
+
if (isExpanded.value && isKeyOfGroup(event, CLOSING_KEYS)) {
|
|
188
|
+
return onToggle?.();
|
|
189
|
+
}
|
|
190
|
+
if (autocomplete.value === "none" && isPrintableCharacter(event.key)) {
|
|
191
|
+
!isExpanded.value && onToggle?.();
|
|
192
|
+
return typeAhead(event);
|
|
193
|
+
}
|
|
194
|
+
return handleNavigation(event);
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const autocompleteInput =
|
|
198
|
+
autocomplete.value !== "none"
|
|
199
|
+
? {
|
|
200
|
+
"aria-autocomplete": autocomplete.value,
|
|
201
|
+
type: "text",
|
|
202
|
+
}
|
|
203
|
+
: null;
|
|
204
|
+
|
|
113
205
|
const {
|
|
114
206
|
elements: { option, group, listbox },
|
|
115
207
|
internals: { getOptionId },
|
|
116
208
|
} = createListbox({
|
|
117
209
|
label: listLabel,
|
|
210
|
+
multiple,
|
|
118
211
|
controlled: true,
|
|
119
212
|
activeOption,
|
|
120
|
-
|
|
121
|
-
|
|
213
|
+
onSelect: handleSelect,
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
useOutsideClick({
|
|
217
|
+
queryComponent: () => templateRef.value,
|
|
218
|
+
onOutsideClick() {
|
|
219
|
+
if (!isExpanded.value) return;
|
|
220
|
+
onToggle?.();
|
|
221
|
+
},
|
|
122
222
|
});
|
|
123
223
|
|
|
124
224
|
return {
|
|
125
225
|
elements: {
|
|
126
226
|
option,
|
|
127
227
|
group,
|
|
128
|
-
label: {
|
|
129
|
-
id: labelId,
|
|
130
|
-
},
|
|
131
228
|
/**
|
|
132
229
|
* The listbox associated with the combobox.
|
|
133
230
|
*/
|
|
134
|
-
|
|
231
|
+
listbox: computed(() => ({
|
|
135
232
|
...listbox.value,
|
|
136
233
|
id: controlsId,
|
|
137
234
|
})),
|
|
@@ -144,11 +241,12 @@ export const createComboBox = createBuilder(
|
|
|
144
241
|
role: "combobox",
|
|
145
242
|
"aria-expanded": isExpanded.value,
|
|
146
243
|
"aria-controls": controlsId,
|
|
147
|
-
"aria-
|
|
148
|
-
"aria-activedescendant":
|
|
244
|
+
"aria-label": unref(label),
|
|
245
|
+
"aria-activedescendant":
|
|
246
|
+
activeOption.value != undefined ? getOptionId(activeOption.value) : undefined,
|
|
149
247
|
onInput: handleInput,
|
|
150
248
|
onKeydown: handleKeydown,
|
|
151
|
-
|
|
249
|
+
...autocompleteInput,
|
|
152
250
|
})),
|
|
153
251
|
/**
|
|
154
252
|
* An optional button to control the visibility of the popup.
|
|
@@ -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;
|
|
@@ -179,23 +185,15 @@ export const createListbox = createBuilder(
|
|
|
179
185
|
});
|
|
180
186
|
}),
|
|
181
187
|
option: computed(() => {
|
|
182
|
-
return (data: {
|
|
183
|
-
|
|
184
|
-
value: TValue;
|
|
185
|
-
selected?: boolean;
|
|
186
|
-
disabled?: boolean;
|
|
187
|
-
}) => {
|
|
188
|
-
const isSelected = data.selected ?? false;
|
|
189
|
-
return {
|
|
188
|
+
return (data: { label: string; value: TValue; disabled?: boolean; selected?: boolean }) =>
|
|
189
|
+
({
|
|
190
190
|
id: getOptionId(data.value),
|
|
191
191
|
role: "option",
|
|
192
192
|
"aria-label": data.label,
|
|
193
|
-
"aria-checked": isMultiselect.value ? isSelected : undefined,
|
|
194
|
-
"aria-selected": !isMultiselect.value ? isSelected : undefined,
|
|
195
193
|
"aria-disabled": data.disabled,
|
|
194
|
+
[isMultiselect.value ? "aria-checked" : "aria-selected"]: data.selected || false,
|
|
196
195
|
onClick: () => !data.disabled && options.onSelect?.(data.value),
|
|
197
|
-
} as const;
|
|
198
|
-
};
|
|
196
|
+
}) as const;
|
|
199
197
|
}),
|
|
200
198
|
},
|
|
201
199
|
state: {
|
|
@@ -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
|
@@ -2,3 +2,4 @@ export * from "./composables/comboBox/createComboBox";
|
|
|
2
2
|
export * from "./composables/listbox/createListbox";
|
|
3
3
|
export * from "./composables/tooltip/createTooltip";
|
|
4
4
|
export { createId } from "./utils/id";
|
|
5
|
+
export { isPrintableCharacter, wasKeyPressed } from "./utils/keyboard";
|
|
@@ -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;
|