@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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sit-onyx/headless",
3
3
  "description": "Headless composables for Vue",
4
- "version": "0.1.0-alpha.7",
4
+ "version": "1.0.0-alpha.8",
5
5
  "type": "module",
6
6
  "author": "Schwarz IT KG",
7
7
  "license": "Apache-2.0",
@@ -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 elements = ["a", "b", "c", "d"];
5
+ const options = ["a", "b", "c", "d"];
6
6
  const isExpanded = ref(false);
7
- const inputValue = ref("");
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 = elements.indexOf(activeOption.value);
12
+ const index = filteredOptions.value.indexOf(activeOption.value);
11
13
  return index !== -1 ? index : undefined;
12
14
  });
13
15
 
14
- const onActivateFirst = () => (activeOption.value = elements[0]);
15
- const onActivateLast = () => (activeOption.value = elements[elements.length - 1]);
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 = elements[selectedIndex.value + (1 % (elements.length - 1))];
23
+ activeOption.value =
24
+ filteredOptions.value[selectedIndex.value + (1 % (filteredOptions.value.length - 1))];
21
25
  };
22
- const onActivatePrevious = () => (activeOption.value = elements[(selectedIndex.value ?? 0) - 1]);
23
- const onSelect = (newValue: string) => (inputValue.value = newValue);
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, label, listBox, button, option },
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
- <label v-bind="label">
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="listBox" :class="{ hidden: !isExpanded }" style="width: 400px">
64
+ <ul v-bind="listbox" :class="{ hidden: !isExpanded }" style="width: 400px">
61
65
  <li
62
- v-for="e in elements"
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
- await page.keyboard.press("ArrowDown");
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 = 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<TValue | undefined>;
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: Ref<boolean>;
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
- } & Pick<
35
- CreateListboxOptions<TValue, TMultiple>,
36
- "onActivateFirst" | "onActivateLast" | "onActivateNext" | "onActivatePrevious" | "onSelect"
37
- >;
38
-
39
- // TODO: https://w3c.github.io/aria/#aria-autocomplete
40
- // TODO: https://www.w3.org/WAI/ARIA/apg/patterns/combobox/
41
- // TODO: button as optional
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
- <TValue extends ListboxValue = ListboxValue, TMultiple extends boolean = false>({
45
- listLabel,
96
+ <
97
+ TValue extends ListboxValue,
98
+ TAutoComplete extends ComboboxAutoComplete,
99
+ TMultiple extends boolean = false,
100
+ >({
46
101
  inputValue,
47
- isExpanded,
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
- }: CreateComboboxOptions<TValue, TMultiple>) => {
116
+ templateRef,
117
+ }: CreateComboboxOptions<TValue, TAutoComplete, TMultiple>) => {
56
118
  const inputValid = ref(true);
57
119
  const controlsId = createId("comboBox-control");
58
- const labelId = createId("comboBox-label");
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 handleBlur = () => {
67
- if (isExpanded.value) {
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 handleKeydown = (event: KeyboardEvent) => {
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 (!activeOption.value) {
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 (!activeOption.value) {
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
- selectedOption: activeOption,
121
- onSelect,
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
- listBox: computed(() => ({
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-labelledby": labelId,
148
- "aria-activedescendant": activeOption.value ? getOptionId(activeOption.value) : undefined,
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
- onBlur: handleBlur,
249
+ ...autocompleteInput,
152
250
  })),
153
251
  /**
154
252
  * An optional button to control the visibility of the popup.
@@ -30,7 +30,6 @@ const {
30
30
  elements: { listbox, option: headlessOption },
31
31
  } = createListbox({
32
32
  label: "Test listbox",
33
- selectedOption,
34
33
  activeOption,
35
34
  onSelect: (id) => {
36
35
  selectedOption.value = selectedOption.value === id ? undefined : id;
@@ -5,23 +5,11 @@ import { useTypeAhead } from "../typeAhead";
5
5
 
6
6
  export type ListboxValue = string | number | boolean;
7
7
 
8
- export type ListboxModelValue<
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?: (label: string) => void;
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 = ListboxValue, TMultiple extends boolean = false>(
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
- label: string;
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
- * Document click handle that closes then tooltip when clicked outside.
97
- * Should only be called when trigger is "click".
98
- */
99
- const handleDocumentClick = (event: MouseEvent) => {
100
- const tooltipParent = document.getElementById(tooltipId)?.parentElement;
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.addEventListener("keydown", handleDocumentKeydown);
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
@@ -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
+ );
@@ -1,8 +1,23 @@
1
- export type IfDefined<Key extends string, T> =
2
- T extends NonNullable<unknown>
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]: T;
14
+ [key in Key]: TValue;
5
15
  }
6
- : {
7
- [key in Key]?: undefined;
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;