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

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.
@@ -30,13 +30,6 @@ export const menuButtonTesting = async ({
30
30
  menu,
31
31
  menuItems,
32
32
  }: MenuButtonTestingOptions) => {
33
- const menuId = await menu.getAttribute("id");
34
- expect(menuId).toBeDefined();
35
- await expect(
36
- button,
37
- "navigation menu should have set the list ID to the aria-controls",
38
- ).toHaveAttribute("aria-controls", menuId!);
39
-
40
33
  await expect(
41
34
  button,
42
35
  'navigation menu should have an "aria-haspopup" attribute set to true',
@@ -80,18 +73,6 @@ export const menuButtonTesting = async ({
80
73
  await menu.press("ArrowUp");
81
74
  await expect(firstItem, "First item should be focused when pressing arrow up key").toBeFocused();
82
75
 
83
- await menu.press("ArrowRight");
84
- await expect(
85
- secondItem,
86
- "Second item should be focused when pressing arrow right key",
87
- ).toBeFocused();
88
-
89
- await menu.press("ArrowLeft");
90
- await expect(
91
- firstItem,
92
- "First item should be focused when pressing arrow left key",
93
- ).toBeFocused();
94
-
95
76
  await page.keyboard.press("Tab");
96
77
  await expect(button, "Button should be focused when pressing tab key").not.toBeFocused();
97
78
 
@@ -1,141 +1,195 @@
1
- import { computed, ref, type Ref } from "vue";
2
- import { createBuilder } from "../../utils/builder";
3
- import { createId } from "../../utils/id";
1
+ import { computed, toValue, useId, watch, type MaybeRef, type Ref } from "vue";
2
+ import { createBuilder, createElRef } from "../../utils/builder";
4
3
  import { debounce } from "../../utils/timer";
5
4
  import { useGlobalEventListener } from "../helpers/useGlobalListener";
5
+ import { useOutsideClick } from "../helpers/useOutsideClick";
6
6
 
7
7
  type CreateMenuButtonOptions = {
8
- isExpanded: Ref<boolean>;
8
+ isExpanded: Readonly<Ref<boolean>>;
9
+ trigger: Readonly<MaybeRef<"hover" | "click">>;
9
10
  onToggle: () => void;
11
+ disabled?: Readonly<Ref<boolean>>;
12
+ /**
13
+ * Whether the menu button opens to the top or bottom. Defines the keyboard navigation behavior (e.g. Arrow up and down).
14
+ *
15
+ * @default "bottom"
16
+ */
17
+ position?: MaybeRef<"top" | "bottom">;
10
18
  };
11
19
 
12
20
  /**
13
21
  * Based on https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/examples/disclosure-navigation/
14
22
  */
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");
21
-
22
- useGlobalEventListener({
23
- type: "keydown",
24
- listener: (e) => e.key === "Escape" && isExpanded.value && onToggle(),
25
- disabled: computed(() => !isExpanded.value),
26
- });
27
-
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
- );
35
-
36
- const focusRelativeItem = (next: "next" | "prev" | "first" | "last") => {
37
- const currentMenuItem = document.activeElement as HTMLElement;
38
-
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;
43
-
44
- const menuItems = [...currentMenu.querySelectorAll<HTMLElement>('[role="menuitem"]')];
45
- let nextIndex = 0;
46
-
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
- }
23
+ export const createMenuButton = createBuilder((options: CreateMenuButtonOptions) => {
24
+ const rootId = useId();
25
+ const menuId = useId();
26
+ const rootRef = createElRef<HTMLElement>();
27
+ const menuRef = createElRef<HTMLElement>();
28
+ const buttonId = useId();
64
29
 
65
- const nextMenuItem = menuItems[nextIndex];
66
- nextMenuItem?.focus();
67
- };
30
+ const position = computed(() => toValue(options.position) ?? "bottom");
68
31
 
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");
32
+ useGlobalEventListener({
33
+ type: "keydown",
34
+ listener: (e) => e.key === "Escape" && setExpanded(false),
35
+ disabled: computed(() => !options.isExpanded.value),
36
+ });
37
+
38
+ /**
39
+ * Debounced expanded state that will only be toggled after a given timeout.
40
+ */
41
+ const updateDebouncedExpanded = debounce(() => options.onToggle(), 200);
42
+ watch(options.isExpanded, () => updateDebouncedExpanded.abort()); // manually changing `isExpanded` should abort debounced action
43
+
44
+ const setExpanded = (expanded: boolean, debounced = false) => {
45
+ if (options.disabled?.value) return;
46
+ if (expanded === options.isExpanded.value) {
47
+ updateDebouncedExpanded.abort();
48
+ return;
49
+ }
50
+ if (debounced) {
51
+ updateDebouncedExpanded();
52
+ return;
53
+ }
54
+ options.onToggle();
55
+ };
56
+
57
+ const focusRelativeItem = (next: "next" | "prev" | "first" | "last") => {
58
+ const currentMenuItem = document.activeElement as HTMLElement;
59
+
60
+ // Either the current focus is on a "menuitem", then we can just get the parent menu.
61
+ // Or the current focus is on the button, then we can get the connected menu using the menuId
62
+ const currentMenu = currentMenuItem?.closest('[role="menu"]') || menuRef.value;
63
+ if (!currentMenu) return;
64
+
65
+ const menuItems = Array.from(currentMenu.querySelectorAll<HTMLElement>('[role="menuitem"]'))
66
+ // filter out nested children
67
+ .filter((item) => item.closest('[role="menu"]') === currentMenu);
68
+ if (position.value === "top") menuItems.reverse();
69
+ let nextIndex = 0;
70
+
71
+ if (currentMenuItem) {
72
+ const currentIndex = menuItems.indexOf(currentMenuItem);
73
+ switch (next) {
74
+ case "next":
75
+ nextIndex = currentIndex + 1;
84
76
  break;
85
- case "End":
86
- event.preventDefault();
87
- focusRelativeItem("last");
77
+ case "prev":
78
+ nextIndex = currentIndex - 1;
88
79
  break;
89
- case " ":
90
- event.preventDefault();
91
- (event.target as HTMLElement).click();
80
+ case "first":
81
+ nextIndex = 0;
92
82
  break;
93
- case "Escape":
94
- event.preventDefault();
95
- isExpanded.value && onToggle();
83
+ case "last":
84
+ nextIndex = menuItems.length - 1;
96
85
  break;
97
86
  }
98
- };
87
+ }
99
88
 
89
+ const nextMenuItem = menuItems[nextIndex];
90
+ nextMenuItem?.focus();
91
+ };
92
+
93
+ const handleKeydown = (event: KeyboardEvent) => {
94
+ switch (event.key) {
95
+ case "ArrowDown":
96
+ event.preventDefault();
97
+ focusRelativeItem(position.value === "bottom" ? "next" : "prev");
98
+ break;
99
+ case "ArrowUp":
100
+ event.preventDefault();
101
+ focusRelativeItem(position.value === "bottom" ? "prev" : "next");
102
+ break;
103
+ case "Home":
104
+ event.preventDefault();
105
+ focusRelativeItem("first");
106
+ break;
107
+ case "End":
108
+ event.preventDefault();
109
+ focusRelativeItem("last");
110
+ break;
111
+ case " ":
112
+ case "Enter":
113
+ if (event.target instanceof HTMLInputElement) break;
114
+ event.preventDefault();
115
+ (event.target as HTMLElement).click();
116
+ break;
117
+ case "Escape":
118
+ event.preventDefault();
119
+ setExpanded(false);
120
+ break;
121
+ }
122
+ };
123
+
124
+ const triggerEvents = computed(() => {
125
+ if (toValue(options.trigger) !== "hover") return;
100
126
  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,
133
- },
127
+ onMouseenter: () => setExpanded(true),
128
+ onMouseleave: () => setExpanded(false, true),
134
129
  };
135
- },
136
- );
130
+ });
131
+
132
+ useOutsideClick({
133
+ inside: rootRef,
134
+ onOutsideClick: () => setExpanded(false),
135
+ disabled: computed(() => !options.isExpanded.value),
136
+ checkOnTab: true,
137
+ });
138
+
139
+ return {
140
+ elements: {
141
+ root: computed(() => ({
142
+ id: rootId,
143
+ onKeydown: handleKeydown,
144
+ ref: rootRef,
145
+ ...triggerEvents.value,
146
+ })),
147
+ button: computed(
148
+ () =>
149
+ ({
150
+ "aria-controls": menuId,
151
+ "aria-expanded": options.isExpanded.value,
152
+ "aria-haspopup": true,
153
+ onFocus: () => setExpanded(true, true),
154
+ onClick: () =>
155
+ toValue(options.trigger) == "click"
156
+ ? setExpanded(!options.isExpanded.value)
157
+ : undefined,
158
+ id: buttonId,
159
+ disabled: options.disabled?.value,
160
+ }) as const,
161
+ ),
162
+ menu: {
163
+ id: menuId,
164
+ ref: menuRef,
165
+ role: "menu",
166
+ "aria-labelledby": buttonId,
167
+ onClick: () => setExpanded(false),
168
+ },
169
+ ...createMenuItems().elements,
170
+ },
171
+ };
172
+ });
173
+
174
+ type CreateMenuItemOptions = {
175
+ /**
176
+ * Called when the menu item should be opened (if it has nested children).
177
+ */
178
+ onOpen?: () => void;
179
+ };
180
+
181
+ export const createMenuItems = createBuilder((options?: CreateMenuItemOptions) => {
182
+ const onKeydown = (event: KeyboardEvent) => {
183
+ switch (event.key) {
184
+ case "ArrowRight":
185
+ case " ":
186
+ case "Enter":
187
+ event.preventDefault();
188
+ options?.onOpen?.();
189
+ break;
190
+ }
191
+ };
137
192
 
138
- export const createMenuItems = createBuilder(() => {
139
193
  return {
140
194
  elements: {
141
195
  listItem: {
@@ -145,6 +199,7 @@ export const createMenuItems = createBuilder(() => {
145
199
  "aria-current": data.active ? "page" : undefined,
146
200
  "aria-disabled": data.disabled,
147
201
  role: "menuitem",
202
+ onKeydown,
148
203
  }),
149
204
  },
150
205
  };
@@ -22,22 +22,11 @@ export const navigationTesting = async ({ nav, buttons }: NavigationMenuTestingO
22
22
  */
23
23
  await expect(nav).toHaveRole("navigation");
24
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
- }
25
+
36
26
  /**
37
27
  * Focus first button
38
28
  */
39
- await nav.press("Tab");
40
- await expect(buttons.nth(0)).toBeFocused();
29
+ await buttons.first().focus();
41
30
  /**
42
31
  * Move keyboard focus among top-level buttons using arrow keys
43
32
  */
@@ -1,5 +1,4 @@
1
- import { unref, type MaybeRef } from "vue";
2
- import { createId } from "../..";
1
+ import { unref, useId, type MaybeRef } from "vue";
3
2
  import { createBuilder } from "../../utils/builder";
4
3
  import { MathUtils } from "../../utils/math";
5
4
 
@@ -15,12 +14,12 @@ type CreateNavigationMenu = {
15
14
  * Based on https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/examples/disclosure-navigation/
16
15
  */
17
16
  export const createNavigationMenu = createBuilder(({ navigationName }: CreateNavigationMenu) => {
18
- const navId = createId("nav");
17
+ const navId = useId();
19
18
 
20
19
  const getMenuButtons = () => {
21
- const nav = document.getElementById(navId);
20
+ const nav = navId ? document.getElementById(navId) : undefined;
22
21
  if (!nav) return [];
23
- return [...nav.querySelectorAll<HTMLElement>("button[aria-expanded][aria-controls]")];
22
+ return Array.from(nav.querySelectorAll<HTMLElement>("button[aria-expanded][aria-controls]"));
24
23
  };
25
24
 
26
25
  const focusRelative = (trigger: HTMLElement, next: "next" | "previous") => {
@@ -0,0 +1,12 @@
1
+ import { test } from "@playwright/experimental-ct-vue";
2
+ import TestTabs from "./TestTabs.vue";
3
+ import { tabsTesting } from "./createTabs.testing";
4
+
5
+ test("tabs", async ({ mount, page }) => {
6
+ const component = await mount(<TestTabs />);
7
+
8
+ await tabsTesting({
9
+ page,
10
+ tablist: component.getByRole("tablist"),
11
+ });
12
+ });
@@ -0,0 +1,28 @@
1
+ <script lang="ts" setup>
2
+ import { ref } from "vue";
3
+ import { createTabs } from "./createTabs";
4
+
5
+ const selectedTab = ref("tab-1");
6
+
7
+ const {
8
+ elements: { tablist, tab, tabpanel },
9
+ } = createTabs({
10
+ label: "Tablist label",
11
+ selectedTab,
12
+ onSelect: (tab) => (selectedTab.value = tab),
13
+ });
14
+ </script>
15
+
16
+ <template>
17
+ <div>
18
+ <div v-bind="tablist">
19
+ <button v-bind="tab({ value: 'tab-1' })" type="button">Tab 1</button>
20
+ <button v-bind="tab({ value: 'tab-2' })" type="button">Tab 2</button>
21
+ <button v-bind="tab({ value: 'tab-3' })" type="button">Tab 3</button>
22
+ </div>
23
+
24
+ <div v-if="selectedTab === 'tab-1'" v-bind="tabpanel({ value: 'tab-1' })">Tab content 1</div>
25
+ <div v-if="selectedTab === 'tab-2'" v-bind="tabpanel({ value: 'tab-2' })">Tab content 2</div>
26
+ <div v-if="selectedTab === 'tab-3'" v-bind="tabpanel({ value: 'tab-3' })">Tab content 3</div>
27
+ </div>
28
+ </template>
@@ -0,0 +1,151 @@
1
+ import { expect } from "@playwright/experimental-ct-vue";
2
+ import type { Locator, Page } from "@playwright/test";
3
+
4
+ export type TabsTestingOptions = {
5
+ page: Page;
6
+ /**
7
+ * Locator of the tabs component. Must have at least 3 tabs where the first one is initially selected.
8
+ */
9
+ tablist: Locator;
10
+ };
11
+
12
+ /**
13
+ * Playwright utility for executing accessibility testing for tabs.
14
+ * Will check aria attributes and keyboard shortcuts as defined in https://www.w3.org/WAI/ARIA/apg/patterns/tabs/
15
+ */
16
+ export const tabsTesting = async (options: TabsTestingOptions) => {
17
+ await expect(options.tablist, 'tablist element must have role "tablist"').toHaveRole("tablist");
18
+ await expect(options.tablist, "tablist must have an accessible label").toHaveAttribute(
19
+ "aria-label",
20
+ );
21
+
22
+ const firstTab = options.tablist.getByRole("tab").first();
23
+ const secondTab = options.tablist.getByRole("tab").nth(1);
24
+ const lastTab = options.tablist.getByRole("tab").last();
25
+
26
+ const { tabId, panelId } = await expectTabAttributes(firstTab, true);
27
+ await expectPanelAttributes(options.page.locator(`#${panelId}`), tabId);
28
+
29
+ // ACT (switch tab)
30
+ await secondTab.click();
31
+
32
+ const { tabId: tabId2, panelId: panelId2 } = await expectTabAttributes(secondTab, true);
33
+ await expectPanelAttributes(options.page.locator(`#${panelId2}`), tabId2);
34
+ await expect(secondTab, "second tab should be focused").toBeFocused();
35
+
36
+ await expect(options.page.getByRole("tabpanel"), "should hide previous panel").toHaveCount(1);
37
+
38
+ // keyboard support
39
+ await options.page.keyboard.press("ArrowLeft");
40
+ await expect(firstTab, "should focus previous tab when pressing arrow left").toBeFocused();
41
+
42
+ await options.page.keyboard.press("End");
43
+ await expect(lastTab, "should focus last tab when pressing End").toBeFocused();
44
+
45
+ await options.page.keyboard.press("ArrowRight");
46
+ await expect(
47
+ firstTab,
48
+ "should focus first tab when last tab is focused and pressing arrow right",
49
+ ).toBeFocused();
50
+
51
+ await options.page.keyboard.press("ArrowRight");
52
+ await expect(secondTab, "should focus next tab when pressing arrow right").toBeFocused();
53
+
54
+ await options.page.keyboard.press("Home");
55
+ await expect(firstTab, "should focus first tab when pressing Home").toBeFocused();
56
+
57
+ await options.page.keyboard.press("ArrowLeft");
58
+
59
+ await expect(
60
+ lastTab,
61
+ "should focus last tab when first tab is focused and pressing arrow left",
62
+ ).toBeFocused();
63
+
64
+ // should select when pressing Enter
65
+ await options.page.keyboard.press("Enter");
66
+ const { tabId: tabIdLast, panelId: panelIdLast } = await expectTabAttributes(lastTab, true);
67
+ await expectPanelAttributes(options.page.locator(`#${panelIdLast}`), tabIdLast);
68
+
69
+ // should select when pressing Space
70
+ await firstTab.focus();
71
+ await options.page.keyboard.press("Space");
72
+ const { tabId: tabIdFirst, panelId: panelIdFirst } = await expectTabAttributes(firstTab, true);
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();
109
+ };
110
+
111
+ /**
112
+ * Executes accessibility tests for a single tab.
113
+ *
114
+ * @param tab Locator of the tab.
115
+ * @param selected Whether the tab is expected to be selected
116
+ */
117
+ const expectTabAttributes = async (tab: Locator, selected: boolean) => {
118
+ await expect(tab, 'tab must have role "tab"').toHaveRole("tab");
119
+ await expect(tab, "tab must have an ID").toHaveAttribute("id");
120
+ await expect(tab, 'tab must have "aria-selected" set').toHaveAttribute(
121
+ "aria-selected",
122
+ String(selected),
123
+ );
124
+ await expect(tab, 'tab must have "aria-controls" set').toHaveAttribute("aria-controls");
125
+
126
+ if (selected) {
127
+ await expect(tab, "selected tab should be focusable").toHaveAttribute("tabindex", "0");
128
+ } else {
129
+ await expect(tab, "unselected tab should NOT be focusable").toHaveAttribute("tabindex", "-1");
130
+ }
131
+
132
+ const tabId = (await tab.getAttribute("id"))!;
133
+ const panelId = (await tab.getAttribute("aria-controls"))!;
134
+ return { tabId, panelId };
135
+ };
136
+
137
+ /**
138
+ * Executes accessibility tests for a single tab panel.
139
+ *
140
+ * @param panel Locator of the panel
141
+ * @param tabId Corresponding tab id
142
+ */
143
+ const expectPanelAttributes = async (panel: Locator, tabId: string) => {
144
+ await expect(panel, "panel should be visible").toBeVisible();
145
+ await expect(panel, 'panel must have role "tabpanel"').toHaveRole("tabpanel");
146
+ await expect(panel, "panel must have an ID").toHaveAttribute("id");
147
+ await expect(panel, 'panel must have "aria-labelledby" set').toHaveAttribute(
148
+ "aria-labelledby",
149
+ tabId,
150
+ );
151
+ };