@sit-onyx/headless 1.0.0-beta.18 → 1.0.0-beta.19

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": "1.0.0-beta.18",
4
+ "version": "1.0.0-beta.19",
5
5
  "type": "module",
6
6
  "author": "Schwarz IT KG",
7
7
  "license": "Apache-2.0",
@@ -27,8 +27,8 @@
27
27
  "vue": ">= 3.5.0"
28
28
  },
29
29
  "devDependencies": {
30
- "@vue/compiler-dom": "3.5.13",
31
- "vue": "3.5.13",
30
+ "@vue/compiler-dom": "3.5.16",
31
+ "vue": "3.5.16",
32
32
  "@sit-onyx/shared": "^1.0.0-beta.3"
33
33
  },
34
34
  "scripts": {
@@ -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;
@@ -13,8 +13,9 @@ describe("useOutsideClick", () => {
13
13
  expect(useOutsideClick).toBeDefined();
14
14
  });
15
15
 
16
- it("should detect outside clicks", () => {
16
+ it("should detect outside clicks", async () => {
17
17
  // ARRANGE
18
+ vi.useFakeTimers();
18
19
  const inside = ref(document.createElement("button"));
19
20
  document.body.appendChild(inside.value);
20
21
  const outside = ref(document.createElement("button"));
@@ -22,12 +23,24 @@ describe("useOutsideClick", () => {
22
23
 
23
24
  const onOutsideClick = vi.fn();
24
25
  useOutsideClick({ inside, onOutsideClick });
26
+
25
27
  // ACT
26
28
  const event = new MouseEvent("click", { bubbles: true });
27
29
  outside.value.dispatchEvent(event);
30
+
28
31
  // ASSERT
29
32
  expect(onOutsideClick).toHaveBeenCalledTimes(1);
30
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);
31
44
  });
32
45
 
33
46
  it("should detect outside clicks correctly for multiple inside elements", () => {
@@ -80,4 +93,25 @@ describe("useOutsideClick", () => {
80
93
  // ASSERT
81
94
  expect(onOutsideClick).toHaveBeenCalledTimes(1);
82
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
+ });
83
117
  });
@@ -3,7 +3,7 @@ import { toValue, type MaybeRefOrGetter, type Ref } from "vue";
3
3
  import type { Nullable } from "../../utils/types";
4
4
  import { useGlobalEventListener } from "./useGlobalListener";
5
5
 
6
- export type UseOutsideClickOptions = {
6
+ export type UseOutsideClickOptions<TCheckOnTab extends boolean | undefined = undefined> = {
7
7
  /**
8
8
  * HTML element of the component where clicks should be ignored
9
9
  */
@@ -11,7 +11,13 @@ export type UseOutsideClickOptions = {
11
11
  /**
12
12
  * Callback when an outside click occurred.
13
13
  */
14
- onOutsideClick: (event: MouseEvent) => 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;
15
21
  /**
16
22
  * If `true`, event listeners will be removed and no outside clicks will be captured.
17
23
  */
@@ -22,19 +28,42 @@ export type UseOutsideClickOptions = {
22
28
  * Composable for listening to click events that occur outside of a component.
23
29
  * Useful to e.g. close flyouts or tooltips.
24
30
  */
25
- export const useOutsideClick = ({ inside, 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
+
26
44
  /**
27
45
  * Document click handle that closes then tooltip when clicked outside.
28
46
  * Should only be called when trigger is "click".
29
47
  */
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);
48
+ const clickListener = (event: MouseEvent) => {
49
+ if (isOutsideClick(event.target)) onOutsideClick(event);
37
50
  };
38
51
 
39
- useGlobalEventListener({ type: "click", listener, disabled });
52
+ useGlobalEventListener({ type: "click", 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
+ }
40
69
  };
@@ -1,5 +1,5 @@
1
1
  <script lang="ts" setup>
2
- import { ref, type Ref } from "vue";
2
+ import { ref } from "vue";
3
3
  import { createMenuButton } from "./createMenuButton";
4
4
 
5
5
  const items = Array.from({ length: 10 }, (_, index) => {
@@ -10,7 +10,7 @@ const items = Array.from({ length: 10 }, (_, index) => {
10
10
  const activeItem = ref<string>();
11
11
  const isExpanded = ref(false);
12
12
  const onToggle = () => (isExpanded.value = !isExpanded.value);
13
- const trigger: Readonly<Ref<"click" | "hover">> = ref("hover");
13
+ const trigger = ref<"click" | "hover">("hover");
14
14
 
15
15
  const {
16
16
  elements: { root, button, menu, menuItem, listItem },
@@ -73,18 +73,6 @@ export const menuButtonTesting = async ({
73
73
  await menu.press("ArrowUp");
74
74
  await expect(firstItem, "First item should be focused when pressing arrow up key").toBeFocused();
75
75
 
76
- await menu.press("ArrowRight");
77
- await expect(
78
- secondItem,
79
- "Second item should be focused when pressing arrow right key",
80
- ).toBeFocused();
81
-
82
- await menu.press("ArrowLeft");
83
- await expect(
84
- firstItem,
85
- "First item should be focused when pressing arrow left key",
86
- ).toBeFocused();
87
-
88
76
  await page.keyboard.press("Tab");
89
77
  await expect(button, "Button should be focused when pressing tab key").not.toBeFocused();
90
78
 
@@ -2,6 +2,7 @@ import { computed, useId, watch, type Ref } from "vue";
2
2
  import { createBuilder, createElRef } from "../../utils/builder";
3
3
  import { debounce } from "../../utils/timer";
4
4
  import { useGlobalEventListener } from "../helpers/useGlobalListener";
5
+ import { useOutsideClick } from "../helpers/useOutsideClick";
5
6
 
6
7
  type CreateMenuButtonOptions = {
7
8
  isExpanded: Readonly<Ref<boolean>>;
@@ -16,6 +17,7 @@ type CreateMenuButtonOptions = {
16
17
  export const createMenuButton = createBuilder((options: CreateMenuButtonOptions) => {
17
18
  const rootId = useId();
18
19
  const menuId = useId();
20
+ const rootRef = createElRef<HTMLElement>();
19
21
  const menuRef = createElRef<HTMLElement>();
20
22
  const buttonId = useId();
21
23
 
@@ -52,7 +54,9 @@ export const createMenuButton = createBuilder((options: CreateMenuButtonOptions)
52
54
  const currentMenu = currentMenuItem?.closest('[role="menu"]') || menuRef.value;
53
55
  if (!currentMenu) return;
54
56
 
55
- const menuItems = Array.from(currentMenu.querySelectorAll<HTMLElement>('[role="menuitem"]'));
57
+ const menuItems = Array.from(currentMenu.querySelectorAll<HTMLElement>('[role="menuitem"]'))
58
+ // filter out nested children
59
+ .filter((item) => item.closest('[role="menu"]') === currentMenu);
56
60
  let nextIndex = 0;
57
61
 
58
62
  if (currentMenuItem) {
@@ -80,12 +84,10 @@ export const createMenuButton = createBuilder((options: CreateMenuButtonOptions)
80
84
  const handleKeydown = (event: KeyboardEvent) => {
81
85
  switch (event.key) {
82
86
  case "ArrowDown":
83
- case "ArrowRight":
84
87
  event.preventDefault();
85
88
  focusRelativeItem("next");
86
89
  break;
87
90
  case "ArrowUp":
88
- case "ArrowLeft":
89
91
  event.preventDefault();
90
92
  focusRelativeItem("prev");
91
93
  break;
@@ -98,6 +100,7 @@ export const createMenuButton = createBuilder((options: CreateMenuButtonOptions)
98
100
  focusRelativeItem("last");
99
101
  break;
100
102
  case " ":
103
+ case "Enter":
101
104
  if (event.target instanceof HTMLInputElement) break;
102
105
  event.preventDefault();
103
106
  (event.target as HTMLElement).click();
@@ -109,32 +112,29 @@ export const createMenuButton = createBuilder((options: CreateMenuButtonOptions)
109
112
  }
110
113
  };
111
114
 
112
- const triggerEvents = () => {
113
- if (options.trigger.value === "hover") {
114
- return {
115
- onMouseenter: () => setExpanded(true),
116
- onMouseleave: () => setExpanded(false, true),
117
- };
118
- }
119
- };
115
+ const triggerEvents = computed(() => {
116
+ if (options.trigger.value !== "hover") return;
117
+ return {
118
+ onMouseenter: () => setExpanded(true),
119
+ onMouseleave: () => setExpanded(false, true),
120
+ };
121
+ });
122
+
123
+ useOutsideClick({
124
+ inside: rootRef,
125
+ onOutsideClick: () => setExpanded(false),
126
+ disabled: computed(() => !options.isExpanded.value),
127
+ checkOnTab: true,
128
+ });
120
129
 
121
130
  return {
122
131
  elements: {
123
- root: {
132
+ root: computed(() => ({
124
133
  id: rootId,
125
134
  onKeydown: handleKeydown,
126
- ...triggerEvents(),
127
- onFocusout: (event) => {
128
- // if focus receiving element is not part of the menu button, then close
129
- if (
130
- rootId &&
131
- document.getElementById(rootId)?.contains(event.relatedTarget as HTMLElement)
132
- ) {
133
- return;
134
- }
135
- setExpanded(false);
136
- },
137
- },
135
+ ref: rootRef,
136
+ ...triggerEvents.value,
137
+ })),
138
138
  button: computed(
139
139
  () =>
140
140
  ({
@@ -160,7 +160,25 @@ export const createMenuButton = createBuilder((options: CreateMenuButtonOptions)
160
160
  };
161
161
  });
162
162
 
163
- export const createMenuItems = createBuilder(() => {
163
+ type CreateMenuItemOptions = {
164
+ /**
165
+ * Called when the menu item should be opened (if it has nested children).
166
+ */
167
+ onOpen?: () => void;
168
+ };
169
+
170
+ export const createMenuItems = createBuilder((options?: CreateMenuItemOptions) => {
171
+ const onKeydown = (event: KeyboardEvent) => {
172
+ switch (event.key) {
173
+ case "ArrowRight":
174
+ case " ":
175
+ case "Enter":
176
+ event.preventDefault();
177
+ options?.onOpen?.();
178
+ break;
179
+ }
180
+ };
181
+
164
182
  return {
165
183
  elements: {
166
184
  listItem: {
@@ -170,6 +188,7 @@ export const createMenuItems = createBuilder(() => {
170
188
  "aria-current": data.active ? "page" : undefined,
171
189
  "aria-disabled": data.disabled,
172
190
  role: "menuitem",
191
+ onKeydown,
173
192
  }),
174
193
  },
175
194
  };
@@ -30,4 +30,4 @@ export type Arrayable<T> = T | Array<T>;
30
30
  /**
31
31
  * Either the actual value or a nullish one.
32
32
  */
33
- export type Nullable<T> = T | null | undefined;
33
+ export type Nullable<T = never> = T | null | undefined;