@sit-onyx/headless 1.0.0-beta.12 → 1.0.0-beta.14

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.12",
4
+ "version": "1.0.0-beta.14",
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.12",
31
- "vue": "3.5.12"
30
+ "@vue/compiler-dom": "3.5.13",
31
+ "vue": "3.5.13"
32
32
  },
33
33
  "scripts": {
34
34
  "build": "vue-tsc --build --force",
@@ -1,5 +1,5 @@
1
1
  <script lang="ts" setup>
2
- import { ref } from "vue";
2
+ import { ref, type Ref } from "vue";
3
3
  import { createMenuButton } from "./createMenuButton";
4
4
 
5
5
  const items = Array.from({ length: 10 }, (_, index) => {
@@ -10,10 +10,11 @@ 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
14
 
14
15
  const {
15
16
  elements: { root, button, menu, menuItem, listItem },
16
- } = createMenuButton({ isExpanded, onToggle });
17
+ } = createMenuButton({ isExpanded, onToggle, trigger });
17
18
  </script>
18
19
 
19
20
  <template>
@@ -1,141 +1,162 @@
1
- import { computed, useId, type Ref } from "vue";
1
+ 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
5
 
6
6
  type CreateMenuButtonOptions = {
7
- isExpanded: Ref<boolean>;
7
+ isExpanded: Readonly<Ref<boolean>>;
8
+ trigger: Readonly<Ref<"hover" | "click">>;
8
9
  onToggle: () => void;
9
10
  };
10
11
 
11
12
  /**
12
13
  * Based on https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/examples/disclosure-navigation/
13
14
  */
14
- export const createMenuButton = createBuilder(
15
- ({ isExpanded, onToggle }: CreateMenuButtonOptions) => {
16
- const rootId = useId();
17
- const menuId = useId();
18
- const menuRef = createElRef<HTMLElement>();
19
- const buttonId = useId();
15
+ export const createMenuButton = createBuilder((options: CreateMenuButtonOptions) => {
16
+ const rootId = useId();
17
+ const menuId = useId();
18
+ const menuRef = createElRef<HTMLElement>();
19
+ const buttonId = useId();
20
20
 
21
- useGlobalEventListener({
22
- type: "keydown",
23
- listener: (e) => e.key === "Escape" && isExpanded.value && onToggle(),
24
- disabled: computed(() => !isExpanded.value),
25
- });
21
+ useGlobalEventListener({
22
+ type: "keydown",
23
+ listener: (e) => e.key === "Escape" && setExpanded(false),
24
+ disabled: computed(() => !options.isExpanded.value),
25
+ });
26
26
 
27
- /**
28
- * Debounced expanded state that will only be toggled after a given timeout.
29
- */
30
- const updateDebouncedExpanded = debounce(
31
- (expanded: boolean) => isExpanded.value !== expanded && onToggle(),
32
- 200,
33
- );
27
+ /**
28
+ * Debounced expanded state that will only be toggled after a given timeout.
29
+ */
30
+ const updateDebouncedExpanded = debounce(() => options.onToggle(), 200);
31
+ watch(options.isExpanded, () => updateDebouncedExpanded.abort()); // manually changing `isExpanded` should abort debounced action
34
32
 
35
- const focusRelativeItem = (next: "next" | "prev" | "first" | "last") => {
36
- const currentMenuItem = document.activeElement as HTMLElement;
37
-
38
- // Either the current focus is on a "menuitem", then we can just get the parent menu.
39
- // Or the current focus is on the button, then we can get the connected menu using the menuId
40
- const currentMenu = currentMenuItem?.closest('[role="menu"]') || menuRef.value;
41
- if (!currentMenu) return;
33
+ const setExpanded = (expanded: boolean, debounced = false) => {
34
+ if (expanded === options.isExpanded.value) {
35
+ updateDebouncedExpanded.abort();
36
+ return;
37
+ }
38
+ if (debounced) {
39
+ updateDebouncedExpanded();
40
+ return;
41
+ }
42
+ options.onToggle();
43
+ };
42
44
 
43
- const menuItems = [...currentMenu.querySelectorAll<HTMLElement>('[role="menuitem"]')];
44
- let nextIndex = 0;
45
+ const focusRelativeItem = (next: "next" | "prev" | "first" | "last") => {
46
+ const currentMenuItem = document.activeElement as HTMLElement;
45
47
 
46
- if (currentMenuItem) {
47
- const currentIndex = menuItems.indexOf(currentMenuItem);
48
- switch (next) {
49
- case "next":
50
- nextIndex = currentIndex + 1;
51
- break;
52
- case "prev":
53
- nextIndex = currentIndex - 1;
54
- break;
55
- case "first":
56
- nextIndex = 0;
57
- break;
58
- case "last":
59
- nextIndex = menuItems.length - 1;
60
- break;
61
- }
62
- }
48
+ // Either the current focus is on a "menuitem", then we can just get the parent menu.
49
+ // Or the current focus is on the button, then we can get the connected menu using the menuId
50
+ const currentMenu = currentMenuItem?.closest('[role="menu"]') || menuRef.value;
51
+ if (!currentMenu) return;
63
52
 
64
- const nextMenuItem = menuItems[nextIndex];
65
- nextMenuItem?.focus();
66
- };
53
+ const menuItems = [...currentMenu.querySelectorAll<HTMLElement>('[role="menuitem"]')];
54
+ let nextIndex = 0;
67
55
 
68
- const handleKeydown = (event: KeyboardEvent) => {
69
- switch (event.key) {
70
- case "ArrowDown":
71
- case "ArrowRight":
72
- event.preventDefault();
73
- focusRelativeItem("next");
74
- break;
75
- case "ArrowUp":
76
- case "ArrowLeft":
77
- event.preventDefault();
78
- focusRelativeItem("prev");
79
- break;
80
- case "Home":
81
- event.preventDefault();
82
- focusRelativeItem("first");
56
+ if (currentMenuItem) {
57
+ const currentIndex = menuItems.indexOf(currentMenuItem);
58
+ switch (next) {
59
+ case "next":
60
+ nextIndex = currentIndex + 1;
83
61
  break;
84
- case "End":
85
- event.preventDefault();
86
- focusRelativeItem("last");
62
+ case "prev":
63
+ nextIndex = currentIndex - 1;
87
64
  break;
88
- case " ":
89
- event.preventDefault();
90
- (event.target as HTMLElement).click();
65
+ case "first":
66
+ nextIndex = 0;
91
67
  break;
92
- case "Escape":
93
- event.preventDefault();
94
- isExpanded.value && onToggle();
68
+ case "last":
69
+ nextIndex = menuItems.length - 1;
95
70
  break;
96
71
  }
97
- };
72
+ }
98
73
 
99
- return {
100
- elements: {
101
- root: {
102
- id: rootId,
103
- onKeydown: handleKeydown,
104
- onMouseover: () => updateDebouncedExpanded(true),
105
- onMouseout: () => updateDebouncedExpanded(false),
106
- onFocusout: (event) => {
107
- // if focus receiving element is not part of the menu button, then close
108
- if (
109
- rootId &&
110
- document.getElementById(rootId)?.contains(event.relatedTarget as HTMLElement)
111
- ) {
112
- return;
113
- }
114
- isExpanded.value && onToggle();
115
- },
116
- },
117
- button: computed(
118
- () =>
119
- ({
120
- "aria-controls": menuId,
121
- "aria-expanded": isExpanded.value,
122
- "aria-haspopup": true,
123
- onFocus: () => !isExpanded.value && onToggle(),
124
- id: buttonId,
125
- }) as const,
126
- ),
127
- menu: {
128
- id: menuId,
129
- ref: menuRef,
130
- role: "menu",
131
- "aria-labelledby": buttonId,
132
- onClick: () => isExpanded.value && onToggle(),
74
+ const nextMenuItem = menuItems[nextIndex];
75
+ nextMenuItem?.focus();
76
+ };
77
+
78
+ const handleKeydown = (event: KeyboardEvent) => {
79
+ switch (event.key) {
80
+ case "ArrowDown":
81
+ case "ArrowRight":
82
+ event.preventDefault();
83
+ focusRelativeItem("next");
84
+ break;
85
+ case "ArrowUp":
86
+ case "ArrowLeft":
87
+ event.preventDefault();
88
+ focusRelativeItem("prev");
89
+ break;
90
+ case "Home":
91
+ event.preventDefault();
92
+ focusRelativeItem("first");
93
+ break;
94
+ case "End":
95
+ event.preventDefault();
96
+ focusRelativeItem("last");
97
+ break;
98
+ case " ":
99
+ event.preventDefault();
100
+ (event.target as HTMLElement).click();
101
+ break;
102
+ case "Escape":
103
+ event.preventDefault();
104
+ setExpanded(false);
105
+ break;
106
+ }
107
+ };
108
+
109
+ const triggerEvents = () => {
110
+ if (options.trigger.value === "click") {
111
+ return {
112
+ onClick: () => setExpanded(true),
113
+ };
114
+ } else {
115
+ return {
116
+ onMouseenter: () => setExpanded(true),
117
+ onMouseleave: () => setExpanded(false, true),
118
+ };
119
+ }
120
+ };
121
+
122
+ return {
123
+ elements: {
124
+ root: {
125
+ id: rootId,
126
+ onKeydown: handleKeydown,
127
+ ...triggerEvents(),
128
+ onFocusout: (event) => {
129
+ // if focus receiving element is not part of the menu button, then close
130
+ if (
131
+ rootId &&
132
+ document.getElementById(rootId)?.contains(event.relatedTarget as HTMLElement)
133
+ ) {
134
+ return;
135
+ }
136
+ setExpanded(false);
133
137
  },
134
- ...createMenuItems().elements,
135
138
  },
136
- };
137
- },
138
- );
139
+ button: computed(
140
+ () =>
141
+ ({
142
+ "aria-controls": menuId,
143
+ "aria-expanded": options.isExpanded.value,
144
+ "aria-haspopup": true,
145
+ onFocus: () => setExpanded(true),
146
+ id: buttonId,
147
+ }) as const,
148
+ ),
149
+ menu: {
150
+ id: menuId,
151
+ ref: menuRef,
152
+ role: "menu",
153
+ "aria-labelledby": buttonId,
154
+ onClick: () => setExpanded(false),
155
+ },
156
+ ...createMenuItems().elements,
157
+ },
158
+ };
159
+ });
139
160
 
140
161
  export const createMenuItems = createBuilder(() => {
141
162
  return {
@@ -1,3 +1,5 @@
1
+ import { toValue, type MaybeRefOrGetter } from "vue";
2
+
1
3
  /**
2
4
  * Debounces a given callback which will only be called when not called for the given timeout.
3
5
  *
@@ -5,11 +7,16 @@
5
7
  */
6
8
  export const debounce = <TArgs extends unknown[]>(
7
9
  handler: (...args: TArgs) => void,
8
- timeout: number,
10
+ timeout: MaybeRefOrGetter<number>,
9
11
  ) => {
10
12
  let timer: ReturnType<typeof setTimeout> | undefined;
11
- return (...lastArgs: TArgs) => {
13
+
14
+ const func = (...lastArgs: TArgs) => {
12
15
  clearTimeout(timer);
13
- timer = setTimeout(() => handler(...lastArgs), timeout);
16
+ timer = setTimeout(() => handler(...lastArgs), toValue(timeout));
14
17
  };
18
+ /** Abort the currently debounced action, if any. */
19
+ func.abort = () => clearTimeout(timer);
20
+
21
+ return func;
15
22
  };