@sit-onyx/headless 1.0.0-beta.1 → 1.0.0-beta.11
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 +6 -2
- package/src/composables/comboBox/SelectOnlyCombobox.vue +6 -2
- package/src/composables/comboBox/TestCombobox.vue +9 -6
- package/src/composables/comboBox/createComboBox.ts +18 -13
- package/src/composables/helpers/useDismissible.ts +19 -0
- package/src/composables/helpers/useOutsideClick.spec.ts +83 -0
- package/src/composables/helpers/useOutsideClick.ts +13 -7
- package/src/composables/listbox/TestListbox.vue +2 -0
- package/src/composables/listbox/createListbox.ts +25 -8
- package/src/composables/menuButton/TestMenuButton.ct.tsx +1 -1
- package/src/composables/menuButton/TestMenuButton.vue +7 -6
- package/src/composables/menuButton/createMenuButton.testing.ts +17 -16
- package/src/composables/menuButton/createMenuButton.ts +121 -101
- package/src/composables/navigationMenu/TestMenu.ct.tsx +12 -0
- package/src/composables/navigationMenu/TestMenu.vue +16 -0
- package/src/composables/navigationMenu/createMenu.testing.ts +37 -0
- package/src/composables/navigationMenu/createMenu.ts +55 -0
- package/src/composables/tabs/TestTabs.ct.tsx +13 -0
- package/src/composables/tabs/TestTabs.vue +28 -0
- package/src/composables/tabs/createTabs.testing.ts +116 -0
- package/src/composables/tabs/createTabs.ts +117 -0
- package/src/composables/tooltip/createToggletip.ts +61 -0
- package/src/composables/tooltip/createTooltip.ts +37 -96
- package/src/index.ts +5 -1
- package/src/playwright.ts +2 -0
- package/src/utils/builder.ts +107 -11
- package/src/utils/types.ts +7 -0
- package/src/utils/vitest.ts +2 -2
- package/src/utils/id.ts +0 -14
|
@@ -1,123 +1,143 @@
|
|
|
1
|
-
import { computed,
|
|
2
|
-
import { createBuilder } from "../../utils/builder";
|
|
3
|
-
import { createId } from "../../utils/id";
|
|
1
|
+
import { computed, useId, type Ref } from "vue";
|
|
2
|
+
import { createBuilder, createElRef } from "../../utils/builder";
|
|
4
3
|
import { debounce } from "../../utils/timer";
|
|
4
|
+
import { useGlobalEventListener } from "../helpers/useGlobalListener";
|
|
5
|
+
|
|
6
|
+
type CreateMenuButtonOptions = {
|
|
7
|
+
isExpanded: Ref<boolean>;
|
|
8
|
+
onToggle: () => void;
|
|
9
|
+
};
|
|
5
10
|
|
|
6
11
|
/**
|
|
7
12
|
* Based on https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/examples/disclosure-navigation/
|
|
8
13
|
*/
|
|
9
|
-
export const createMenuButton = createBuilder(
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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();
|
|
13
20
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
200,
|
|
20
|
-
);
|
|
21
|
+
useGlobalEventListener({
|
|
22
|
+
type: "keydown",
|
|
23
|
+
listener: (e) => e.key === "Escape" && isExpanded.value && onToggle(),
|
|
24
|
+
disabled: computed(() => !isExpanded.value),
|
|
25
|
+
});
|
|
21
26
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
});
|
|
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
|
+
);
|
|
30
34
|
|
|
31
|
-
|
|
32
|
-
|
|
35
|
+
const focusRelativeItem = (next: "next" | "prev" | "first" | "last") => {
|
|
36
|
+
const currentMenuItem = document.activeElement as HTMLElement;
|
|
33
37
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
if (!currentMenu) return;
|
|
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;
|
|
39
42
|
|
|
40
|
-
|
|
41
|
-
|
|
43
|
+
const menuItems = [...currentMenu.querySelectorAll<HTMLElement>('[role="menuitem"]')];
|
|
44
|
+
let nextIndex = 0;
|
|
42
45
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
+
}
|
|
63
|
+
|
|
64
|
+
const nextMenuItem = menuItems[nextIndex];
|
|
65
|
+
nextMenuItem?.focus();
|
|
66
|
+
};
|
|
67
|
+
|
|
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");
|
|
79
|
+
break;
|
|
80
|
+
case "Home":
|
|
81
|
+
event.preventDefault();
|
|
82
|
+
focusRelativeItem("first");
|
|
48
83
|
break;
|
|
49
|
-
case "
|
|
50
|
-
|
|
84
|
+
case "End":
|
|
85
|
+
event.preventDefault();
|
|
86
|
+
focusRelativeItem("last");
|
|
51
87
|
break;
|
|
52
|
-
case "
|
|
53
|
-
|
|
88
|
+
case " ":
|
|
89
|
+
event.preventDefault();
|
|
90
|
+
(event.target as HTMLElement).click();
|
|
54
91
|
break;
|
|
55
|
-
case "
|
|
56
|
-
|
|
92
|
+
case "Escape":
|
|
93
|
+
event.preventDefault();
|
|
94
|
+
isExpanded.value && onToggle();
|
|
57
95
|
break;
|
|
58
96
|
}
|
|
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
|
-
};
|
|
97
|
+
};
|
|
91
98
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
(
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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(),
|
|
133
|
+
},
|
|
134
|
+
...createMenuItems().elements,
|
|
114
135
|
},
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
});
|
|
136
|
+
};
|
|
137
|
+
},
|
|
138
|
+
);
|
|
119
139
|
|
|
120
|
-
export const
|
|
140
|
+
export const createMenuItems = createBuilder(() => {
|
|
121
141
|
return {
|
|
122
142
|
elements: {
|
|
123
143
|
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,55 @@
|
|
|
1
|
+
import { unref, useId, type MaybeRef } from "vue";
|
|
2
|
+
import { createBuilder } from "../../utils/builder";
|
|
3
|
+
import { MathUtils } from "../../utils/math";
|
|
4
|
+
|
|
5
|
+
type CreateNavigationMenu = {
|
|
6
|
+
/**
|
|
7
|
+
* Name of the navigation landmark.
|
|
8
|
+
* Usually this is the name of the website.
|
|
9
|
+
*/
|
|
10
|
+
navigationName?: MaybeRef<string | undefined>;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Based on https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/examples/disclosure-navigation/
|
|
15
|
+
*/
|
|
16
|
+
export const createNavigationMenu = createBuilder(({ navigationName }: CreateNavigationMenu) => {
|
|
17
|
+
const navId = useId();
|
|
18
|
+
|
|
19
|
+
const getMenuButtons = () => {
|
|
20
|
+
const nav = navId ? document.getElementById(navId) : undefined;
|
|
21
|
+
if (!nav) return [];
|
|
22
|
+
return [...nav.querySelectorAll<HTMLElement>("button[aria-expanded][aria-controls]")];
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const focusRelative = (trigger: HTMLElement, next: "next" | "previous") => {
|
|
26
|
+
const menuButtons = getMenuButtons();
|
|
27
|
+
const index = menuButtons.indexOf(trigger);
|
|
28
|
+
if (index === -1) return;
|
|
29
|
+
const nextIndex = MathUtils.clamp(
|
|
30
|
+
index + (next === "next" ? 1 : -1),
|
|
31
|
+
0,
|
|
32
|
+
menuButtons.length - 1,
|
|
33
|
+
);
|
|
34
|
+
menuButtons[nextIndex].focus();
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
elements: {
|
|
39
|
+
nav: {
|
|
40
|
+
"aria-label": unref(navigationName),
|
|
41
|
+
id: navId,
|
|
42
|
+
onKeydown: (event) => {
|
|
43
|
+
switch (event.key) {
|
|
44
|
+
case "ArrowRight":
|
|
45
|
+
focusRelative(event.target as HTMLElement, "next");
|
|
46
|
+
break;
|
|
47
|
+
case "ArrowLeft":
|
|
48
|
+
focusRelative(event.target as HTMLElement, "previous");
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { test } from "@playwright/experimental-ct-vue";
|
|
2
|
+
import TestTabs from "./TestTabs.vue";
|
|
3
|
+
import { tabsTesting } from "./createTabs.testing";
|
|
4
|
+
|
|
5
|
+
// eslint-disable-next-line playwright/expect-expect
|
|
6
|
+
test("tabs", async ({ mount, page }) => {
|
|
7
|
+
const component = await mount(<TestTabs />);
|
|
8
|
+
|
|
9
|
+
await tabsTesting({
|
|
10
|
+
page,
|
|
11
|
+
tablist: component.getByRole("tablist"),
|
|
12
|
+
});
|
|
13
|
+
});
|
|
@@ -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,116 @@
|
|
|
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
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Executes accessibility tests for a single tab.
|
|
78
|
+
*
|
|
79
|
+
* @param tab Locator of the tab.
|
|
80
|
+
* @param selected Whether the tab is expected to be selected
|
|
81
|
+
*/
|
|
82
|
+
const expectTabAttributes = async (tab: Locator, selected: boolean) => {
|
|
83
|
+
await expect(tab, 'tab must have role "tab"').toHaveRole("tab");
|
|
84
|
+
await expect(tab, "tab must have an ID").toHaveAttribute("id");
|
|
85
|
+
await expect(tab, 'tab must have "aria-selected" set').toHaveAttribute(
|
|
86
|
+
"aria-selected",
|
|
87
|
+
String(selected),
|
|
88
|
+
);
|
|
89
|
+
await expect(tab, 'tab must have "aria-controls" set').toHaveAttribute("aria-controls");
|
|
90
|
+
|
|
91
|
+
if (selected) {
|
|
92
|
+
await expect(tab, "selected tab should be focusable").toHaveAttribute("tabindex", "0");
|
|
93
|
+
} else {
|
|
94
|
+
await expect(tab, "unselected tab should NOT be focusable").toHaveAttribute("tabindex", "-1");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const tabId = (await tab.getAttribute("id"))!;
|
|
98
|
+
const panelId = (await tab.getAttribute("aria-controls"))!;
|
|
99
|
+
return { tabId, panelId };
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Executes accessibility tests for a single tab panel.
|
|
104
|
+
*
|
|
105
|
+
* @param panel Locator of the panel
|
|
106
|
+
* @param tabId Corresponding tab id
|
|
107
|
+
*/
|
|
108
|
+
const expectPanelAttributes = async (panel: Locator, tabId: string) => {
|
|
109
|
+
await expect(panel, "panel should be visible").toBeVisible();
|
|
110
|
+
await expect(panel, 'panel must have role "tabpanel"').toHaveRole("tabpanel");
|
|
111
|
+
await expect(panel, "panel must have an ID").toHaveAttribute("id");
|
|
112
|
+
await expect(panel, 'panel must have "aria-labelledby" set').toHaveAttribute(
|
|
113
|
+
"aria-labelledby",
|
|
114
|
+
tabId,
|
|
115
|
+
);
|
|
116
|
+
};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { computed, unref, useId, type MaybeRef, type Ref } from "vue";
|
|
2
|
+
import { createBuilder } from "../../utils/builder";
|
|
3
|
+
|
|
4
|
+
type CreateTabsOptions<TKey extends PropertyKey = PropertyKey> = {
|
|
5
|
+
/**
|
|
6
|
+
* Label of the tablist.
|
|
7
|
+
*/
|
|
8
|
+
label: MaybeRef<string>;
|
|
9
|
+
/**
|
|
10
|
+
* Currently selected tab.
|
|
11
|
+
*/
|
|
12
|
+
selectedTab: Ref<TKey>;
|
|
13
|
+
/**
|
|
14
|
+
* Called when the user selects a tab.
|
|
15
|
+
*/
|
|
16
|
+
onSelect?: (selectedTabValue: TKey) => void;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Composable for implementing accessible tabs.
|
|
21
|
+
* Based on https://www.w3.org/WAI/ARIA/apg/patterns/tabs/
|
|
22
|
+
*/
|
|
23
|
+
export const createTabs = createBuilder(<T extends PropertyKey>(options: CreateTabsOptions<T>) => {
|
|
24
|
+
/**
|
|
25
|
+
* Map for looking up tab and panel IDs for given tab keys/values defined by the user.
|
|
26
|
+
* Key = custom value from the user, value = random generated tab and panel ID
|
|
27
|
+
*/
|
|
28
|
+
const idMap = new Map<T, { tabId: string; panelId: string }>();
|
|
29
|
+
|
|
30
|
+
const getId = (value: T) => {
|
|
31
|
+
if (!idMap.has(value)) {
|
|
32
|
+
idMap.set(value, { tabId: useId(), panelId: useId() });
|
|
33
|
+
}
|
|
34
|
+
return idMap.get(value)!;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const handleKeydown = (event: KeyboardEvent) => {
|
|
38
|
+
const tab = event.target as Element;
|
|
39
|
+
|
|
40
|
+
const focusFirstTab = () => {
|
|
41
|
+
const element = tab.parentElement?.querySelector('[role="tab"]');
|
|
42
|
+
if (element instanceof HTMLElement) element.focus();
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const focusLastTab = () => {
|
|
46
|
+
const element = Array.from(tab.parentElement?.querySelectorAll('[role="tab"]') ?? []).at(-1);
|
|
47
|
+
if (element instanceof HTMLElement) element.focus();
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
switch (event.key) {
|
|
51
|
+
case "ArrowRight":
|
|
52
|
+
if (tab.nextElementSibling && tab.nextElementSibling instanceof HTMLElement) {
|
|
53
|
+
tab.nextElementSibling.focus();
|
|
54
|
+
} else {
|
|
55
|
+
focusFirstTab();
|
|
56
|
+
}
|
|
57
|
+
break;
|
|
58
|
+
case "ArrowLeft":
|
|
59
|
+
if (tab.previousElementSibling && tab.previousElementSibling instanceof HTMLElement) {
|
|
60
|
+
tab.previousElementSibling.focus();
|
|
61
|
+
} else {
|
|
62
|
+
focusLastTab();
|
|
63
|
+
}
|
|
64
|
+
break;
|
|
65
|
+
case "Home":
|
|
66
|
+
focusFirstTab();
|
|
67
|
+
break;
|
|
68
|
+
case "End":
|
|
69
|
+
focusLastTab();
|
|
70
|
+
break;
|
|
71
|
+
case "Enter":
|
|
72
|
+
case " ":
|
|
73
|
+
{
|
|
74
|
+
const tabEntry = Array.from(idMap.entries()).find(([, { tabId }]) => tabId === tab.id);
|
|
75
|
+
if (tabEntry) options.onSelect?.(tabEntry[0]);
|
|
76
|
+
}
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
elements: {
|
|
83
|
+
tablist: computed(() => ({
|
|
84
|
+
role: "tablist",
|
|
85
|
+
"aria-label": unref(options.label),
|
|
86
|
+
onKeydown: handleKeydown,
|
|
87
|
+
})),
|
|
88
|
+
tab: computed(() => {
|
|
89
|
+
return (data: { value: T }) => {
|
|
90
|
+
const { tabId: selectedTabId } = getId(unref(options.selectedTab));
|
|
91
|
+
const { tabId, panelId } = getId(data.value);
|
|
92
|
+
const isSelected = tabId === selectedTabId;
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
id: tabId,
|
|
96
|
+
role: "tab",
|
|
97
|
+
"aria-selected": isSelected,
|
|
98
|
+
"aria-controls": panelId,
|
|
99
|
+
onClick: () => options.onSelect?.(data.value),
|
|
100
|
+
tabindex: isSelected ? 0 : -1,
|
|
101
|
+
} as const;
|
|
102
|
+
};
|
|
103
|
+
}),
|
|
104
|
+
tabpanel: computed(() => {
|
|
105
|
+
return (data: { value: T }) => {
|
|
106
|
+
const { tabId, panelId } = getId(data.value);
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
id: panelId,
|
|
110
|
+
role: "tabpanel",
|
|
111
|
+
"aria-labelledby": tabId,
|
|
112
|
+
} as const;
|
|
113
|
+
};
|
|
114
|
+
}),
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
});
|