@sit-onyx/headless 1.0.0-beta.2 → 1.0.0-beta.21

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 (41) hide show
  1. package/README.md +1 -5
  2. package/package.json +11 -3
  3. package/src/composables/comboBox/SelectOnlyCombobox.vue +15 -8
  4. package/src/composables/comboBox/TestCombobox.ct.tsx +1 -1
  5. package/src/composables/comboBox/TestCombobox.vue +13 -10
  6. package/src/composables/comboBox/createComboBox.ts +34 -28
  7. package/src/composables/helpers/useDismissible.ts +19 -0
  8. package/src/composables/helpers/useGlobalListener.spec.ts +2 -2
  9. package/src/composables/helpers/useGlobalListener.ts +1 -1
  10. package/src/composables/helpers/useOutsideClick.spec.ts +117 -0
  11. package/src/composables/helpers/useOutsideClick.ts +45 -10
  12. package/src/composables/helpers/useTypeAhead.spec.ts +1 -1
  13. package/src/composables/helpers/useTypeAhead.ts +2 -2
  14. package/src/composables/listbox/TestListbox.ct.tsx +1 -1
  15. package/src/composables/listbox/TestListbox.vue +3 -1
  16. package/src/composables/listbox/createListbox.ts +28 -10
  17. package/src/composables/menuButton/TestMenuButton.ct.tsx +1 -1
  18. package/src/composables/menuButton/TestMenuButton.vue +4 -3
  19. package/src/composables/menuButton/createMenuButton.testing.ts +0 -19
  20. package/src/composables/menuButton/createMenuButton.ts +174 -119
  21. package/src/composables/navigationMenu/TestMenu.ct.tsx +1 -1
  22. package/src/composables/navigationMenu/TestMenu.vue +1 -1
  23. package/src/composables/navigationMenu/createMenu.testing.ts +2 -13
  24. package/src/composables/navigationMenu/createMenu.ts +6 -7
  25. package/src/composables/tabs/TestTabs.ct.tsx +12 -0
  26. package/src/composables/tabs/TestTabs.vue +28 -0
  27. package/src/composables/tabs/createTabs.testing.ts +151 -0
  28. package/src/composables/tabs/createTabs.ts +129 -0
  29. package/src/composables/tooltip/createToggletip.ts +58 -0
  30. package/src/composables/tooltip/createTooltip.ts +39 -97
  31. package/src/index.ts +11 -8
  32. package/src/playwright.ts +5 -3
  33. package/src/utils/builder.ts +108 -12
  34. package/src/utils/keyboard.spec.ts +1 -1
  35. package/src/utils/keyboard.ts +1 -1
  36. package/src/utils/math.spec.ts +1 -1
  37. package/src/utils/object.spec.ts +1 -1
  38. package/src/utils/timer.ts +10 -3
  39. package/src/utils/types.ts +10 -0
  40. package/src/utils/vitest.ts +2 -2
  41. package/src/utils/id.ts +0 -14
package/README.md CHANGED
@@ -1,9 +1,5 @@
1
1
  <div align="center" style="text-align: center">
2
- <picture>
3
- <source media="(prefers-color-scheme: dark)" type="image/svg+xml" srcset="https://raw.githubusercontent.com/SchwarzIT/onyx/main/.github/onyx-logo-light.svg">
4
- <source media="(prefers-color-scheme: light)" type="image/svg+xml" srcset="https://raw.githubusercontent.com/SchwarzIT/onyx/main/.github/onyx-logo-dark.svg">
5
- <img alt="onyx logo" src="https://raw.githubusercontent.com/SchwarzIT/onyx/main/.github/onyx-logo-dark.svg" width="160px">
6
- </picture>
2
+ <img alt="onyx logo" src="https://raw.githubusercontent.com/SchwarzIT/onyx/main/.github/onyx-logo.svg" height="96px">
7
3
  </div>
8
4
 
9
5
  <br>
package/package.json CHANGED
@@ -1,10 +1,13 @@
1
1
  {
2
2
  "name": "@sit-onyx/headless",
3
3
  "description": "Headless composables for Vue",
4
- "version": "1.0.0-beta.2",
4
+ "version": "1.0.0-beta.21",
5
5
  "type": "module",
6
6
  "author": "Schwarz IT KG",
7
7
  "license": "Apache-2.0",
8
+ "engines": {
9
+ "node": ">=20"
10
+ },
8
11
  "files": [
9
12
  "src"
10
13
  ],
@@ -24,11 +27,16 @@
24
27
  },
25
28
  "peerDependencies": {
26
29
  "typescript": ">= 5",
27
- "vue": ">= 3"
30
+ "vue": ">= 3.5.0"
31
+ },
32
+ "devDependencies": {
33
+ "@vue/compiler-dom": "3.5.16",
34
+ "vue": "3.5.16",
35
+ "@sit-onyx/shared": "^1.0.0-beta.4"
28
36
  },
29
37
  "scripts": {
30
38
  "build": "vue-tsc --build --force",
31
39
  "test": "vitest",
32
- "test:components": "playwright install && playwright test"
40
+ "test:playwright": "playwright install && playwright test"
33
41
  }
34
42
  }
@@ -1,10 +1,10 @@
1
1
  <script setup lang="ts">
2
- import { computed, ref } from "vue";
3
- import { createComboBox } from "./createComboBox";
2
+ import { computed, ref, useTemplateRef } from "vue";
3
+ import { createComboBox } from "./createComboBox.js";
4
4
 
5
5
  const options = ["a", "b", "c", "d"];
6
6
  const isExpanded = ref(false);
7
- const comboboxRef = ref<HTMLElement>();
7
+ const comboboxRef = useTemplateRef("combobox");
8
8
  const activeOption = ref("");
9
9
  const selectedOption = ref("");
10
10
  const selectedIndex = computed<number | undefined>(() => {
@@ -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",
@@ -50,14 +49,19 @@ defineExpose({ comboBox });
50
49
  </script>
51
50
 
52
51
  <template>
53
- <div ref="comboboxRef">
54
- <input v-bind="input" readonly @keydown.arrow-down="isExpanded = true" />
52
+ <div ref="combobox">
53
+ <input
54
+ v-bind="input"
55
+ v-model="selectedOption"
56
+ readonly
57
+ @keydown.arrow-down="isExpanded = true"
58
+ />
55
59
 
56
- <button v-bind="button">
60
+ <button v-bind="button" type="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 class="listbox" v-bind="listbox" :class="{ hidden: !isExpanded }">
61
65
  <li
62
66
  v-for="e in options"
63
67
  :key="e"
@@ -80,4 +84,7 @@ defineExpose({ comboBox });
80
84
  [aria-selected="true"] {
81
85
  background-color: red;
82
86
  }
87
+ .listbox {
88
+ width: 400px;
89
+ }
83
90
  </style>
@@ -1,5 +1,5 @@
1
1
  import { test } from "@playwright/experimental-ct-vue";
2
- import { comboboxSelectOnlyTesting, comboboxTesting } from "./createComboBox.testing";
2
+ import { comboboxSelectOnlyTesting, comboboxTesting } from "./createComboBox.testing.js";
3
3
  import SelectOnlyCombobox from "./SelectOnlyCombobox.vue";
4
4
  import TestCombobox from "./TestCombobox.vue";
5
5
 
@@ -1,11 +1,11 @@
1
1
  <script setup lang="ts">
2
- import { computed, ref } from "vue";
3
- import { createComboBox } from "./createComboBox";
2
+ import { computed, ref, useTemplateRef } from "vue";
3
+ import { createComboBox } from "./createComboBox.js";
4
4
 
5
5
  const options = ["a", "b", "c", "d"];
6
6
  const isExpanded = ref(false);
7
7
  const searchTerm = ref("");
8
- const comboboxRef = ref<HTMLElement>();
8
+ const comboboxRef = useTemplateRef("combobox");
9
9
  const activeOption = ref("");
10
10
  const filteredOptions = computed(() => options.filter((v) => v.includes(searchTerm.value)));
11
11
  const selectedIndex = computed<number | undefined>(() => {
@@ -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",
@@ -54,14 +53,14 @@ defineExpose({ comboBox });
54
53
  </script>
55
54
 
56
55
  <template>
57
- <div ref="comboboxRef">
58
- <input v-bind="input" @keydown.arrow-down="isExpanded = true" />
56
+ <div ref="combobox">
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,16 @@
1
- import { computed, unref, type MaybeRef, type Ref } from "vue";
2
- import { createBuilder } from "../../utils/builder";
3
- import { createId } from "../../utils/id";
4
- import { isPrintableCharacter, wasKeyPressed, type PressedKey } from "../../utils/keyboard";
5
- import { useOutsideClick } from "../helpers/useOutsideClick";
6
- import { useTypeAhead } from "../helpers/useTypeAhead";
1
+ import { computed, unref, useId, type MaybeRef, type Ref } from "vue";
2
+ import { createBuilder } from "../../utils/builder.js";
3
+ import { isPrintableCharacter, wasKeyPressed, type PressedKey } from "../../utils/keyboard.js";
4
+ import type { Nullable } from "../../utils/types.js";
5
+ import { useOutsideClick } from "../helpers/useOutsideClick.js";
6
+ import { useTypeAhead } from "../helpers/useTypeAhead.js";
7
7
  import {
8
8
  createListbox,
9
9
  type CreateListboxOptions,
10
10
  type ListboxValue,
11
- } from "../listbox/createListbox";
11
+ } from "../listbox/createListbox.js";
12
12
 
13
+ /** See https://w3c.github.io/aria/#aria-autocomplete */
13
14
  export type ComboboxAutoComplete = "none" | "list" | "both";
14
15
 
15
16
  export const OPENING_KEYS: PressedKey[] = ["ArrowDown", "ArrowUp", " ", "Enter", "Home", "End"];
@@ -19,11 +20,15 @@ export const CLOSING_KEYS: PressedKey[] = [
19
20
  "Enter",
20
21
  "Tab",
21
22
  ];
22
- const SELECTING_KEYS_SINGLE: PressedKey[] = ["Enter", " "];
23
- const SELECTING_KEYS_MULTIPLE: PressedKey[] = ["Enter"];
24
23
 
25
- const isSelectingKey = (event: KeyboardEvent, isMultiselect?: boolean) => {
26
- const selectingKeys = isMultiselect ? SELECTING_KEYS_MULTIPLE : SELECTING_KEYS_SINGLE;
24
+ const SELECTING_KEYS: PressedKey[] = ["Enter"];
25
+
26
+ /**
27
+ * if the a search input is included, space should not be used to select
28
+ * TODO: idea for the future: move this distinction to the listbox?
29
+ */
30
+ const isSelectingKey = (event: KeyboardEvent, withSpace?: boolean) => {
31
+ const selectingKeys = withSpace ? [...SELECTING_KEYS, " "] : SELECTING_KEYS;
27
32
  return isKeyOfGroup(event, selectingKeys);
28
33
  };
29
34
 
@@ -42,9 +47,9 @@ export type CreateComboboxOptions<
42
47
  */
43
48
  listLabel: MaybeRef<string>;
44
49
  /**
45
- * The current value of the combobox. Is updated when an option from the controlled listbox is selected or by typing into it.
50
+ * Provides additional description for the listbox which displays the available options.
46
51
  */
47
- inputValue: Ref<string | undefined>;
52
+ listDescription?: MaybeRef<Nullable<string>>;
48
53
  /**
49
54
  * Controls the opened/visible state of the associated pop-up. When expanded the activeOption can be controlled via the keyboard.
50
55
  */
@@ -52,11 +57,11 @@ export type CreateComboboxOptions<
52
57
  /**
53
58
  * If expanded, the active option is the currently highlighted option of the controlled listbox.
54
59
  */
55
- activeOption: Ref<TValue | undefined>;
60
+ activeOption: Ref<Nullable<TValue>>;
56
61
  /**
57
62
  * Template ref to the component root (required to close combobox on outside click).
58
63
  */
59
- templateRef: Ref<HTMLElement | undefined>;
64
+ templateRef: Ref<Nullable<HTMLElement>>;
60
65
  /**
61
66
  * Hook when the popover should toggle.
62
67
  *
@@ -105,13 +110,13 @@ export const createComboBox = createBuilder(
105
110
  TAutoComplete extends ComboboxAutoComplete,
106
111
  TMultiple extends boolean = false,
107
112
  >({
108
- inputValue,
109
113
  autocomplete: autocompleteRef,
110
114
  onAutocomplete,
111
115
  onTypeAhead,
112
116
  multiple: multipleRef,
113
117
  label,
114
118
  listLabel,
119
+ listDescription,
115
120
  isExpanded: isExpandedRef,
116
121
  activeOption,
117
122
  onToggle,
@@ -122,7 +127,7 @@ export const createComboBox = createBuilder(
122
127
  onActivatePrevious,
123
128
  templateRef,
124
129
  }: CreateComboboxOptions<TValue, TAutoComplete, TMultiple>) => {
125
- const controlsId = createId("comboBox-control");
130
+ const controlsId = useId();
126
131
 
127
132
  const autocomplete = computed(() => unref(autocompleteRef));
128
133
  const isExpanded = computed(() => unref(isExpandedRef));
@@ -188,7 +193,7 @@ export const createComboBox = createBuilder(
188
193
  }
189
194
  return onActivateFirst?.();
190
195
  }
191
- if (isSelectingKey(event, multiple.value)) {
196
+ if (isSelectingKey(event, autocomplete.value === "none")) {
192
197
  return handleSelect(activeOption.value!);
193
198
  }
194
199
  if (isExpanded.value && isKeyOfGroup(event, CLOSING_KEYS)) {
@@ -205,27 +210,29 @@ export const createComboBox = createBuilder(
205
210
  return handleNavigation(event);
206
211
  };
207
212
 
208
- const autocompleteInput =
209
- autocomplete.value !== "none"
210
- ? {
211
- "aria-autocomplete": autocomplete.value,
212
- type: "text",
213
- }
214
- : null;
213
+ const autocompleteInput = computed(() => {
214
+ if (autocomplete.value === "none") return null;
215
+ return {
216
+ "aria-autocomplete": autocomplete.value,
217
+ type: "text",
218
+ };
219
+ });
215
220
 
216
221
  const {
217
222
  elements: { option, group, listbox },
218
223
  internals: { getOptionId },
219
224
  } = createListbox({
220
225
  label: listLabel,
226
+ description: listDescription,
221
227
  multiple,
222
228
  controlled: true,
223
229
  activeOption,
230
+ isExpanded,
224
231
  onSelect: handleSelect,
225
232
  });
226
233
 
227
234
  useOutsideClick({
228
- element: templateRef,
235
+ inside: templateRef,
229
236
  onOutsideClick() {
230
237
  if (!isExpanded.value) return;
231
238
  onToggle?.(true);
@@ -250,7 +257,6 @@ export const createComboBox = createBuilder(
250
257
  * 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
258
  */
252
259
  input: computed(() => ({
253
- value: inputValue.value,
254
260
  role: "combobox",
255
261
  "aria-expanded": isExpanded.value,
256
262
  "aria-controls": controlsId,
@@ -259,7 +265,7 @@ export const createComboBox = createBuilder(
259
265
  activeOption.value != undefined ? getOptionId(activeOption.value) : undefined,
260
266
  onInput: handleInput,
261
267
  onKeydown: handleKeydown,
262
- ...autocompleteInput,
268
+ ...autocompleteInput.value,
263
269
  })),
264
270
  /**
265
271
  * An optional button to control the visibility of the popup.
@@ -0,0 +1,19 @@
1
+ import { computed, type Ref } from "vue";
2
+ import { useGlobalEventListener } from "./useGlobalListener.js";
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
+ });
@@ -1,7 +1,7 @@
1
1
  import { beforeEach, describe, expect, it, vi } from "vitest";
2
2
  import { ref, type Ref } from "vue";
3
- import { mockVueLifecycle } from "../../utils/vitest";
4
- import { useGlobalEventListener } from "./useGlobalListener";
3
+ import { mockVueLifecycle } from "../../utils/vitest.js";
4
+ import { useGlobalEventListener } from "./useGlobalListener.js";
5
5
 
6
6
  let unmount: () => Promise<void> | undefined;
7
7
 
@@ -3,7 +3,7 @@ import { onBeforeMount, onBeforeUnmount, reactive, watchEffect, type Ref } from
3
3
  type DocumentEventType = keyof DocumentEventMap;
4
4
  type GlobalListener<K extends DocumentEventType = DocumentEventType> = (
5
5
  event: DocumentEventMap[K],
6
- ) => void;
6
+ ) => unknown;
7
7
 
8
8
  export type UseGlobalEventListenerOptions<K extends DocumentEventType> = {
9
9
  type: K;
@@ -0,0 +1,117 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { ref } from "vue";
3
+ import { mockVueLifecycle } from "../../utils/vitest.js";
4
+ import { useOutsideClick } from "./useOutsideClick.js";
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", async () => {
17
+ // ARRANGE
18
+ vi.useFakeTimers();
19
+ const inside = ref(document.createElement("button"));
20
+ document.body.appendChild(inside.value);
21
+ const outside = ref(document.createElement("button"));
22
+ document.body.appendChild(outside.value);
23
+
24
+ const onOutsideClick = vi.fn();
25
+ useOutsideClick({ inside, onOutsideClick });
26
+
27
+ // ACT
28
+ const event = new MouseEvent("mousedown", { bubbles: true });
29
+ outside.value.dispatchEvent(event);
30
+
31
+ // ASSERT
32
+ expect(onOutsideClick).toHaveBeenCalledTimes(1);
33
+ expect(onOutsideClick).toBeCalledWith(event);
34
+
35
+ // ACT
36
+ outside.value.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Tab" }));
37
+ await vi.runAllTimersAsync();
38
+
39
+ // ASSERT
40
+ expect(
41
+ onOutsideClick,
42
+ "should not trigger on Tab press when checkOnTab option is disabled",
43
+ ).toHaveBeenCalledTimes(1);
44
+ });
45
+
46
+ it("should detect outside clicks correctly for multiple inside elements", () => {
47
+ // ARRANGE
48
+ const inside = [document.createElement("button"), document.createElement("button")];
49
+ inside.forEach((e) => document.body.appendChild(e));
50
+ const outside = ref(document.createElement("button"));
51
+ document.body.appendChild(outside.value);
52
+
53
+ const onOutsideClick = vi.fn();
54
+ useOutsideClick({ inside, onOutsideClick });
55
+ // ACT
56
+ const event = new MouseEvent("mousedown", { bubbles: true });
57
+ inside[0].dispatchEvent(event);
58
+ inside[1].dispatchEvent(event);
59
+ // ASSERT
60
+ expect(onOutsideClick).not.toHaveBeenCalled();
61
+
62
+ // ACT
63
+ outside.value.dispatchEvent(event);
64
+ // ASSERT
65
+ expect(onOutsideClick).toHaveBeenCalledTimes(1);
66
+ expect(onOutsideClick).toBeCalledWith(event);
67
+ });
68
+
69
+ it("should ignore outside clicks when disabled", async () => {
70
+ // ARRANGE
71
+ vi.useFakeTimers();
72
+ const inside = ref(document.createElement("button"));
73
+ document.body.appendChild(inside.value);
74
+ const outside = ref(document.createElement("button"));
75
+ document.body.appendChild(outside.value);
76
+
77
+ const disabled = ref(false);
78
+ const onOutsideClick = vi.fn();
79
+ useOutsideClick({ inside, disabled, onOutsideClick });
80
+
81
+ // ACT
82
+ const event = new MouseEvent("mousedown", { bubbles: true });
83
+ outside.value.dispatchEvent(event);
84
+ // ASSERT
85
+ expect(onOutsideClick).toHaveBeenCalledTimes(1);
86
+ expect(onOutsideClick).toBeCalledWith(event);
87
+
88
+ // ACT
89
+ disabled.value = true;
90
+ await vi.runAllTimersAsync();
91
+ const event2 = new MouseEvent("mousedown", { bubbles: true });
92
+ outside.value.dispatchEvent(event2);
93
+ // ASSERT
94
+ expect(onOutsideClick).toHaveBeenCalledTimes(1);
95
+ });
96
+
97
+ it("should detect outside tab via keyboard", async () => {
98
+ // ARRANGE
99
+ vi.useFakeTimers();
100
+ const inside = ref(document.createElement("button"));
101
+ document.body.appendChild(inside.value);
102
+ const outside = ref(document.createElement("button"));
103
+ document.body.appendChild(outside.value);
104
+
105
+ const onOutsideClick = vi.fn();
106
+ useOutsideClick({ inside, onOutsideClick, checkOnTab: true });
107
+
108
+ // ACT
109
+ const event = new KeyboardEvent("keydown", { bubbles: true, key: "Tab" });
110
+ outside.value.dispatchEvent(event);
111
+ await vi.runAllTimersAsync();
112
+
113
+ // ASSERT
114
+ expect(onOutsideClick).toHaveBeenCalledTimes(1);
115
+ expect(onOutsideClick).toBeCalledWith(event);
116
+ });
117
+ });
@@ -1,15 +1,23 @@
1
- import { type Ref } from "vue";
2
- import { useGlobalEventListener } from "./useGlobalListener";
1
+ import type { Arrayable } from "vitest"; // For an unknown reason removing this import will break the build of "demo-app" and "playground"
2
+ import { toValue, type MaybeRefOrGetter, type Ref } from "vue";
3
+ import type { Nullable } from "../../utils/types.js";
4
+ import { useGlobalEventListener } from "./useGlobalListener.js";
3
5
 
4
- export type UseOutsideClickOptions = {
6
+ export type UseOutsideClickOptions<TCheckOnTab extends boolean | undefined = undefined> = {
5
7
  /**
6
8
  * HTML element of the component where clicks should be ignored
7
9
  */
8
- element: Ref<HTMLElement | undefined>;
10
+ inside: MaybeRefOrGetter<Arrayable<Nullable<HTMLElement>>>;
9
11
  /**
10
12
  * Callback when an outside click occurred.
11
13
  */
12
- onOutsideClick: () => void;
14
+ onOutsideClick: (
15
+ event: TCheckOnTab extends true ? MouseEvent | KeyboardEvent : MouseEvent,
16
+ ) => void;
17
+ /**
18
+ * Whether the outside focus should also be checked when pressing the Tab key.
19
+ */
20
+ checkOnTab?: TCheckOnTab;
13
21
  /**
14
22
  * If `true`, event listeners will be removed and no outside clicks will be captured.
15
23
  */
@@ -20,15 +28,42 @@ export type UseOutsideClickOptions = {
20
28
  * Composable for listening to click events that occur outside of a component.
21
29
  * Useful to e.g. close flyouts or tooltips.
22
30
  */
23
- export const useOutsideClick = ({ element, onOutsideClick, disabled }: UseOutsideClickOptions) => {
31
+ export const useOutsideClick = <TCheckOnTab extends boolean | undefined>({
32
+ inside,
33
+ onOutsideClick,
34
+ disabled,
35
+ checkOnTab,
36
+ }: UseOutsideClickOptions<TCheckOnTab>) => {
37
+ const isOutsideClick = (target: EventTarget | null) => {
38
+ if (!target) return true;
39
+ const raw = toValue(inside);
40
+ const elements = Array.isArray(raw) ? raw : [raw];
41
+ return !elements.some((element) => element?.contains(target as Node | null));
42
+ };
43
+
24
44
  /**
25
45
  * Document click handle that closes then tooltip when clicked outside.
26
46
  * Should only be called when trigger is "click".
27
47
  */
28
- const listener = ({ target }: MouseEvent) => {
29
- const isOutsideClick = !element.value?.contains(target as HTMLElement);
30
- if (isOutsideClick) onOutsideClick();
48
+ const clickListener = (event: MouseEvent) => {
49
+ if (isOutsideClick(event.target)) onOutsideClick(event);
31
50
  };
32
51
 
33
- useGlobalEventListener({ type: "click", listener, disabled });
52
+ useGlobalEventListener({ type: "mousedown", listener: clickListener, disabled });
53
+
54
+ if (checkOnTab) {
55
+ const keydownListener = async (event: KeyboardEvent) => {
56
+ if (event.key !== "Tab") return;
57
+
58
+ // using setTimeout here to guarantee that side effects that might change the document.activeElement have run before checking
59
+ // the activeElement
60
+ await new Promise((resolve) => setTimeout(resolve));
61
+
62
+ if (isOutsideClick(document.activeElement)) {
63
+ onOutsideClick(event as Parameters<typeof onOutsideClick>[0]);
64
+ }
65
+ };
66
+
67
+ useGlobalEventListener({ type: "keydown", listener: keydownListener, disabled });
68
+ }
34
69
  };
@@ -1,5 +1,5 @@
1
1
  import { beforeAll, expect, test, vi } from "vitest";
2
- import { useTypeAhead } from "./useTypeAhead";
2
+ import { useTypeAhead } from "./useTypeAhead.js";
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.js";
2
+ import { debounce } from "../../utils/timer.js";
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.testing";
3
+ import { listboxTesting } from "./createListbox.testing.js";
4
4
 
5
5
  test("listbox", async ({ mount, page }) => {
6
6
  await mount(<TestListbox />);
@@ -1,6 +1,6 @@
1
1
  <script lang="ts" setup>
2
2
  import { ref } from "vue";
3
- import { createListbox } from "./createListbox";
3
+ import { createListbox } from "./createListbox.js";
4
4
 
5
5
  type Options = (typeof options)[number];
6
6
 
@@ -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
  },