@sit-onyx/headless 1.0.0-beta.11 → 1.0.0-beta.13

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.11",
4
+ "version": "1.0.0-beta.13",
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",
@@ -57,11 +57,11 @@ defineExpose({ comboBox });
57
57
  @keydown.arrow-down="isExpanded = true"
58
58
  />
59
59
 
60
- <button v-bind="button">
60
+ <button v-bind="button" type="button">
61
61
  <template v-if="isExpanded">⬆️</template>
62
62
  <template v-else>⬇️</template>
63
63
  </button>
64
- <ul v-bind="listbox" :class="{ hidden: !isExpanded }" style="width: 400px">
64
+ <ul class="listbox" v-bind="listbox" :class="{ hidden: !isExpanded }">
65
65
  <li
66
66
  v-for="e in options"
67
67
  :key="e"
@@ -84,4 +84,7 @@ defineExpose({ comboBox });
84
84
  [aria-selected="true"] {
85
85
  background-color: red;
86
86
  }
87
+ .listbox {
88
+ width: 400px;
89
+ }
87
90
  </style>
@@ -209,13 +209,13 @@ export const createComboBox = createBuilder(
209
209
  return handleNavigation(event);
210
210
  };
211
211
 
212
- const autocompleteInput =
213
- autocomplete.value !== "none"
214
- ? {
215
- "aria-autocomplete": autocomplete.value,
216
- type: "text",
217
- }
218
- : null;
212
+ const autocompleteInput = computed(() => {
213
+ if (autocomplete.value === "none") return null;
214
+ return {
215
+ "aria-autocomplete": autocomplete.value,
216
+ type: "text",
217
+ };
218
+ });
219
219
 
220
220
  const {
221
221
  elements: { option, group, listbox },
@@ -264,7 +264,7 @@ export const createComboBox = createBuilder(
264
264
  activeOption.value != undefined ? getOptionId(activeOption.value) : undefined,
265
265
  onInput: handleInput,
266
266
  onKeydown: handleKeydown,
267
- ...autocompleteInput,
267
+ ...autocompleteInput.value,
268
268
  })),
269
269
  /**
270
270
  * An optional button to control the visibility of the popup.
@@ -18,7 +18,7 @@ const {
18
18
 
19
19
  <template>
20
20
  <div v-bind="root">
21
- <button v-bind="button">Toggle nav menu</button>
21
+ <button v-bind="button" type="button">Toggle nav menu</button>
22
22
  <ul v-show="isExpanded" v-bind="menu">
23
23
  <li v-for="item in items" v-bind="listItem" :key="item.value">
24
24
  <a v-bind="menuItem({ active: activeItem === item.value })" href="#">{{ item.label }}</a>
@@ -1,141 +1,149 @@
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
8
  onToggle: () => void;
9
9
  };
10
10
 
11
11
  /**
12
12
  * Based on https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/examples/disclosure-navigation/
13
13
  */
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();
14
+ export const createMenuButton = createBuilder((options: CreateMenuButtonOptions) => {
15
+ const rootId = useId();
16
+ const menuId = useId();
17
+ const menuRef = createElRef<HTMLElement>();
18
+ const buttonId = useId();
20
19
 
21
- useGlobalEventListener({
22
- type: "keydown",
23
- listener: (e) => e.key === "Escape" && isExpanded.value && onToggle(),
24
- disabled: computed(() => !isExpanded.value),
25
- });
20
+ useGlobalEventListener({
21
+ type: "keydown",
22
+ listener: (e) => e.key === "Escape" && setExpanded(false),
23
+ disabled: computed(() => !options.isExpanded.value),
24
+ });
26
25
 
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
- );
26
+ /**
27
+ * Debounced expanded state that will only be toggled after a given timeout.
28
+ */
29
+ const updateDebouncedExpanded = debounce(() => options.onToggle(), 200);
30
+ watch(options.isExpanded, () => updateDebouncedExpanded.abort()); // manually changing `isExpanded` should abort debounced action
34
31
 
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;
32
+ const setExpanded = (expanded: boolean, debounced = false) => {
33
+ if (expanded === options.isExpanded.value) {
34
+ updateDebouncedExpanded.abort();
35
+ return;
36
+ }
37
+ if (debounced) {
38
+ updateDebouncedExpanded();
39
+ return;
40
+ }
41
+ options.onToggle();
42
+ };
42
43
 
43
- const menuItems = [...currentMenu.querySelectorAll<HTMLElement>('[role="menuitem"]')];
44
- let nextIndex = 0;
44
+ const focusRelativeItem = (next: "next" | "prev" | "first" | "last") => {
45
+ const currentMenuItem = document.activeElement as HTMLElement;
45
46
 
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
- }
47
+ // Either the current focus is on a "menuitem", then we can just get the parent menu.
48
+ // Or the current focus is on the button, then we can get the connected menu using the menuId
49
+ const currentMenu = currentMenuItem?.closest('[role="menu"]') || menuRef.value;
50
+ if (!currentMenu) return;
63
51
 
64
- const nextMenuItem = menuItems[nextIndex];
65
- nextMenuItem?.focus();
66
- };
52
+ const menuItems = [...currentMenu.querySelectorAll<HTMLElement>('[role="menuitem"]')];
53
+ let nextIndex = 0;
67
54
 
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");
55
+ if (currentMenuItem) {
56
+ const currentIndex = menuItems.indexOf(currentMenuItem);
57
+ switch (next) {
58
+ case "next":
59
+ nextIndex = currentIndex + 1;
79
60
  break;
80
- case "Home":
81
- event.preventDefault();
82
- focusRelativeItem("first");
61
+ case "prev":
62
+ nextIndex = currentIndex - 1;
83
63
  break;
84
- case "End":
85
- event.preventDefault();
86
- focusRelativeItem("last");
64
+ case "first":
65
+ nextIndex = 0;
87
66
  break;
88
- case " ":
89
- event.preventDefault();
90
- (event.target as HTMLElement).click();
91
- break;
92
- case "Escape":
93
- event.preventDefault();
94
- isExpanded.value && onToggle();
67
+ case "last":
68
+ nextIndex = menuItems.length - 1;
95
69
  break;
96
70
  }
97
- };
71
+ }
98
72
 
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(),
73
+ const nextMenuItem = menuItems[nextIndex];
74
+ nextMenuItem?.focus();
75
+ };
76
+
77
+ const handleKeydown = (event: KeyboardEvent) => {
78
+ switch (event.key) {
79
+ case "ArrowDown":
80
+ case "ArrowRight":
81
+ event.preventDefault();
82
+ focusRelativeItem("next");
83
+ break;
84
+ case "ArrowUp":
85
+ case "ArrowLeft":
86
+ event.preventDefault();
87
+ focusRelativeItem("prev");
88
+ break;
89
+ case "Home":
90
+ event.preventDefault();
91
+ focusRelativeItem("first");
92
+ break;
93
+ case "End":
94
+ event.preventDefault();
95
+ focusRelativeItem("last");
96
+ break;
97
+ case " ":
98
+ event.preventDefault();
99
+ (event.target as HTMLElement).click();
100
+ break;
101
+ case "Escape":
102
+ event.preventDefault();
103
+ setExpanded(false);
104
+ break;
105
+ }
106
+ };
107
+
108
+ return {
109
+ elements: {
110
+ root: {
111
+ id: rootId,
112
+ onKeydown: handleKeydown,
113
+ onMouseenter: () => setExpanded(true),
114
+ onMouseleave: () => setExpanded(false, true),
115
+ onFocusout: (event) => {
116
+ // if focus receiving element is not part of the menu button, then close
117
+ if (
118
+ rootId &&
119
+ document.getElementById(rootId)?.contains(event.relatedTarget as HTMLElement)
120
+ ) {
121
+ return;
122
+ }
123
+ setExpanded(false);
133
124
  },
134
- ...createMenuItems().elements,
135
125
  },
136
- };
137
- },
138
- );
126
+ button: computed(
127
+ () =>
128
+ ({
129
+ "aria-controls": menuId,
130
+ "aria-expanded": options.isExpanded.value,
131
+ "aria-haspopup": true,
132
+ onFocus: () => setExpanded(true),
133
+ id: buttonId,
134
+ }) as const,
135
+ ),
136
+ menu: {
137
+ id: menuId,
138
+ ref: menuRef,
139
+ role: "menu",
140
+ "aria-labelledby": buttonId,
141
+ onClick: () => setExpanded(false),
142
+ },
143
+ ...createMenuItems().elements,
144
+ },
145
+ };
146
+ });
139
147
 
140
148
  export const createMenuItems = createBuilder(() => {
141
149
  return {
@@ -71,6 +71,41 @@ export const tabsTesting = async (options: TabsTestingOptions) => {
71
71
  await options.page.keyboard.press("Space");
72
72
  const { tabId: tabIdFirst, panelId: panelIdFirst } = await expectTabAttributes(firstTab, true);
73
73
  await expectPanelAttributes(options.page.locator(`#${panelIdFirst}`), tabIdFirst);
74
+
75
+ // should skip disabled tabs when using the keyboard
76
+ await firstTab.click();
77
+ await secondTab.evaluate((element) => (element.ariaDisabled = "true"));
78
+ await expect(secondTab, "should disable second tab when setting aria-disabled").toBeDisabled();
79
+
80
+ await options.page.keyboard.press("ArrowRight");
81
+ await expect(secondTab, "should not focus second tab if its aria-disabled").not.toBeFocused();
82
+ await expect(
83
+ options.tablist.getByRole("tab").nth(2),
84
+ "should focus next tab after disabled one when pressing arrow right",
85
+ ).toBeFocused();
86
+
87
+ await options.page.keyboard.press("ArrowLeft");
88
+ await expect(
89
+ firstTab,
90
+ "should focus tab before disabled one when pressing arrow left",
91
+ ).toBeFocused();
92
+
93
+ await secondTab.evaluate((element) => (element.ariaDisabled = null));
94
+ await firstTab.evaluate((element) => (element.ariaDisabled = "true"));
95
+ await options.page.keyboard.press("Home");
96
+ await expect(
97
+ secondTab,
98
+ "should focus second tab when pressing Home if first tab is disabled",
99
+ ).toBeFocused();
100
+
101
+ await firstTab.evaluate((element) => (element.ariaDisabled = null));
102
+ await lastTab.evaluate((element) => (element.ariaDisabled = "true"));
103
+ await firstTab.focus();
104
+ await options.page.keyboard.press("End");
105
+ await expect(
106
+ options.tablist.getByRole("tab").nth(-2),
107
+ "should focus second last tab when pressing End if last tab is disabled",
108
+ ).toBeFocused();
74
109
  };
75
110
 
76
111
  /**
@@ -37,30 +37,41 @@ export const createTabs = createBuilder(<T extends PropertyKey>(options: CreateT
37
37
  const handleKeydown = (event: KeyboardEvent) => {
38
38
  const tab = event.target as Element;
39
39
 
40
- const focusFirstTab = () => {
41
- const element = tab.parentElement?.querySelector('[role="tab"]');
40
+ const enabledTabs = Array.from(
41
+ tab.parentElement?.querySelectorAll('[role="tab"]') ?? [],
42
+ ).filter((tab) => tab.ariaDisabled !== "true");
43
+
44
+ const currentTabIndex = enabledTabs.indexOf(tab);
45
+
46
+ const focusElement = (element?: Element | null) => {
42
47
  if (element instanceof HTMLElement) element.focus();
43
48
  };
44
49
 
45
- const focusLastTab = () => {
46
- const element = Array.from(tab.parentElement?.querySelectorAll('[role="tab"]') ?? []).at(-1);
47
- if (element instanceof HTMLElement) element.focus();
50
+ const focusFirstTab = () => focusElement(enabledTabs.at(0));
51
+ const focusLastTab = () => focusElement(enabledTabs.at(-1));
52
+
53
+ /**
54
+ * Focuses the next/previous tab. Will ignore/skip disabled ones.
55
+ */
56
+ const focusTab = (direction: "next" | "previous") => {
57
+ if (currentTabIndex === -1) return;
58
+ const newIndex = direction === "next" ? currentTabIndex + 1 : currentTabIndex - 1;
59
+
60
+ if (newIndex < 0) {
61
+ return focusLastTab();
62
+ } else if (newIndex >= enabledTabs.length) {
63
+ return focusFirstTab();
64
+ }
65
+
66
+ return focusElement(enabledTabs.at(newIndex));
48
67
  };
49
68
 
50
69
  switch (event.key) {
51
70
  case "ArrowRight":
52
- if (tab.nextElementSibling && tab.nextElementSibling instanceof HTMLElement) {
53
- tab.nextElementSibling.focus();
54
- } else {
55
- focusFirstTab();
56
- }
71
+ focusTab("next");
57
72
  break;
58
73
  case "ArrowLeft":
59
- if (tab.previousElementSibling && tab.previousElementSibling instanceof HTMLElement) {
60
- tab.previousElementSibling.focus();
61
- } else {
62
- focusLastTab();
63
- }
74
+ focusTab("previous");
64
75
  break;
65
76
  case "Home":
66
77
  focusFirstTab();
@@ -86,7 +97,7 @@ export const createTabs = createBuilder(<T extends PropertyKey>(options: CreateT
86
97
  onKeydown: handleKeydown,
87
98
  })),
88
99
  tab: computed(() => {
89
- return (data: { value: T }) => {
100
+ return (data: { value: T; disabled?: boolean }) => {
90
101
  const { tabId: selectedTabId } = getId(unref(options.selectedTab));
91
102
  const { tabId, panelId } = getId(data.value);
92
103
  const isSelected = tabId === selectedTabId;
@@ -96,8 +107,9 @@ export const createTabs = createBuilder(<T extends PropertyKey>(options: CreateT
96
107
  role: "tab",
97
108
  "aria-selected": isSelected,
98
109
  "aria-controls": panelId,
110
+ "aria-disabled": data.disabled ? true : undefined,
99
111
  onClick: () => options.onSelect?.(data.value),
100
- tabindex: isSelected ? 0 : -1,
112
+ tabindex: isSelected && !data.disabled ? 0 : -1,
101
113
  } as const;
102
114
  };
103
115
  }),
@@ -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
  };