@sit-onyx/headless 1.0.0-alpha.9 → 1.0.0-beta.1

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/README.md CHANGED
@@ -1,7 +1,7 @@
1
- <div align="center">
1
+ <div align="center" style="text-align: center">
2
2
  <picture>
3
- <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/SchwarzIT/onyx/main/.github/onyx-logo-light.svg">
4
- <source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/SchwarzIT/onyx/main/.github/onyx-logo-dark.svg">
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
5
  <img alt="onyx logo" src="https://raw.githubusercontent.com/SchwarzIT/onyx/main/.github/onyx-logo-dark.svg" width="160px">
6
6
  </picture>
7
7
  </div>
@@ -12,8 +12,6 @@
12
12
 
13
13
  A composable headless library for Vue created by [Schwarz IT](https://it.schwarz).
14
14
 
15
- > **Work in progress**: This library is currently in early / active development.
16
-
17
15
  <br />
18
16
 
19
17
  ## Documentation
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-alpha.9",
4
+ "version": "1.0.0-beta.1",
5
5
  "type": "module",
6
6
  "author": "Schwarz IT KG",
7
7
  "license": "Apache-2.0",
@@ -1,7 +1,7 @@
1
1
  import { test } from "@playwright/experimental-ct-vue";
2
- import TestCombobox from "./TestCombobox.vue";
3
- import { comboboxTesting, comboboxSelectOnlyTesting } from "./createComboBox.ct";
2
+ import { comboboxSelectOnlyTesting, comboboxTesting } from "./createComboBox.testing";
4
3
  import SelectOnlyCombobox from "./SelectOnlyCombobox.vue";
4
+ import TestCombobox from "./TestCombobox.vue";
5
5
 
6
6
  test("combobox", async ({ mount, page }) => {
7
7
  await mount(<TestCombobox />);
@@ -36,7 +36,7 @@ const expectToClose = async (
36
36
  * Test an implementation of the combobox based on https://w3c.github.io/aria/#combobox
37
37
  */
38
38
  export const comboboxTesting = async (
39
- page: Page,
39
+ _page: Page,
40
40
  listbox: Locator,
41
41
  combobox: Locator,
42
42
  button: Locator,
@@ -1,19 +1,24 @@
1
- import { computed, ref, unref, 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
4
  import { isPrintableCharacter, wasKeyPressed, type PressedKey } from "../../utils/keyboard";
5
+ import { useOutsideClick } from "../helpers/useOutsideClick";
6
+ import { useTypeAhead } from "../helpers/useTypeAhead";
5
7
  import {
6
8
  createListbox,
7
9
  type CreateListboxOptions,
8
10
  type ListboxValue,
9
11
  } from "../listbox/createListbox";
10
- import { useOutsideClick } from "../outsideClick";
11
- import { useTypeAhead } from "../typeAhead";
12
12
 
13
13
  export type ComboboxAutoComplete = "none" | "list" | "both";
14
14
 
15
- const OPENING_KEYS: PressedKey[] = ["ArrowDown", "ArrowUp", " ", "Enter", "Home", "End"];
16
- const CLOSING_KEYS: PressedKey[] = ["Escape", { key: "ArrowUp", altKey: true }, "Enter", "Tab"];
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
+ ];
17
22
  const SELECTING_KEYS_SINGLE: PressedKey[] = ["Enter", " "];
18
23
  const SELECTING_KEYS_MULTIPLE: PressedKey[] = ["Enter"];
19
24
 
@@ -117,7 +122,6 @@ export const createComboBox = createBuilder(
117
122
  onActivatePrevious,
118
123
  templateRef,
119
124
  }: CreateComboboxOptions<TValue, TAutoComplete, TMultiple>) => {
120
- const inputValid = ref(true);
121
125
  const controlsId = createId("comboBox-control");
122
126
 
123
127
  const autocomplete = computed(() => unref(autocompleteRef));
@@ -126,10 +130,6 @@ export const createComboBox = createBuilder(
126
130
 
127
131
  const handleInput = (event: Event) => {
128
132
  const inputElement = event.target as HTMLInputElement;
129
- inputValid.value = inputElement.validity.valid;
130
- if (!unref(isExpanded)) {
131
- onToggle?.();
132
- }
133
133
 
134
134
  if (autocomplete.value !== "none") {
135
135
  onAutocomplete?.(inputElement.value);
@@ -173,6 +173,11 @@ export const createComboBox = createBuilder(
173
173
  };
174
174
 
175
175
  const handleKeydown = (event: KeyboardEvent) => {
176
+ if (event.key === "Enter") {
177
+ // prevent submitting on pressing enter when the combo box is used inside a <form>
178
+ event.preventDefault();
179
+ }
180
+
176
181
  if (!isExpanded.value && isKeyOfGroup(event, OPENING_KEYS)) {
177
182
  onToggle?.();
178
183
  if (event.key === " ") {
@@ -193,6 +198,10 @@ export const createComboBox = createBuilder(
193
198
  !isExpanded.value && onToggle?.();
194
199
  return typeAhead(event);
195
200
  }
201
+ if (autocomplete.value !== "none" && isPrintableCharacter(event.key)) {
202
+ !isExpanded.value && onToggle?.();
203
+ return;
204
+ }
196
205
  return handleNavigation(event);
197
206
  };
198
207
 
@@ -216,7 +225,7 @@ export const createComboBox = createBuilder(
216
225
  });
217
226
 
218
227
  useOutsideClick({
219
- queryComponent: () => templateRef.value,
228
+ element: templateRef,
220
229
  onOutsideClick() {
221
230
  if (!isExpanded.value) return;
222
231
  onToggle?.(true);
@@ -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,34 @@
1
+ import { type Ref } from "vue";
2
+ import { useGlobalEventListener } from "./useGlobalListener";
3
+
4
+ export type UseOutsideClickOptions = {
5
+ /**
6
+ * HTML element of the component where clicks should be ignored
7
+ */
8
+ element: Ref<HTMLElement | undefined>;
9
+ /**
10
+ * Callback when an outside click occurred.
11
+ */
12
+ onOutsideClick: () => void;
13
+ /**
14
+ * If `true`, event listeners will be removed and no outside clicks will be captured.
15
+ */
16
+ disabled?: Ref<boolean>;
17
+ };
18
+
19
+ /**
20
+ * Composable for listening to click events that occur outside of a component.
21
+ * Useful to e.g. close flyouts or tooltips.
22
+ */
23
+ export const useOutsideClick = ({ element, onOutsideClick, disabled }: UseOutsideClickOptions) => {
24
+ /**
25
+ * Document click handle that closes then tooltip when clicked outside.
26
+ * Should only be called when trigger is "click".
27
+ */
28
+ const listener = ({ target }: MouseEvent) => {
29
+ const isOutsideClick = !element.value?.contains(target as HTMLElement);
30
+ if (isOutsideClick) onOutsideClick();
31
+ };
32
+
33
+ useGlobalEventListener({ type: "click", listener, disabled });
34
+ };
@@ -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 />);
@@ -1,7 +1,7 @@
1
1
  import { computed, ref, unref, watchEffect, type MaybeRef, type Ref } from "vue";
2
2
  import { createId } from "../..";
3
3
  import { createBuilder, type HeadlessElementAttributes } from "../../utils/builder";
4
- import { useTypeAhead } from "../typeAhead";
4
+ import { useTypeAhead } from "../helpers/useTypeAhead";
5
5
 
6
6
  export type ListboxValue = string | number | boolean;
7
7
 
@@ -185,15 +185,24 @@ export const createListbox = createBuilder(
185
185
  });
186
186
  }),
187
187
  option: computed(() => {
188
- return (data: { label: string; value: TValue; disabled?: boolean; selected?: boolean }) =>
189
- ({
188
+ return (data: {
189
+ label: string;
190
+ value: TValue;
191
+ disabled?: boolean;
192
+ selected?: boolean;
193
+ }) => {
194
+ const selected = data.selected ?? false;
195
+
196
+ return {
190
197
  id: getOptionId(data.value),
191
198
  role: "option",
192
199
  "aria-label": data.label,
193
200
  "aria-disabled": data.disabled,
194
- [isMultiselect.value ? "aria-checked" : "aria-selected"]: data.selected || false,
201
+ "aria-checked": isMultiselect.value ? selected : undefined,
202
+ "aria-selected": !isMultiselect.value ? selected : undefined,
195
203
  onClick: () => !data.disabled && options.onSelect?.(data.value),
196
- }) as const;
204
+ } as const;
205
+ };
197
206
  }),
198
207
  },
199
208
  state: {
@@ -0,0 +1,14 @@
1
+ import { test } from "@playwright/experimental-ct-vue";
2
+ import { menuButtonTesting } from "./createMenuButton.testing";
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,27 @@
1
+ <script lang="ts" setup>
2
+ import { ref } from "vue";
3
+ import { createMenuButton } from "./createMenuButton";
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
+ </script>
17
+
18
+ <template>
19
+ <button v-bind="button">Toggle nav menu</button>
20
+ <div v-bind="flyout">
21
+ <ul v-show="isExpanded" v-bind="menu">
22
+ <li v-for="item in items" v-bind="listItem" :key="item.value" title="item">
23
+ <a v-bind="menuItem({ active: activeItem === item.value })" href="#">{{ item.label }}</a>
24
+ </li>
25
+ </ul>
26
+ </div>
27
+ </template>
@@ -0,0 +1,109 @@
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 ({
28
+ page,
29
+ button,
30
+ menu,
31
+ menuItems,
32
+ }: MenuButtonTestingOptions) => {
33
+ const menuId = await menu.getAttribute("id");
34
+ expect(menuId).toBeDefined();
35
+ await expect(
36
+ button,
37
+ "navigation menu should have set the list ID to the aria-controls",
38
+ ).toHaveAttribute("aria-controls", menuId!);
39
+
40
+ await expect(
41
+ button,
42
+ 'navigation menu should have an "aria-haspopup" attribute set to true',
43
+ ).toHaveAttribute("aria-haspopup", "true");
44
+
45
+ await expect(button).toBeVisible();
46
+
47
+ // ensure correct navigation menu aria attributes
48
+ await expect(
49
+ button,
50
+ 'flyout menu must have an "aria-expanded" attribute set to false',
51
+ ).toHaveAttribute("aria-expanded", "false");
52
+
53
+ button.hover();
54
+
55
+ await expect(
56
+ button,
57
+ 'flyout menu must have an "aria-expanded" attribute set to true',
58
+ ).toHaveAttribute("aria-expanded", "true");
59
+
60
+ const firstItem = menuItems[0].getByRole("menuitem");
61
+ const secondItem = menuItems[1].getByRole("menuitem");
62
+ const lastItem = menuItems[menuItems.length - 1].getByRole("menuitem");
63
+
64
+ await page.keyboard.press("Tab");
65
+ await expect(button, "Button should be focused when pressing tab key").toBeFocused();
66
+
67
+ await button.press("ArrowDown");
68
+ await expect(
69
+ firstItem,
70
+ "First item should be focused when pressing arrow down key",
71
+ ).toBeFocused();
72
+
73
+ await menu.press("ArrowDown");
74
+ await expect(
75
+ secondItem,
76
+ "Second item should be focused when pressing arrow down key",
77
+ ).toBeFocused();
78
+
79
+ await menu.press("ArrowUp");
80
+ await expect(firstItem, "First item should be focused when pressing arrow up key").toBeFocused();
81
+
82
+ await menu.press("ArrowRight");
83
+ await expect(
84
+ secondItem,
85
+ "Second item should be focused when pressing arrow right key",
86
+ ).toBeFocused();
87
+
88
+ await menu.press("ArrowLeft");
89
+ await expect(
90
+ firstItem,
91
+ "First item should be focused when pressing arrow left key",
92
+ ).toBeFocused();
93
+
94
+ await page.keyboard.press("Tab");
95
+ await expect(button, "Button should be focused when pressing tab key").not.toBeFocused();
96
+
97
+ await page.keyboard.press("Tab");
98
+
99
+ await menu.press("Home");
100
+ await expect(firstItem, "First item should be focused when pressing home key").toBeFocused();
101
+
102
+ await page.keyboard.press("Tab");
103
+ await expect(button, "Button should be focused when pressing tab key").not.toBeFocused();
104
+
105
+ await page.keyboard.press("Tab");
106
+
107
+ await menu.press("End");
108
+ await expect(lastItem, "Last item should be focused when pressing end key").toBeFocused();
109
+ };
@@ -0,0 +1,133 @@
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
+ /**
7
+ * Based on https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/examples/disclosure-navigation/
8
+ */
9
+ export const createMenuButton = createBuilder(() => {
10
+ const menuId = createId("menu");
11
+ const buttonId = createId("menu-button");
12
+ const isExpanded = ref(false);
13
+
14
+ /**
15
+ * Debounced expanded state that will only be toggled after a given timeout.
16
+ */
17
+ const updateDebouncedExpanded = debounce(
18
+ (expanded: boolean) => (isExpanded.value = expanded),
19
+ 200,
20
+ );
21
+
22
+ const hoverEvents = computed(() => {
23
+ return {
24
+ onMouseover: () => updateDebouncedExpanded(true),
25
+ onMouseout: () => updateDebouncedExpanded(false),
26
+ onFocusin: () => (isExpanded.value = true),
27
+ onFocusout: () => (isExpanded.value = false),
28
+ };
29
+ });
30
+
31
+ const focusRelativeItem = (next: "next" | "prev" | "first" | "last") => {
32
+ const currentMenuItem = document.activeElement as HTMLElement;
33
+
34
+ // Either the current focus is on a "menuitem", then we can just get the parent menu.
35
+ // Or the current focus is on the button, then we can get the connected menu using the menuId
36
+ const currentMenu =
37
+ currentMenuItem?.closest('[role="menu"]') || document.getElementById(menuId);
38
+ if (!currentMenu) return;
39
+
40
+ const menuItems = [...currentMenu.querySelectorAll<HTMLElement>('[role="menuitem"]')];
41
+ let nextIndex = 0;
42
+
43
+ if (currentMenuItem) {
44
+ const currentIndex = menuItems.indexOf(currentMenuItem);
45
+ switch (next) {
46
+ case "next":
47
+ nextIndex = currentIndex + 1;
48
+ break;
49
+ case "prev":
50
+ nextIndex = currentIndex - 1;
51
+ break;
52
+ case "first":
53
+ nextIndex = 0;
54
+ break;
55
+ case "last":
56
+ nextIndex = menuItems.length - 1;
57
+ break;
58
+ }
59
+ }
60
+
61
+ const nextMenuItem = menuItems[nextIndex];
62
+ nextMenuItem?.focus();
63
+ };
64
+
65
+ const handleKeydown = (event: KeyboardEvent) => {
66
+ switch (event.key) {
67
+ case "ArrowDown":
68
+ case "ArrowRight":
69
+ event.preventDefault();
70
+ focusRelativeItem("next");
71
+ break;
72
+ case "ArrowUp":
73
+ case "ArrowLeft":
74
+ event.preventDefault();
75
+ focusRelativeItem("prev");
76
+ break;
77
+ case "Home":
78
+ event.preventDefault();
79
+ focusRelativeItem("first");
80
+ break;
81
+ case "End":
82
+ event.preventDefault();
83
+ focusRelativeItem("last");
84
+ break;
85
+ case " ":
86
+ event.preventDefault();
87
+ (event.target as HTMLElement).click();
88
+ break;
89
+ }
90
+ };
91
+
92
+ return {
93
+ state: { isExpanded },
94
+ elements: {
95
+ button: computed(
96
+ () =>
97
+ ({
98
+ "aria-controls": menuId,
99
+ "aria-expanded": isExpanded.value,
100
+ "aria-haspopup": true,
101
+ id: buttonId,
102
+ ...hoverEvents.value,
103
+ onKeydown: handleKeydown,
104
+ }) as const,
105
+ ),
106
+ flyout: {
107
+ ...hoverEvents.value,
108
+ },
109
+ menu: {
110
+ id: menuId,
111
+ role: "menu",
112
+ "aria-labelledby": buttonId,
113
+ onKeydown: handleKeydown,
114
+ },
115
+ ...createMenuItem().elements,
116
+ },
117
+ };
118
+ });
119
+
120
+ export const createMenuItem = createBuilder(() => {
121
+ return {
122
+ elements: {
123
+ listItem: {
124
+ role: "none",
125
+ },
126
+ menuItem: (data: { active?: boolean; disabled?: boolean }) => ({
127
+ "aria-current": data.active ? "page" : undefined,
128
+ "aria-disabled": data.disabled,
129
+ role: "menuitem",
130
+ }),
131
+ },
132
+ };
133
+ });
@@ -1,7 +1,7 @@
1
1
  import { computed, onBeforeMount, onBeforeUnmount, ref, unref, type MaybeRef } from "vue";
2
2
  import { createId } from "../..";
3
3
  import { createBuilder } from "../../utils/builder";
4
- import { useOutsideClick } from "../outsideClick";
4
+ import { useOutsideClick } from "../helpers/useOutsideClick";
5
5
 
6
6
  export type CreateTooltipOptions = {
7
7
  open: MaybeRef<TooltipOpen>;
@@ -22,6 +22,7 @@ export const TOOLTIP_TRIGGERS = ["hover", "click"] as const;
22
22
  export type TooltipTrigger = (typeof TOOLTIP_TRIGGERS)[number];
23
23
 
24
24
  export const createTooltip = createBuilder((options: CreateTooltipOptions) => {
25
+ const rootRef = ref<HTMLElement>();
25
26
  const tooltipId = createId("tooltip");
26
27
  const _isVisible = ref(false);
27
28
  let timeout: ReturnType<typeof setTimeout> | undefined;
@@ -87,7 +88,7 @@ export const createTooltip = createBuilder((options: CreateTooltipOptions) => {
87
88
 
88
89
  // close tooltip on outside click
89
90
  useOutsideClick({
90
- queryComponent: () => document.getElementById(tooltipId)?.parentElement,
91
+ element: rootRef,
91
92
  onOutsideClick: () => (_isVisible.value = false),
92
93
  disabled: computed(() => openType.value !== "click"),
93
94
  });
@@ -106,6 +107,9 @@ export const createTooltip = createBuilder((options: CreateTooltipOptions) => {
106
107
 
107
108
  return {
108
109
  elements: {
110
+ root: {
111
+ ref: rootRef,
112
+ },
109
113
  trigger: computed(() => ({
110
114
  "aria-describedby": tooltipId,
111
115
  onClick: openType.value === "click" ? handleClick : undefined,
package/src/index.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  export * from "./composables/comboBox/createComboBox";
2
2
  export * from "./composables/listbox/createListbox";
3
+ export * from "./composables/menuButton/createMenuButton";
3
4
  export * from "./composables/tooltip/createTooltip";
4
5
  export { createId } from "./utils/id";
5
6
  export { isPrintableCharacter, wasKeyPressed } from "./utils/keyboard";
7
+ export { debounce } from "./utils/timer";
package/src/playwright.ts CHANGED
@@ -1,2 +1,3 @@
1
- export * from "./composables/comboBox/createComboBox.ct";
2
- export * from "./composables/listbox/createListbox.ct";
1
+ export * from "./composables/comboBox/createComboBox.testing";
2
+ export * from "./composables/listbox/createListbox.testing";
3
+ export * from "./composables/menuButton/createMenuButton.testing";
@@ -1,12 +1,14 @@
1
- import type { ComputedRef, HtmlHTMLAttributes, Ref } from "vue";
1
+ import type { ComputedRef, HtmlHTMLAttributes, Ref, VNodeRef } from "vue";
2
2
  import type { IfDefined } from "./types";
3
3
 
4
+ export type ElementAttributes = HtmlHTMLAttributes & { ref?: VNodeRef };
5
+
4
6
  export type IteratedHeadlessElementFunc<T extends Record<string, unknown>> = (
5
7
  opts: T,
6
- ) => HtmlHTMLAttributes;
8
+ ) => ElementAttributes;
7
9
 
8
10
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
9
- export type HeadlessElementAttributes = HtmlHTMLAttributes | IteratedHeadlessElementFunc<any>;
11
+ export type HeadlessElementAttributes = ElementAttributes | IteratedHeadlessElementFunc<any>;
10
12
 
11
13
  export type HeadlessElements = Record<
12
14
  string,
@@ -28,10 +30,10 @@ export type HeadlessComposable<
28
30
  * We use this identity function to ensure the correct typings of the headless composables
29
31
  */
30
32
  export const createBuilder = <
31
- P,
32
- Elements extends HeadlessElements,
33
+ Args extends unknown[] = unknown[],
34
+ Elements extends HeadlessElements = HeadlessElements,
33
35
  State extends HeadlessState | undefined = undefined,
34
36
  Internals extends object | undefined = undefined,
35
37
  >(
36
- builder: (props: P) => HeadlessComposable<Elements, State, Internals>,
38
+ builder: (...args: Args) => HeadlessComposable<Elements, State, Internals>,
37
39
  ) => builder;
@@ -0,0 +1,14 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { MathUtils } from "./math";
3
+
4
+ describe("MathUtils.clamp", () => {
5
+ test.each([
6
+ { number: 1, min: 1, max: 2, result: 1 },
7
+ { number: 1, min: 2, max: 2, result: 2 },
8
+ { number: 1, min: 0, max: 0, result: 0 },
9
+ { number: 1, min: 1, max: 1, result: 1 },
10
+ ])(
11
+ "should return $result for key number:$number min:$min max:$max",
12
+ ({ number, min, max, result }) => expect(MathUtils.clamp(number, min, max)).toBe(result),
13
+ );
14
+ });
@@ -0,0 +1,6 @@
1
+ export const MathUtils = {
2
+ /**
3
+ * Ensures that a given `number` is or is between a given `min` and `max`.
4
+ */
5
+ clamp: (number: number, min: number, max: number) => Math.max(Math.min(number, max), min),
6
+ };
@@ -0,0 +1,36 @@
1
+ import { vi, type Awaitable } from "vitest";
2
+
3
+ type Callback = () => Awaitable<void>;
4
+
5
+ /**
6
+ * Mocks the following vue lifecycle functions:
7
+ * - onBeforeMount
8
+ * - onMounted
9
+ * - onBeforeUnmount
10
+ * - onUnmounted
11
+ *
12
+ * `onBeforeMount` and `onMounted` callbacks are executed immediately.
13
+ * `onBeforeUnmount` and `onUnmounted` are executed when the returned callback is run.
14
+ * @returns a callback to trigger the run of `onBeforeUnmount` and `onUnmounted`
15
+ */
16
+ export const mockVueLifecycle = () => {
17
+ const { callbacks } = vi.hoisted(() => ({
18
+ callbacks: {
19
+ onBeforeUnmountedCb: null as Callback | null,
20
+ onUnmountedCb: null as Callback | null,
21
+ },
22
+ }));
23
+
24
+ vi.mock("vue", async (original) => ({
25
+ ...((await original()) as typeof import("vue")),
26
+ onBeforeMount: vi.fn((cb: Callback) => cb()),
27
+ onMounted: vi.fn((cb: Callback) => cb()),
28
+ onBeforeUnmount: vi.fn((cb: Callback) => (callbacks.onBeforeUnmountedCb = cb)),
29
+ onUnmounted: vi.fn((cb: Callback) => (callbacks.onUnmountedCb = cb)),
30
+ }));
31
+
32
+ return async () => {
33
+ await callbacks.onBeforeUnmountedCb?.();
34
+ await callbacks.onUnmountedCb?.();
35
+ };
36
+ };
@@ -1,52 +0,0 @@
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
- };