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

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.3",
5
5
  "type": "module",
6
6
  "author": "Schwarz IT KG",
7
7
  "license": "Apache-2.0",
@@ -26,7 +26,6 @@ const onToggle = () => (isExpanded.value = !isExpanded.value);
26
26
  const onTypeAhead = () => {};
27
27
 
28
28
  const comboBox = createComboBox({
29
- inputValue: selectedOption,
30
29
  autocomplete: "none",
31
30
  label: "some label",
32
31
  listLabel: "List",
@@ -51,7 +50,12 @@ defineExpose({ comboBox });
51
50
 
52
51
  <template>
53
52
  <div ref="comboboxRef">
54
- <input v-bind="input" readonly @keydown.arrow-down="isExpanded = true" />
53
+ <input
54
+ v-bind="input"
55
+ v-model="selectedOption"
56
+ readonly
57
+ @keydown.arrow-down="isExpanded = true"
58
+ />
55
59
 
56
60
  <button v-bind="button">
57
61
  <template v-if="isExpanded">⬆️</template>
@@ -30,7 +30,6 @@ const onAutocomplete = (input: string) => (searchTerm.value = input);
30
30
  const onToggle = () => (isExpanded.value = !isExpanded.value);
31
31
 
32
32
  const comboBox = createComboBox({
33
- inputValue: searchTerm,
34
33
  autocomplete: "list",
35
34
  label: "some label",
36
35
  listLabel: "List",
@@ -55,7 +54,7 @@ defineExpose({ comboBox });
55
54
 
56
55
  <template>
57
56
  <div ref="comboboxRef">
58
- <input v-bind="input" @keydown.arrow-down="isExpanded = true" />
57
+ <input v-bind="input" v-model="searchTerm" @keydown.arrow-down="isExpanded = true" />
59
58
 
60
59
  <button v-bind="button">
61
60
  <template v-if="isExpanded">⬆️</template>
@@ -41,10 +41,6 @@ export type CreateComboboxOptions<
41
41
  * Labels the listbox which displays the available options. E.g. the list label could be "Countries" for a combobox which is labelled "Country".
42
42
  */
43
43
  listLabel: MaybeRef<string>;
44
- /**
45
- * The current value of the combobox. Is updated when an option from the controlled listbox is selected or by typing into it.
46
- */
47
- inputValue: Ref<string | undefined>;
48
44
  /**
49
45
  * Controls the opened/visible state of the associated pop-up. When expanded the activeOption can be controlled via the keyboard.
50
46
  */
@@ -105,7 +101,6 @@ export const createComboBox = createBuilder(
105
101
  TAutoComplete extends ComboboxAutoComplete,
106
102
  TMultiple extends boolean = false,
107
103
  >({
108
- inputValue,
109
104
  autocomplete: autocompleteRef,
110
105
  onAutocomplete,
111
106
  onTypeAhead,
@@ -250,7 +245,6 @@ export const createComboBox = createBuilder(
250
245
  * The input MAY be either a single-line text field that supports editing and typing or an element that only displays the current value of the combobox.
251
246
  */
252
247
  input: computed(() => ({
253
- value: inputValue.value,
254
248
  role: "combobox",
255
249
  "aria-expanded": isExpanded.value,
256
250
  "aria-controls": controlsId,
@@ -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,37 @@
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
+ /**
27
+ * Focus first button
28
+ */
29
+ await buttons.first().focus();
30
+ /**
31
+ * Move keyboard focus among top-level buttons using arrow keys
32
+ */
33
+ await nav.press("ArrowRight");
34
+ await expect(buttons.nth(1)).toBeFocused();
35
+ await nav.press("ArrowLeft");
36
+ await expect(buttons.nth(0)).toBeFocused();
37
+ };
@@ -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";
package/src/playwright.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export * from "./composables/comboBox/createComboBox.testing";
2
2
  export * from "./composables/listbox/createListbox.testing";
3
3
  export * from "./composables/menuButton/createMenuButton.testing";
4
+ export * from "./composables/navigationMenu/createMenu.testing";