@sit-onyx/headless 1.0.0-beta.1 → 1.0.0-beta.2

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.1",
4
+ "version": "1.0.0-beta.2",
5
5
  "type": "module",
6
6
  "author": "Schwarz IT KG",
7
7
  "license": "Apache-2.0",
@@ -9,6 +9,6 @@ test("menuButton", async ({ mount, page }) => {
9
9
  page,
10
10
  button: page.getByRole("button"),
11
11
  menu: page.locator("ul"),
12
- menuItems: await page.locator("li").all(),
12
+ menuItems: page.getByRole("menuitem"),
13
13
  });
14
14
  });
@@ -8,18 +8,19 @@ const items = Array.from({ length: 10 }, (_, index) => {
8
8
  });
9
9
 
10
10
  const activeItem = ref<string>();
11
+ const isExpanded = ref(false);
12
+ const onToggle = () => (isExpanded.value = !isExpanded.value);
11
13
 
12
14
  const {
13
- elements: { button, menu, menuItem, listItem, flyout },
14
- state: { isExpanded },
15
- } = createMenuButton();
15
+ elements: { root, button, menu, menuItem, listItem },
16
+ } = createMenuButton({ isExpanded, onToggle });
16
17
  </script>
17
18
 
18
19
  <template>
19
- <button v-bind="button">Toggle nav menu</button>
20
- <div v-bind="flyout">
20
+ <div v-bind="root">
21
+ <button v-bind="button">Toggle nav menu</button>
21
22
  <ul v-show="isExpanded" v-bind="menu">
22
- <li v-for="item in items" v-bind="listItem" :key="item.value" title="item">
23
+ <li v-for="item in items" v-bind="listItem" :key="item.value">
23
24
  <a v-bind="menuItem({ active: activeItem === item.value })" href="#">{{ item.label }}</a>
24
25
  </li>
25
26
  </ul>
@@ -17,7 +17,7 @@ export type MenuButtonTestingOptions = {
17
17
  /**
18
18
  * List items (at least 3).
19
19
  */
20
- menuItems: Locator[];
20
+ menuItems: Locator;
21
21
  };
22
22
 
23
23
  /**
@@ -45,25 +45,26 @@ export const menuButtonTesting = async ({
45
45
  await expect(button).toBeVisible();
46
46
 
47
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");
48
+ await expect(button, "button must have arial-controls attribute").toHaveAttribute(
49
+ "aria-controls",
50
+ );
51
+ await expect(button, "button must have aria-expanded attribute").toHaveAttribute(
52
+ "aria-expanded",
53
+ "false",
54
+ );
63
55
 
64
56
  await page.keyboard.press("Tab");
65
57
  await expect(button, "Button should be focused when pressing tab key").toBeFocused();
66
58
 
59
+ const firstItem = menuItems.first();
60
+ const secondItem = menuItems.nth(1);
61
+ const lastItem = menuItems.last();
62
+
63
+ await page.keyboard.press("Enter");
64
+ await expect(button, "button must have aria-expanded attribute").toHaveAttribute(
65
+ "aria-expanded",
66
+ "true",
67
+ );
67
68
  await button.press("ArrowDown");
68
69
  await expect(
69
70
  firstItem,
@@ -1,123 +1,141 @@
1
- import { computed, ref } from "vue";
1
+ import { computed, ref, type Ref } from "vue";
2
2
  import { createBuilder } from "../../utils/builder";
3
3
  import { createId } from "../../utils/id";
4
4
  import { debounce } from "../../utils/timer";
5
+ import { useGlobalEventListener } from "../helpers/useGlobalListener";
6
+
7
+ type CreateMenuButtonOptions = {
8
+ isExpanded: Ref<boolean>;
9
+ onToggle: () => void;
10
+ };
5
11
 
6
12
  /**
7
13
  * Based on https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/examples/disclosure-navigation/
8
14
  */
9
- export const createMenuButton = createBuilder(() => {
10
- const menuId = createId("menu");
11
- const buttonId = createId("menu-button");
12
- const isExpanded = ref(false);
15
+ export const createMenuButton = createBuilder(
16
+ ({ isExpanded, onToggle }: CreateMenuButtonOptions) => {
17
+ const rootId = createId("menu-button-root");
18
+ const menuId = createId("menu-button-list");
19
+ const menuRef = ref<HTMLElement>();
20
+ const buttonId = createId("menu-button-button");
13
21
 
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
- );
22
+ useGlobalEventListener({
23
+ type: "keydown",
24
+ listener: (e) => e.key === "Escape" && isExpanded.value && onToggle(),
25
+ disabled: computed(() => !isExpanded.value),
26
+ });
21
27
 
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
- });
28
+ /**
29
+ * Debounced expanded state that will only be toggled after a given timeout.
30
+ */
31
+ const updateDebouncedExpanded = debounce(
32
+ (expanded: boolean) => isExpanded.value !== expanded && onToggle(),
33
+ 200,
34
+ );
30
35
 
31
- const focusRelativeItem = (next: "next" | "prev" | "first" | "last") => {
32
- const currentMenuItem = document.activeElement as HTMLElement;
36
+ const focusRelativeItem = (next: "next" | "prev" | "first" | "last") => {
37
+ const currentMenuItem = document.activeElement as HTMLElement;
33
38
 
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
+ // Either the current focus is on a "menuitem", then we can just get the parent menu.
40
+ // Or the current focus is on the button, then we can get the connected menu using the menuId
41
+ const currentMenu = currentMenuItem?.closest('[role="menu"]') || menuRef.value;
42
+ if (!currentMenu) return;
39
43
 
40
- const menuItems = [...currentMenu.querySelectorAll<HTMLElement>('[role="menuitem"]')];
41
- let nextIndex = 0;
44
+ const menuItems = [...currentMenu.querySelectorAll<HTMLElement>('[role="menuitem"]')];
45
+ let nextIndex = 0;
42
46
 
43
- if (currentMenuItem) {
44
- const currentIndex = menuItems.indexOf(currentMenuItem);
45
- switch (next) {
46
- case "next":
47
- nextIndex = currentIndex + 1;
47
+ if (currentMenuItem) {
48
+ const currentIndex = menuItems.indexOf(currentMenuItem);
49
+ switch (next) {
50
+ case "next":
51
+ nextIndex = currentIndex + 1;
52
+ break;
53
+ case "prev":
54
+ nextIndex = currentIndex - 1;
55
+ break;
56
+ case "first":
57
+ nextIndex = 0;
58
+ break;
59
+ case "last":
60
+ nextIndex = menuItems.length - 1;
61
+ break;
62
+ }
63
+ }
64
+
65
+ const nextMenuItem = menuItems[nextIndex];
66
+ nextMenuItem?.focus();
67
+ };
68
+
69
+ const handleKeydown = (event: KeyboardEvent) => {
70
+ switch (event.key) {
71
+ case "ArrowDown":
72
+ case "ArrowRight":
73
+ event.preventDefault();
74
+ focusRelativeItem("next");
75
+ break;
76
+ case "ArrowUp":
77
+ case "ArrowLeft":
78
+ event.preventDefault();
79
+ focusRelativeItem("prev");
80
+ break;
81
+ case "Home":
82
+ event.preventDefault();
83
+ focusRelativeItem("first");
48
84
  break;
49
- case "prev":
50
- nextIndex = currentIndex - 1;
85
+ case "End":
86
+ event.preventDefault();
87
+ focusRelativeItem("last");
51
88
  break;
52
- case "first":
53
- nextIndex = 0;
89
+ case " ":
90
+ event.preventDefault();
91
+ (event.target as HTMLElement).click();
54
92
  break;
55
- case "last":
56
- nextIndex = menuItems.length - 1;
93
+ case "Escape":
94
+ event.preventDefault();
95
+ isExpanded.value && onToggle();
57
96
  break;
58
97
  }
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
- };
98
+ };
91
99
 
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,
100
+ return {
101
+ elements: {
102
+ root: {
103
+ id: rootId,
104
+ onKeydown: handleKeydown,
105
+ onMouseover: () => updateDebouncedExpanded(true),
106
+ onMouseout: () => updateDebouncedExpanded(false),
107
+ onFocusout: (event) => {
108
+ // if focus receiving element is not part of the menu button, then close
109
+ if (document.getElementById(rootId)?.contains(event.relatedTarget as HTMLElement)) {
110
+ return;
111
+ }
112
+ isExpanded.value && onToggle();
113
+ },
114
+ },
115
+ button: computed(
116
+ () =>
117
+ ({
118
+ "aria-controls": menuId,
119
+ "aria-expanded": isExpanded.value,
120
+ "aria-haspopup": true,
121
+ onFocus: () => !isExpanded.value && onToggle(),
122
+ id: buttonId,
123
+ }) as const,
124
+ ),
125
+ menu: {
126
+ id: menuId,
127
+ ref: menuRef,
128
+ role: "menu",
129
+ "aria-labelledby": buttonId,
130
+ onClick: () => isExpanded.value && onToggle(),
131
+ },
132
+ ...createMenuItems().elements,
114
133
  },
115
- ...createMenuItem().elements,
116
- },
117
- };
118
- });
134
+ };
135
+ },
136
+ );
119
137
 
120
- export const createMenuItem = createBuilder(() => {
138
+ export const createMenuItems = createBuilder(() => {
121
139
  return {
122
140
  elements: {
123
141
  listItem: {
@@ -0,0 +1,12 @@
1
+ import { test } from "@playwright/experimental-ct-vue";
2
+ import { navigationTesting } from "./createMenu.testing";
3
+ import TestMenu from "./TestMenu.vue";
4
+
5
+ test("navigationMenu", async ({ mount, page }) => {
6
+ await mount(<TestMenu />);
7
+
8
+ await navigationTesting({
9
+ buttons: page.getByRole("button"),
10
+ nav: page.getByRole("navigation"),
11
+ });
12
+ });
@@ -0,0 +1,16 @@
1
+ <script lang="ts" setup>
2
+ import TestMenuButton from "../menuButton/TestMenuButton.vue";
3
+ import { createNavigationMenu } from "./createMenu";
4
+
5
+ const {
6
+ elements: { nav },
7
+ } = createNavigationMenu({ navigationName: "test menu" });
8
+ </script>
9
+
10
+ <template>
11
+ <nav v-bind="nav">
12
+ <TestMenuButton />
13
+ <TestMenuButton />
14
+ <TestMenuButton />
15
+ </nav>
16
+ </template>
@@ -0,0 +1,48 @@
1
+ import { expect } from "@playwright/experimental-ct-vue";
2
+ import type { Locator } from "@playwright/test";
3
+
4
+ export type NavigationMenuTestingOptions = {
5
+ /**
6
+ * Locator for the navigation landmark.
7
+ */
8
+ nav: Locator;
9
+ /**
10
+ * Locator for the button elements.
11
+ */
12
+ buttons: Locator;
13
+ };
14
+
15
+ /**
16
+ * Playwright utility for executing accessibility testing for a navigation menu.
17
+ * Will check aria attributes and keyboard shortcuts as defined in https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/examples/disclosure-navigation/
18
+ */
19
+ export const navigationTesting = async ({ nav, buttons }: NavigationMenuTestingOptions) => {
20
+ /**
21
+ * Navigation landmark should have label
22
+ */
23
+ await expect(nav).toHaveRole("navigation");
24
+ await expect(nav).toHaveAttribute("aria-label");
25
+ /**
26
+ * Disclosure buttons should have aria attributes
27
+ */
28
+ for (const button of await buttons.all()) {
29
+ await expect(button, "button must have arial-controls attribute").toHaveAttribute(
30
+ "aria-controls",
31
+ );
32
+ await expect(button, "button must have aria-expanded attribute").toHaveAttribute(
33
+ "aria-expanded",
34
+ );
35
+ }
36
+ /**
37
+ * Focus first button
38
+ */
39
+ await nav.press("Tab");
40
+ await expect(buttons.nth(0)).toBeFocused();
41
+ /**
42
+ * Move keyboard focus among top-level buttons using arrow keys
43
+ */
44
+ await nav.press("ArrowRight");
45
+ await expect(buttons.nth(1)).toBeFocused();
46
+ await nav.press("ArrowLeft");
47
+ await expect(buttons.nth(0)).toBeFocused();
48
+ };
@@ -0,0 +1,56 @@
1
+ import { unref, type MaybeRef } from "vue";
2
+ import { createId } from "../..";
3
+ import { createBuilder } from "../../utils/builder";
4
+ import { MathUtils } from "../../utils/math";
5
+
6
+ type CreateNavigationMenu = {
7
+ /**
8
+ * Name of the navigation landmark.
9
+ * Usually this is the name of the website.
10
+ */
11
+ navigationName?: MaybeRef<string | undefined>;
12
+ };
13
+
14
+ /**
15
+ * Based on https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/examples/disclosure-navigation/
16
+ */
17
+ export const createNavigationMenu = createBuilder(({ navigationName }: CreateNavigationMenu) => {
18
+ const navId = createId("nav");
19
+
20
+ const getMenuButtons = () => {
21
+ const nav = document.getElementById(navId);
22
+ if (!nav) return [];
23
+ return [...nav.querySelectorAll<HTMLElement>("button[aria-expanded][aria-controls]")];
24
+ };
25
+
26
+ const focusRelative = (trigger: HTMLElement, next: "next" | "previous") => {
27
+ const menuButtons = getMenuButtons();
28
+ const index = menuButtons.indexOf(trigger);
29
+ if (index === -1) return;
30
+ const nextIndex = MathUtils.clamp(
31
+ index + (next === "next" ? 1 : -1),
32
+ 0,
33
+ menuButtons.length - 1,
34
+ );
35
+ menuButtons[nextIndex].focus();
36
+ };
37
+
38
+ return {
39
+ elements: {
40
+ nav: {
41
+ "aria-label": unref(navigationName),
42
+ id: navId,
43
+ onKeydown: (event) => {
44
+ switch (event.key) {
45
+ case "ArrowRight":
46
+ focusRelative(event.target as HTMLElement, "next");
47
+ break;
48
+ case "ArrowLeft":
49
+ focusRelative(event.target as HTMLElement, "previous");
50
+ break;
51
+ }
52
+ },
53
+ },
54
+ },
55
+ };
56
+ });
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export * from "./composables/comboBox/createComboBox";
2
2
  export * from "./composables/listbox/createListbox";
3
3
  export * from "./composables/menuButton/createMenuButton";
4
+ export * from "./composables/navigationMenu/createMenu";
4
5
  export * from "./composables/tooltip/createTooltip";
5
6
  export { createId } from "./utils/id";
6
7
  export { isPrintableCharacter, wasKeyPressed } from "./utils/keyboard";