@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 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.11",
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,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
- page: Page,
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
- 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,42 @@
1
- import { computed, ref, type MaybeRef, type Ref } from "vue";
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 = 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<TValue | undefined>;
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: Ref<boolean>;
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
- } & 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
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
- <TValue extends ListboxValue = ListboxValue, TMultiple extends boolean = false>({
45
- listLabel,
103
+ <
104
+ TValue extends ListboxValue,
105
+ TAutoComplete extends ComboboxAutoComplete,
106
+ TMultiple extends boolean = false,
107
+ >({
46
108
  inputValue,
47
- isExpanded,
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
- }: CreateComboboxOptions<TValue, TMultiple>) => {
56
- const inputValid = ref(true);
123
+ templateRef,
124
+ }: CreateComboboxOptions<TValue, TAutoComplete, TMultiple>) => {
57
125
  const controlsId = createId("comboBox-control");
58
- const labelId = createId("comboBox-label");
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
- inputValue.value = inputElement.value as TValue;
63
- inputValid.value = inputElement.validity.valid;
133
+
134
+ if (autocomplete.value !== "none") {
135
+ onAutocomplete?.(inputElement.value);
136
+ }
64
137
  };
65
138
 
66
- const handleBlur = () => {
67
- if (isExpanded.value) {
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 handleKeydown = (event: KeyboardEvent) => {
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 (!activeOption.value) {
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 (!activeOption.value) {
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
- selectedOption: activeOption,
121
- onSelect,
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
- listBox: computed(() => ({
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-labelledby": labelId,
148
- "aria-activedescendant": activeOption.value ? getOptionId(activeOption.value) : undefined,
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
- onBlur: handleBlur,
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
  };
@@ -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;
@@ -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 isSelected = data.selected ?? false;
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
- * 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
@@ -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,2 +1,3 @@
1
1
  export * from "./composables/comboBox/createComboBox.ct";
2
2
  export * from "./composables/listbox/createListbox.ct";
3
+ export * from "./composables/menuButton/createMenuButton.ct";
@@ -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;