@sit-onyx/headless 1.0.0-alpha.10 → 1.0.0-alpha.12

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-alpha.10",
4
+ "version": "1.0.0-alpha.12",
5
5
  "type": "module",
6
6
  "author": "Schwarz IT KG",
7
7
  "license": "Apache-2.0",
@@ -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,4 +1,4 @@
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";
@@ -12,8 +12,13 @@ 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
 
@@ -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: {
@@ -12,11 +12,7 @@ const activeItem = ref<string>();
12
12
  const {
13
13
  elements: { button, menu, menuItem, listItem, flyout },
14
14
  state: { isExpanded },
15
- } = createMenuButton({
16
- onSelect: (value) => {
17
- activeItem.value = value;
18
- },
19
- });
15
+ } = createMenuButton({});
20
16
  </script>
21
17
 
22
18
  <template>
@@ -24,9 +20,7 @@ const {
24
20
  <div v-bind="flyout">
25
21
  <ul v-show="isExpanded" v-bind="menu">
26
22
  <li v-for="item in items" v-bind="listItem" :key="item.value" title="item">
27
- <a v-bind="menuItem({ active: activeItem === item.value, value: item.value })">{{
28
- item.label
29
- }}</a>
23
+ <a v-bind="menuItem({ active: activeItem === item.value })" href="#">{{ item.label }}</a>
30
24
  </li>
31
25
  </ul>
32
26
  </div>
@@ -24,7 +24,12 @@ export type MenuButtonTestingOptions = {
24
24
  * Playwright utility for executing accessibility testing for a navigation menu.
25
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
26
  */
27
- export const menuButtonTesting = async ({ button, menu }: MenuButtonTestingOptions) => {
27
+ export const menuButtonTesting = async ({
28
+ page,
29
+ button,
30
+ menu,
31
+ menuItems,
32
+ }: MenuButtonTestingOptions) => {
28
33
  const menuId = await menu.getAttribute("id");
29
34
  expect(menuId).toBeDefined();
30
35
  await expect(
@@ -51,4 +56,54 @@ export const menuButtonTesting = async ({ button, menu }: MenuButtonTestingOptio
51
56
  button,
52
57
  'flyout menu must have an "aria-expanded" attribute set to true',
53
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();
54
109
  };
@@ -3,17 +3,13 @@ import { createBuilder } from "../../utils/builder";
3
3
  import { createId } from "../../utils/id";
4
4
  import { debounce } from "../../utils/timer";
5
5
 
6
- export type CreateMenuButtonOptions = {
7
- /**
8
- * Called when a menu item is selected (via mouse or keyboard).
9
- */
10
- onSelect: (value: string) => void;
11
- };
12
-
13
- export const createMenuButton = createBuilder((options: CreateMenuButtonOptions) => {
6
+ /**
7
+ * Based on https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/examples/disclosure-navigation/
8
+ */
9
+ export const createMenuButton = createBuilder(() => {
14
10
  const menuId = createId("menu");
15
11
  const buttonId = createId("menu-button");
16
- const isExpanded = ref<boolean>(false);
12
+ const isExpanded = ref(false);
17
13
 
18
14
  /**
19
15
  * Debounced expanded state that will only be toggled after a given timeout.
@@ -32,6 +28,67 @@ export const createMenuButton = createBuilder((options: CreateMenuButtonOptions)
32
28
  };
33
29
  });
34
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
+
35
92
  return {
36
93
  state: { isExpanded },
37
94
  elements: {
@@ -43,11 +100,9 @@ export const createMenuButton = createBuilder((options: CreateMenuButtonOptions)
43
100
  "aria-haspopup": true,
44
101
  id: buttonId,
45
102
  ...hoverEvents.value,
103
+ onKeydown: handleKeydown,
46
104
  }) as const,
47
105
  ),
48
- listItem: {
49
- role: "none",
50
- },
51
106
  flyout: {
52
107
  ...hoverEvents.value,
53
108
  },
@@ -55,14 +110,15 @@ export const createMenuButton = createBuilder((options: CreateMenuButtonOptions)
55
110
  id: menuId,
56
111
  role: "menu",
57
112
  "aria-labelledby": buttonId,
113
+ onKeydown: handleKeydown,
114
+ },
115
+ listItem: {
116
+ role: "none",
58
117
  },
59
- menuItem: (data: { active?: boolean; value: string }) => ({
118
+ menuItem: (data: { active?: boolean; disabled?: boolean }) => ({
60
119
  "aria-current": data.active ? "page" : undefined,
120
+ "aria-disabled": data.disabled,
61
121
  role: "menuitem",
62
- tabindex: -1,
63
- onClick: () => {
64
- options.onSelect(data.value);
65
- },
66
122
  }),
67
123
  },
68
124
  };
@@ -28,7 +28,7 @@ export const useOutsideClick = (options: UseOutsideClickOptions) => {
28
28
  const component = options.queryComponent();
29
29
  if (!component || !(event.target instanceof Node)) return;
30
30
 
31
- const isOutsideClick = !event.composedPath().includes(component);
31
+ const isOutsideClick = !component.contains(event.target);
32
32
  if (isOutsideClick) options.onOutsideClick();
33
33
  };
34
34