@sit-onyx/headless 1.0.0-beta.0 → 1.0.0-beta.10

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.
Files changed (40) hide show
  1. package/package.json +6 -2
  2. package/src/composables/comboBox/SelectOnlyCombobox.vue +6 -2
  3. package/src/composables/comboBox/TestCombobox.ct.tsx +1 -1
  4. package/src/composables/comboBox/TestCombobox.vue +9 -6
  5. package/src/composables/comboBox/createComboBox.ts +20 -15
  6. package/src/composables/helpers/useDismissible.ts +19 -0
  7. package/src/composables/helpers/useGlobalListener.spec.ts +93 -0
  8. package/src/composables/helpers/useGlobalListener.ts +64 -0
  9. package/src/composables/helpers/useOutsideClick.spec.ts +83 -0
  10. package/src/composables/helpers/useOutsideClick.ts +40 -0
  11. package/src/composables/{typeAhead.spec.ts → helpers/useTypeAhead.spec.ts} +1 -1
  12. package/src/composables/{typeAhead.ts → helpers/useTypeAhead.ts} +2 -2
  13. package/src/composables/listbox/TestListbox.ct.tsx +1 -1
  14. package/src/composables/listbox/TestListbox.vue +2 -0
  15. package/src/composables/listbox/createListbox.ts +26 -9
  16. package/src/composables/menuButton/TestMenuButton.ct.tsx +2 -2
  17. package/src/composables/menuButton/TestMenuButton.vue +7 -6
  18. package/src/composables/menuButton/{createMenuButton.ct.ts → createMenuButton.testing.ts} +17 -16
  19. package/src/composables/menuButton/createMenuButton.ts +121 -101
  20. package/src/composables/navigationMenu/TestMenu.ct.tsx +12 -0
  21. package/src/composables/navigationMenu/TestMenu.vue +16 -0
  22. package/src/composables/navigationMenu/createMenu.testing.ts +37 -0
  23. package/src/composables/navigationMenu/createMenu.ts +55 -0
  24. package/src/composables/tabs/TestTabs.ct.tsx +13 -0
  25. package/src/composables/tabs/TestTabs.vue +26 -0
  26. package/src/composables/tabs/createTabs.testing.ts +79 -0
  27. package/src/composables/tabs/createTabs.ts +72 -0
  28. package/src/composables/tooltip/createToggletip.ts +61 -0
  29. package/src/composables/tooltip/createTooltip.ts +37 -92
  30. package/src/index.ts +6 -1
  31. package/src/playwright.ts +5 -3
  32. package/src/utils/builder.ts +111 -13
  33. package/src/utils/math.spec.ts +14 -0
  34. package/src/utils/math.ts +6 -0
  35. package/src/utils/types.ts +7 -0
  36. package/src/utils/vitest.ts +36 -0
  37. package/src/composables/outsideClick.ts +0 -52
  38. package/src/utils/id.ts +0 -14
  39. /package/src/composables/comboBox/{createComboBox.ct.ts → createComboBox.testing.ts} +0 -0
  40. /package/src/composables/listbox/{createListbox.ct.ts → createListbox.testing.ts} +0 -0
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sit-onyx/headless",
3
3
  "description": "Headless composables for Vue",
4
- "version": "1.0.0-beta.0",
4
+ "version": "1.0.0-beta.10",
5
5
  "type": "module",
6
6
  "author": "Schwarz IT KG",
7
7
  "license": "Apache-2.0",
@@ -24,7 +24,11 @@
24
24
  },
25
25
  "peerDependencies": {
26
26
  "typescript": ">= 5",
27
- "vue": ">= 3"
27
+ "vue": ">= 3.5.0"
28
+ },
29
+ "devDependencies": {
30
+ "@vue/compiler-dom": "3.5.12",
31
+ "vue": "3.5.12"
28
32
  },
29
33
  "scripts": {
30
34
  "build": "vue-tsc --build --force",
@@ -26,7 +26,6 @@ const onToggle = () => (isExpanded.value = !isExpanded.value);
26
26
  const onTypeAhead = () => {};
27
27
 
28
28
  const comboBox = createComboBox({
29
- inputValue: selectedOption,
30
29
  autocomplete: "none",
31
30
  label: "some label",
32
31
  listLabel: "List",
@@ -51,7 +50,12 @@ defineExpose({ comboBox });
51
50
 
52
51
  <template>
53
52
  <div ref="comboboxRef">
54
- <input v-bind="input" readonly @keydown.arrow-down="isExpanded = true" />
53
+ <input
54
+ v-bind="input"
55
+ v-model="selectedOption"
56
+ readonly
57
+ @keydown.arrow-down="isExpanded = true"
58
+ />
55
59
 
56
60
  <button v-bind="button">
57
61
  <template v-if="isExpanded">⬆️</template>
@@ -1,5 +1,5 @@
1
1
  import { test } from "@playwright/experimental-ct-vue";
2
- import { comboboxSelectOnlyTesting, comboboxTesting } from "./createComboBox.ct";
2
+ import { comboboxSelectOnlyTesting, comboboxTesting } from "./createComboBox.testing";
3
3
  import SelectOnlyCombobox from "./SelectOnlyCombobox.vue";
4
4
  import TestCombobox from "./TestCombobox.vue";
5
5
 
@@ -30,7 +30,6 @@ const onAutocomplete = (input: string) => (searchTerm.value = input);
30
30
  const onToggle = () => (isExpanded.value = !isExpanded.value);
31
31
 
32
32
  const comboBox = createComboBox({
33
- inputValue: searchTerm,
34
33
  autocomplete: "list",
35
34
  label: "some label",
36
35
  listLabel: "List",
@@ -55,13 +54,13 @@ defineExpose({ comboBox });
55
54
 
56
55
  <template>
57
56
  <div ref="comboboxRef">
58
- <input v-bind="input" @keydown.arrow-down="isExpanded = true" />
57
+ <input v-bind="input" v-model="searchTerm" @keydown.arrow-down="isExpanded = true" />
59
58
 
60
- <button v-bind="button">
61
- <template v-if="isExpanded">⬆️</template>
62
- <template v-else>⬇️</template>
59
+ <button v-bind="button" type="button">
60
+ <template v-if="isExpanded"> ⬆️ </template>
61
+ <template v-else> ⬇️ </template>
63
62
  </button>
64
- <ul v-bind="listbox" :class="{ hidden: !isExpanded }" style="width: 400px">
63
+ <ul v-bind="listbox" :class="{ list: true, hidden: !isExpanded }">
65
64
  <li
66
65
  v-for="e in filteredOptions"
67
66
  :key="e"
@@ -75,6 +74,10 @@ defineExpose({ comboBox });
75
74
  </template>
76
75
 
77
76
  <style>
77
+ .list {
78
+ width: 400px;
79
+ }
80
+
78
81
  .hidden {
79
82
  display: none;
80
83
  }
@@ -1,15 +1,15 @@
1
- import { computed, unref, type MaybeRef, type Ref } from "vue";
1
+ import { computed, unref, useId, type MaybeRef, type Ref } from "vue";
2
2
  import { createBuilder } from "../../utils/builder";
3
- import { createId } from "../../utils/id";
4
3
  import { isPrintableCharacter, wasKeyPressed, type PressedKey } from "../../utils/keyboard";
4
+ import { useOutsideClick } from "../helpers/useOutsideClick";
5
+ import { useTypeAhead } from "../helpers/useTypeAhead";
5
6
  import {
6
7
  createListbox,
7
8
  type CreateListboxOptions,
8
9
  type ListboxValue,
9
10
  } from "../listbox/createListbox";
10
- import { useOutsideClick } from "../outsideClick";
11
- import { useTypeAhead } from "../typeAhead";
12
11
 
12
+ /** See https://w3c.github.io/aria/#aria-autocomplete */
13
13
  export type ComboboxAutoComplete = "none" | "list" | "both";
14
14
 
15
15
  export const OPENING_KEYS: PressedKey[] = ["ArrowDown", "ArrowUp", " ", "Enter", "Home", "End"];
@@ -19,11 +19,15 @@ export const CLOSING_KEYS: PressedKey[] = [
19
19
  "Enter",
20
20
  "Tab",
21
21
  ];
22
- const SELECTING_KEYS_SINGLE: PressedKey[] = ["Enter", " "];
23
- const SELECTING_KEYS_MULTIPLE: PressedKey[] = ["Enter"];
24
22
 
25
- const isSelectingKey = (event: KeyboardEvent, isMultiselect?: boolean) => {
26
- const selectingKeys = isMultiselect ? SELECTING_KEYS_MULTIPLE : SELECTING_KEYS_SINGLE;
23
+ const SELECTING_KEYS: PressedKey[] = ["Enter"];
24
+
25
+ /**
26
+ * if the a search input is included, space should not be used to select
27
+ * TODO: idea for the future: move this distinction to the listbox?
28
+ */
29
+ const isSelectingKey = (event: KeyboardEvent, withSpace?: boolean) => {
30
+ const selectingKeys = withSpace ? [...SELECTING_KEYS, " "] : SELECTING_KEYS;
27
31
  return isKeyOfGroup(event, selectingKeys);
28
32
  };
29
33
 
@@ -42,9 +46,9 @@ export type CreateComboboxOptions<
42
46
  */
43
47
  listLabel: MaybeRef<string>;
44
48
  /**
45
- * The current value of the combobox. Is updated when an option from the controlled listbox is selected or by typing into it.
49
+ * Provides additional description for the listbox which displays the available options.
46
50
  */
47
- inputValue: Ref<string | undefined>;
51
+ listDescription?: MaybeRef<string | undefined>;
48
52
  /**
49
53
  * Controls the opened/visible state of the associated pop-up. When expanded the activeOption can be controlled via the keyboard.
50
54
  */
@@ -105,13 +109,13 @@ export const createComboBox = createBuilder(
105
109
  TAutoComplete extends ComboboxAutoComplete,
106
110
  TMultiple extends boolean = false,
107
111
  >({
108
- inputValue,
109
112
  autocomplete: autocompleteRef,
110
113
  onAutocomplete,
111
114
  onTypeAhead,
112
115
  multiple: multipleRef,
113
116
  label,
114
117
  listLabel,
118
+ listDescription,
115
119
  isExpanded: isExpandedRef,
116
120
  activeOption,
117
121
  onToggle,
@@ -122,7 +126,7 @@ export const createComboBox = createBuilder(
122
126
  onActivatePrevious,
123
127
  templateRef,
124
128
  }: CreateComboboxOptions<TValue, TAutoComplete, TMultiple>) => {
125
- const controlsId = createId("comboBox-control");
129
+ const controlsId = useId();
126
130
 
127
131
  const autocomplete = computed(() => unref(autocompleteRef));
128
132
  const isExpanded = computed(() => unref(isExpandedRef));
@@ -188,7 +192,7 @@ export const createComboBox = createBuilder(
188
192
  }
189
193
  return onActivateFirst?.();
190
194
  }
191
- if (isSelectingKey(event, multiple.value)) {
195
+ if (isSelectingKey(event, autocomplete.value === "none")) {
192
196
  return handleSelect(activeOption.value!);
193
197
  }
194
198
  if (isExpanded.value && isKeyOfGroup(event, CLOSING_KEYS)) {
@@ -218,14 +222,16 @@ export const createComboBox = createBuilder(
218
222
  internals: { getOptionId },
219
223
  } = createListbox({
220
224
  label: listLabel,
225
+ description: listDescription,
221
226
  multiple,
222
227
  controlled: true,
223
228
  activeOption,
229
+ isExpanded,
224
230
  onSelect: handleSelect,
225
231
  });
226
232
 
227
233
  useOutsideClick({
228
- queryComponent: () => templateRef.value,
234
+ inside: templateRef,
229
235
  onOutsideClick() {
230
236
  if (!isExpanded.value) return;
231
237
  onToggle?.(true);
@@ -250,7 +256,6 @@ export const createComboBox = createBuilder(
250
256
  * The input MAY be either a single-line text field that supports editing and typing or an element that only displays the current value of the combobox.
251
257
  */
252
258
  input: computed(() => ({
253
- value: inputValue.value,
254
259
  role: "combobox",
255
260
  "aria-expanded": isExpanded.value,
256
261
  "aria-controls": controlsId,
@@ -0,0 +1,19 @@
1
+ import { computed, type Ref } from "vue";
2
+ import { useGlobalEventListener } from "./useGlobalListener";
3
+
4
+ type UseDismissibleOptions = { isExpanded: Ref<boolean> };
5
+
6
+ /**
7
+ * Composable that sets `isExpanded` to false, when the `Escape` key is pressed.
8
+ * Addresses the "dismissible" aspect of https://www.w3.org/WAI/WCAG21/Understanding/content-on-hover-or-focus.html
9
+ */
10
+ export const useDismissible = ({ isExpanded }: UseDismissibleOptions) =>
11
+ useGlobalEventListener({
12
+ type: "keydown",
13
+ listener: (e) => {
14
+ if (e.key === "Escape") {
15
+ isExpanded.value = false;
16
+ }
17
+ },
18
+ disabled: computed(() => !isExpanded.value),
19
+ });
@@ -0,0 +1,93 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { ref, type Ref } from "vue";
3
+ import { mockVueLifecycle } from "../../utils/vitest";
4
+ import { useGlobalEventListener } from "./useGlobalListener";
5
+
6
+ let unmount: () => Promise<void> | undefined;
7
+
8
+ describe("useGlobalEventListener", () => {
9
+ let target: Ref<HTMLButtonElement>;
10
+
11
+ beforeEach(() => {
12
+ vi.clearAllMocks();
13
+ unmount = mockVueLifecycle();
14
+ target = ref(document.createElement("button"));
15
+ document.body.appendChild(target.value);
16
+ });
17
+
18
+ it("should be defined", () => {
19
+ expect(useGlobalEventListener).toBeDefined();
20
+ });
21
+
22
+ it("should listen to global events", () => {
23
+ // ARRANGE
24
+ const listener = vi.fn();
25
+ useGlobalEventListener({ type: "click", listener });
26
+ // ACT
27
+ const event = new MouseEvent("click", { bubbles: true });
28
+ target.value.dispatchEvent(event);
29
+ // ASSERT
30
+ expect(listener).toHaveBeenCalledTimes(1);
31
+ expect(listener).toBeCalledWith(event);
32
+ });
33
+
34
+ it("should stop to listen to global events after unmount", async () => {
35
+ // ARRANGE
36
+ const listener = vi.fn();
37
+ useGlobalEventListener({ type: "click", listener });
38
+ // ACT
39
+ await unmount();
40
+ expect(listener).toHaveBeenCalledTimes(0);
41
+ target.value.dispatchEvent(new MouseEvent("click", { bubbles: true }));
42
+ // ASSERT
43
+ expect(listener).toHaveBeenCalledTimes(0);
44
+ });
45
+
46
+ it("should allow for multiple of the same listener types", async () => {
47
+ // ARRANGE
48
+ vi.useFakeTimers();
49
+ const listener = vi.fn();
50
+ const disabled = ref(false);
51
+ const listener2 = vi.fn();
52
+ useGlobalEventListener({ type: "click", listener, disabled });
53
+ useGlobalEventListener({ type: "click", listener: listener2 });
54
+ // ACT
55
+ target.value.dispatchEvent(new MouseEvent("click", { bubbles: true }));
56
+ // ASSERT
57
+ expect(listener).toHaveBeenCalledTimes(1);
58
+ expect(listener2).toHaveBeenCalledTimes(1);
59
+ // ACT
60
+ disabled.value = true;
61
+ await vi.runAllTimersAsync();
62
+ // ACT
63
+ target.value.dispatchEvent(new MouseEvent("click", { bubbles: true }));
64
+ // ASSERT
65
+ expect(listener).toHaveBeenCalledTimes(1);
66
+ expect(listener2).toHaveBeenCalledTimes(2);
67
+ });
68
+
69
+ it("should not listen to events when disabled", async () => {
70
+ // ARRANGE
71
+ vi.useFakeTimers();
72
+ const disabled = ref(false);
73
+ const listener = vi.fn();
74
+ useGlobalEventListener({ type: "click", listener, disabled });
75
+ // ACT
76
+ await vi.runAllTimersAsync();
77
+ target.value.dispatchEvent(new MouseEvent("click", { bubbles: true }));
78
+ // ASSERT
79
+ expect(listener).toHaveBeenCalledTimes(1);
80
+ // ACT
81
+ disabled.value = true;
82
+ await vi.runAllTimersAsync();
83
+ target.value.dispatchEvent(new MouseEvent("click", { bubbles: true }));
84
+ // ASSERT
85
+ expect(listener).toHaveBeenCalledTimes(1);
86
+ // ACT
87
+ disabled.value = false;
88
+ await vi.runAllTimersAsync();
89
+ target.value.dispatchEvent(new MouseEvent("click", { bubbles: true }));
90
+ // ASSERT
91
+ expect(listener).toHaveBeenCalledTimes(2);
92
+ });
93
+ });
@@ -0,0 +1,64 @@
1
+ import { onBeforeMount, onBeforeUnmount, reactive, watchEffect, type Ref } from "vue";
2
+
3
+ type DocumentEventType = keyof DocumentEventMap;
4
+ type GlobalListener<K extends DocumentEventType = DocumentEventType> = (
5
+ event: DocumentEventMap[K],
6
+ ) => void;
7
+
8
+ export type UseGlobalEventListenerOptions<K extends DocumentEventType> = {
9
+ type: K;
10
+ listener: GlobalListener<K>;
11
+ disabled?: Ref<boolean>;
12
+ };
13
+
14
+ const GLOBAL_LISTENERS = reactive(new Map<DocumentEventType, Set<GlobalListener>>());
15
+
16
+ const updateRemainingListeners = (type: DocumentEventType, remaining?: Set<GlobalListener>) => {
17
+ if (remaining?.size) {
18
+ GLOBAL_LISTENERS.set(type, remaining);
19
+ return;
20
+ }
21
+ GLOBAL_LISTENERS.delete(type);
22
+ document.removeEventListener(type, GLOBAL_HANDLER);
23
+ };
24
+
25
+ const removeGlobalListener = <K extends DocumentEventType>(
26
+ type: K,
27
+ listener: GlobalListener<K>,
28
+ ) => {
29
+ const globalListener = GLOBAL_LISTENERS.get(type);
30
+ globalListener?.delete(listener as GlobalListener);
31
+
32
+ updateRemainingListeners(type, globalListener);
33
+ };
34
+
35
+ const addGlobalListener = <K extends DocumentEventType>(type: K, listener: GlobalListener<K>) => {
36
+ const globalListener = GLOBAL_LISTENERS.get(type) ?? new Set();
37
+ globalListener.add(listener as GlobalListener);
38
+ GLOBAL_LISTENERS.set(type, globalListener);
39
+
40
+ document.addEventListener(type, GLOBAL_HANDLER);
41
+ };
42
+
43
+ /**
44
+ * A single and unique function for all event types.
45
+ * We use the fact that `addEventListener` and `removeEventListener` are idempotent when called with the same function reference.
46
+ */
47
+ const GLOBAL_HANDLER = (event: Event) => {
48
+ const type = event.type as DocumentEventType;
49
+ GLOBAL_LISTENERS.get(type)?.forEach((cb) => cb(event));
50
+ };
51
+
52
+ export const useGlobalEventListener = <K extends DocumentEventType>({
53
+ type,
54
+ listener,
55
+ disabled,
56
+ }: UseGlobalEventListenerOptions<K>) => {
57
+ onBeforeMount(() =>
58
+ watchEffect(() =>
59
+ disabled?.value ? removeGlobalListener(type, listener) : addGlobalListener(type, listener),
60
+ ),
61
+ );
62
+
63
+ onBeforeUnmount(() => removeGlobalListener(type, listener));
64
+ };
@@ -0,0 +1,83 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { ref } from "vue";
3
+ import { mockVueLifecycle } from "../../utils/vitest";
4
+ import { useOutsideClick } from "./useOutsideClick";
5
+
6
+ describe("useOutsideClick", () => {
7
+ beforeEach(() => {
8
+ vi.clearAllMocks();
9
+ mockVueLifecycle();
10
+ });
11
+
12
+ it("should be defined", () => {
13
+ expect(useOutsideClick).toBeDefined();
14
+ });
15
+
16
+ it("should detect outside clicks", () => {
17
+ // ARRANGE
18
+ const inside = ref(document.createElement("button"));
19
+ document.body.appendChild(inside.value);
20
+ const outside = ref(document.createElement("button"));
21
+ document.body.appendChild(outside.value);
22
+
23
+ const onOutsideClick = vi.fn();
24
+ useOutsideClick({ inside, onOutsideClick });
25
+ // ACT
26
+ const event = new MouseEvent("click", { bubbles: true });
27
+ outside.value.dispatchEvent(event);
28
+ // ASSERT
29
+ expect(onOutsideClick).toHaveBeenCalledTimes(1);
30
+ expect(onOutsideClick).toBeCalledWith(event);
31
+ });
32
+
33
+ it("should detect outside clicks correctly for multiple inside elements", () => {
34
+ // ARRANGE
35
+ const inside = [document.createElement("button"), document.createElement("button")];
36
+ inside.forEach((e) => document.body.appendChild(e));
37
+ const outside = ref(document.createElement("button"));
38
+ document.body.appendChild(outside.value);
39
+
40
+ const onOutsideClick = vi.fn();
41
+ useOutsideClick({ inside, onOutsideClick });
42
+ // ACT
43
+ const event = new MouseEvent("click", { bubbles: true });
44
+ inside[0].dispatchEvent(event);
45
+ inside[1].dispatchEvent(event);
46
+ // ASSERT
47
+ expect(onOutsideClick).not.toHaveBeenCalled();
48
+
49
+ // ACT
50
+ outside.value.dispatchEvent(event);
51
+ // ASSERT
52
+ expect(onOutsideClick).toHaveBeenCalledTimes(1);
53
+ expect(onOutsideClick).toBeCalledWith(event);
54
+ });
55
+
56
+ it("should ignore outside clicks when disabled", async () => {
57
+ // ARRANGE
58
+ vi.useFakeTimers();
59
+ const inside = ref(document.createElement("button"));
60
+ document.body.appendChild(inside.value);
61
+ const outside = ref(document.createElement("button"));
62
+ document.body.appendChild(outside.value);
63
+
64
+ const disabled = ref(false);
65
+ const onOutsideClick = vi.fn();
66
+ useOutsideClick({ inside, disabled, onOutsideClick });
67
+
68
+ // ACT
69
+ const event = new MouseEvent("click", { bubbles: true });
70
+ outside.value.dispatchEvent(event);
71
+ // ASSERT
72
+ expect(onOutsideClick).toHaveBeenCalledTimes(1);
73
+ expect(onOutsideClick).toBeCalledWith(event);
74
+
75
+ // ACT
76
+ disabled.value = true;
77
+ await vi.runAllTimersAsync();
78
+ const event2 = new MouseEvent("click", { bubbles: true });
79
+ outside.value.dispatchEvent(event2);
80
+ // ASSERT
81
+ expect(onOutsideClick).toHaveBeenCalledTimes(1);
82
+ });
83
+ });
@@ -0,0 +1,40 @@
1
+ import type { Arrayable } from "vitest";
2
+ import { toValue, type Ref } from "vue";
3
+ import type { MaybeReactiveSource } from "../../utils/types";
4
+ import { useGlobalEventListener } from "./useGlobalListener";
5
+
6
+ export type UseOutsideClickOptions = {
7
+ /**
8
+ * HTML element of the component where clicks should be ignored
9
+ */
10
+ inside: MaybeReactiveSource<Arrayable<HTMLElement | undefined>>;
11
+ /**
12
+ * Callback when an outside click occurred.
13
+ */
14
+ onOutsideClick: (event: MouseEvent) => void;
15
+ /**
16
+ * If `true`, event listeners will be removed and no outside clicks will be captured.
17
+ */
18
+ disabled?: Ref<boolean>;
19
+ };
20
+
21
+ /**
22
+ * Composable for listening to click events that occur outside of a component.
23
+ * Useful to e.g. close flyouts or tooltips.
24
+ */
25
+ export const useOutsideClick = ({ inside, onOutsideClick, disabled }: UseOutsideClickOptions) => {
26
+ /**
27
+ * Document click handle that closes then tooltip when clicked outside.
28
+ * Should only be called when trigger is "click".
29
+ */
30
+ const listener = (event: MouseEvent) => {
31
+ const raw = toValue(inside);
32
+ const elements = Array.isArray(raw) ? raw : [raw];
33
+ const isOutsideClick = !elements.some((element) =>
34
+ element?.contains(event.target as HTMLElement),
35
+ );
36
+ if (isOutsideClick) onOutsideClick(event);
37
+ };
38
+
39
+ useGlobalEventListener({ type: "click", listener, disabled });
40
+ };
@@ -1,5 +1,5 @@
1
1
  import { beforeAll, expect, test, vi } from "vitest";
2
- import { useTypeAhead } from "./typeAhead";
2
+ import { useTypeAhead } from "./useTypeAhead";
3
3
 
4
4
  beforeAll(() => {
5
5
  vi.useFakeTimers();
@@ -1,5 +1,5 @@
1
- import { isPrintableCharacter } from "../utils/keyboard";
2
- import { debounce } from "../utils/timer";
1
+ import { isPrintableCharacter } from "../../utils/keyboard";
2
+ import { debounce } from "../../utils/timer";
3
3
 
4
4
  /**
5
5
  * Enhances typeAhead to combine multiple inputs in quick succession and filter out non-printable characters.
@@ -1,6 +1,6 @@
1
1
  import { test } from "@playwright/experimental-ct-vue";
2
2
  import TestListbox from "./TestListbox.vue";
3
- import { listboxTesting } from "./createListbox.ct";
3
+ import { listboxTesting } from "./createListbox.testing";
4
4
 
5
5
  test("listbox", async ({ mount, page }) => {
6
6
  await mount(<TestListbox />);
@@ -30,7 +30,9 @@ const {
30
30
  elements: { listbox, option: headlessOption },
31
31
  } = createListbox({
32
32
  label: "Test listbox",
33
+ description: "Test description",
33
34
  activeOption,
35
+ isExpanded: true,
34
36
  onSelect: (id) => {
35
37
  selectedOption.value = selectedOption.value === id ? undefined : id;
36
38
  },
@@ -1,7 +1,6 @@
1
- import { computed, ref, unref, watchEffect, type MaybeRef, type Ref } from "vue";
2
- import { createId } from "../..";
3
- import { createBuilder, type HeadlessElementAttributes } from "../../utils/builder";
4
- import { useTypeAhead } from "../typeAhead";
1
+ import { computed, nextTick, ref, unref, useId, watchEffect, type MaybeRef, type Ref } from "vue";
2
+ import { createBuilder, type VBindAttributes } from "../../utils/builder";
3
+ import { useTypeAhead } from "../helpers/useTypeAhead";
5
4
 
6
5
  export type ListboxValue = string | number | boolean;
7
6
 
@@ -10,6 +9,10 @@ export type CreateListboxOptions<TValue extends ListboxValue, TMultiple extends
10
9
  * Aria label for the listbox.
11
10
  */
12
11
  label: MaybeRef<string>;
12
+ /**
13
+ * Aria description for the listbox.
14
+ */
15
+ description?: MaybeRef<string | undefined>;
13
16
  /**
14
17
  * Value of currently (visually) active option.
15
18
  */
@@ -19,6 +22,10 @@ export type CreateListboxOptions<TValue extends ListboxValue, TMultiple extends
19
22
  * This disables keyboard events and makes the listbox not focusable.
20
23
  */
21
24
  controlled?: boolean;
25
+ /**
26
+ * Controls the opened/visible state of the listbox. When expanded the activeOption can be controlled via the keyboard.
27
+ */
28
+ isExpanded?: MaybeRef<boolean>;
22
29
  /**
23
30
  * Whether the listbox is multiselect.
24
31
  */
@@ -77,6 +84,7 @@ export const createListbox = createBuilder(
77
84
  options: CreateListboxOptions<TValue, TMultiple>,
78
85
  ) => {
79
86
  const isMultiselect = computed(() => unref(options.multiple) ?? false);
87
+ const isExpanded = computed(() => unref(options.isExpanded) ?? false);
80
88
 
81
89
  /**
82
90
  * Map for option IDs. key = option value, key = ID for the HTML element
@@ -85,7 +93,7 @@ export const createListbox = createBuilder(
85
93
 
86
94
  const getOptionId = (value: TValue) => {
87
95
  if (!descendantKeyIdMap.has(value)) {
88
- descendantKeyIdMap.set(value, createId("listbox-option"));
96
+ descendantKeyIdMap.set(value, useId());
89
97
  }
90
98
  return descendantKeyIdMap.get(value)!;
91
99
  };
@@ -96,11 +104,18 @@ export const createListbox = createBuilder(
96
104
  const isFocused = ref(false);
97
105
 
98
106
  // scroll currently active option into view if needed
99
- watchEffect(() => {
100
- if (options.activeOption.value == undefined || (!isFocused.value && !options.controlled))
107
+ watchEffect(async () => {
108
+ if (
109
+ !isExpanded.value ||
110
+ options.activeOption.value == undefined ||
111
+ (!isFocused.value && !options.controlled)
112
+ )
101
113
  return;
102
114
  const id = getOptionId(options.activeOption.value);
103
- document.getElementById(id)?.scrollIntoView({ block: "nearest", inline: "nearest" });
115
+
116
+ await nextTick(() => {
117
+ document.getElementById(id)?.scrollIntoView({ block: "end", inline: "nearest" });
118
+ });
104
119
  });
105
120
 
106
121
  const typeAhead = useTypeAhead((inputString) => options.onTypeAhead?.(inputString));
@@ -152,18 +167,20 @@ export const createListbox = createBuilder(
152
167
  }
153
168
  };
154
169
 
155
- const listbox = computed<HeadlessElementAttributes>(() =>
170
+ const listbox = computed<VBindAttributes>(() =>
156
171
  options.controlled
157
172
  ? {
158
173
  role: "listbox",
159
174
  "aria-multiselectable": isMultiselect.value,
160
175
  "aria-label": unref(options.label),
176
+ "aria-description": options.description,
161
177
  tabindex: "-1",
162
178
  }
163
179
  : {
164
180
  role: "listbox",
165
181
  "aria-multiselectable": isMultiselect.value,
166
182
  "aria-label": unref(options.label),
183
+ "aria-description": options.description,
167
184
  tabindex: "0",
168
185
  "aria-activedescendant":
169
186
  options.activeOption.value != undefined
@@ -1,5 +1,5 @@
1
1
  import { test } from "@playwright/experimental-ct-vue";
2
- import { menuButtonTesting } from "./createMenuButton.ct";
2
+ import { menuButtonTesting } from "./createMenuButton.testing";
3
3
  import TestMenuButton from "./TestMenuButton.vue";
4
4
 
5
5
  test("menuButton", async ({ mount, page }) => {
@@ -9,6 +9,6 @@ test("menuButton", async ({ mount, page }) => {
9
9
  page,
10
10
  button: page.getByRole("button"),
11
11
  menu: page.locator("ul"),
12
- menuItems: await page.locator("li").all(),
12
+ menuItems: page.getByRole("menuitem"),
13
13
  });
14
14
  });
@@ -8,18 +8,19 @@ const items = Array.from({ length: 10 }, (_, index) => {
8
8
  });
9
9
 
10
10
  const activeItem = ref<string>();
11
+ const isExpanded = ref(false);
12
+ const onToggle = () => (isExpanded.value = !isExpanded.value);
11
13
 
12
14
  const {
13
- elements: { button, menu, menuItem, listItem, flyout },
14
- state: { isExpanded },
15
- } = createMenuButton({});
15
+ elements: { root, button, menu, menuItem, listItem },
16
+ } = createMenuButton({ isExpanded, onToggle });
16
17
  </script>
17
18
 
18
19
  <template>
19
- <button v-bind="button">Toggle nav menu</button>
20
- <div v-bind="flyout">
20
+ <div v-bind="root">
21
+ <button v-bind="button">Toggle nav menu</button>
21
22
  <ul v-show="isExpanded" v-bind="menu">
22
- <li v-for="item in items" v-bind="listItem" :key="item.value" title="item">
23
+ <li v-for="item in items" v-bind="listItem" :key="item.value">
23
24
  <a v-bind="menuItem({ active: activeItem === item.value })" href="#">{{ item.label }}</a>
24
25
  </li>
25
26
  </ul>