@sit-onyx/headless 1.0.0-beta.1 → 1.0.0-beta.10
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 +26 -0
- package/src/composables/tabs/createTabs.testing.ts +79 -0
- package/src/composables/tabs/createTabs.ts +72 -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,26 @@
|
|
|
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
|
+
</div>
|
|
22
|
+
|
|
23
|
+
<div v-if="selectedTab === 'tab-1'" v-bind="tabpanel({ value: 'tab-1' })">Tab content 1</div>
|
|
24
|
+
<div v-if="selectedTab === 'tab-2'" v-bind="tabpanel({ value: 'tab-2' })">Tab content 2</div>
|
|
25
|
+
</div>
|
|
26
|
+
</template>
|
|
@@ -0,0 +1,79 @@
|
|
|
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.
|
|
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 activeTab = options.tablist.locator('[aria-selected="true"]');
|
|
23
|
+
await expect(activeTab, "must have an initially active tab").toBeVisible();
|
|
24
|
+
|
|
25
|
+
const { tabId, panelId } = await expectTabAttributes(activeTab, true);
|
|
26
|
+
await expectPanelAttributes(options.page.locator(`#${panelId}`), tabId);
|
|
27
|
+
|
|
28
|
+
// ACT (switch tab)
|
|
29
|
+
let tab2 = options.tablist.locator('[aria-selected="false"]').first();
|
|
30
|
+
tab2 = options.tablist.locator(`#${await tab2.getAttribute("id")}`);
|
|
31
|
+
await tab2.click();
|
|
32
|
+
|
|
33
|
+
const { tabId: tabId2, panelId: panelId2 } = await expectTabAttributes(tab2, true);
|
|
34
|
+
await expectPanelAttributes(options.page.locator(`#${panelId2}`), tabId2);
|
|
35
|
+
|
|
36
|
+
await expect(options.page.getByRole("tabpanel"), "should hide previous panel").toHaveCount(1);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Executes accessibility tests for a single tab.
|
|
41
|
+
*
|
|
42
|
+
* @param tab Locator of the tab.
|
|
43
|
+
* @param selected Whether the tab is expected to be selected
|
|
44
|
+
*/
|
|
45
|
+
const expectTabAttributes = async (tab: Locator, selected: boolean) => {
|
|
46
|
+
await expect(tab, 'tab must have role "tab"').toHaveRole("tab");
|
|
47
|
+
await expect(tab, "tab must have an ID").toHaveAttribute("id");
|
|
48
|
+
await expect(tab, 'tab must have "aria-selected" set').toHaveAttribute(
|
|
49
|
+
"aria-selected",
|
|
50
|
+
String(selected),
|
|
51
|
+
);
|
|
52
|
+
await expect(tab, 'tab must have "aria-controls" set').toHaveAttribute("aria-controls");
|
|
53
|
+
|
|
54
|
+
if (selected) {
|
|
55
|
+
await expect(tab, "selected tab should be focusable").toHaveAttribute("tabindex", "0");
|
|
56
|
+
} else {
|
|
57
|
+
await expect(tab, "unselected tab should NOT be focusable").toHaveAttribute("tabindex", "-1");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const tabId = (await tab.getAttribute("id"))!;
|
|
61
|
+
const panelId = (await tab.getAttribute("aria-controls"))!;
|
|
62
|
+
return { tabId, panelId };
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Executes accessibility tests for a single tab panel.
|
|
67
|
+
*
|
|
68
|
+
* @param panel Locator of the panel
|
|
69
|
+
* @param tabId Corresponding tab id
|
|
70
|
+
*/
|
|
71
|
+
const expectPanelAttributes = async (panel: Locator, tabId: string) => {
|
|
72
|
+
await expect(panel, "panel should be visible").toBeVisible();
|
|
73
|
+
await expect(panel, 'panel must have role "tabpanel"').toHaveRole("tabpanel");
|
|
74
|
+
await expect(panel, "panel must have an ID").toHaveAttribute("id");
|
|
75
|
+
await expect(panel, 'panel must have "aria-labelledby" set').toHaveAttribute(
|
|
76
|
+
"aria-labelledby",
|
|
77
|
+
tabId,
|
|
78
|
+
);
|
|
79
|
+
};
|
|
@@ -0,0 +1,72 @@
|
|
|
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
|
+
return {
|
|
38
|
+
elements: {
|
|
39
|
+
tablist: computed(() => ({
|
|
40
|
+
role: "tablist",
|
|
41
|
+
"aria-label": unref(options.label),
|
|
42
|
+
})),
|
|
43
|
+
tab: computed(() => {
|
|
44
|
+
return (data: { value: T }) => {
|
|
45
|
+
const { tabId: selectedTabId } = getId(unref(options.selectedTab));
|
|
46
|
+
const { tabId, panelId } = getId(data.value);
|
|
47
|
+
const isSelected = tabId === selectedTabId;
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
id: tabId,
|
|
51
|
+
role: "tab",
|
|
52
|
+
"aria-selected": isSelected,
|
|
53
|
+
"aria-controls": panelId,
|
|
54
|
+
onClick: () => options.onSelect?.(data.value),
|
|
55
|
+
tabindex: isSelected ? 0 : -1,
|
|
56
|
+
} as const;
|
|
57
|
+
};
|
|
58
|
+
}),
|
|
59
|
+
tabpanel: computed(() => {
|
|
60
|
+
return (data: { value: T }) => {
|
|
61
|
+
const { tabId, panelId } = getId(data.value);
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
id: panelId,
|
|
65
|
+
role: "tabpanel",
|
|
66
|
+
"aria-labelledby": tabId,
|
|
67
|
+
} as const;
|
|
68
|
+
};
|
|
69
|
+
}),
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { computed, toRef, toValue, type MaybeRefOrGetter, type Ref } from "vue";
|
|
2
|
+
import { createBuilder, createElRef } from "../../utils/builder";
|
|
3
|
+
import { useDismissible } from "../helpers/useDismissible";
|
|
4
|
+
import { useOutsideClick } from "../helpers/useOutsideClick";
|
|
5
|
+
|
|
6
|
+
export type CreateToggletipOptions = {
|
|
7
|
+
toggleLabel: MaybeRefOrGetter<string>;
|
|
8
|
+
isVisible?: Ref<boolean>;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Create a toggletip as described in https://inclusive-components.design/tooltips-toggletips/
|
|
13
|
+
* Its visibility is toggled on click.
|
|
14
|
+
* Therefore a toggletip MUST NOT be used to describe the associated trigger element.
|
|
15
|
+
* Commonly this pattern uses a button with the ⓘ as the trigger element.
|
|
16
|
+
* To describe the associated element use `createTooltip`.
|
|
17
|
+
*/
|
|
18
|
+
export const createToggletip = createBuilder(
|
|
19
|
+
({ toggleLabel, isVisible }: CreateToggletipOptions) => {
|
|
20
|
+
const triggerRef = createElRef<HTMLButtonElement>();
|
|
21
|
+
const tooltipRef = createElRef<HTMLElement>();
|
|
22
|
+
const _isVisible = toRef(isVisible ?? false);
|
|
23
|
+
|
|
24
|
+
// close tooltip on outside click
|
|
25
|
+
useOutsideClick({
|
|
26
|
+
inside: computed(() => [triggerRef.value, tooltipRef.value]),
|
|
27
|
+
onOutsideClick: () => (_isVisible.value = false),
|
|
28
|
+
disabled: computed(() => !_isVisible.value),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
useDismissible({ isExpanded: _isVisible });
|
|
32
|
+
|
|
33
|
+
const toggle = () => (_isVisible.value = !_isVisible.value);
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
elements: {
|
|
37
|
+
/**
|
|
38
|
+
* The element which controls the toggletip visibility:
|
|
39
|
+
* Preferably a `button` element.
|
|
40
|
+
*/
|
|
41
|
+
trigger: computed(() => ({
|
|
42
|
+
ref: triggerRef,
|
|
43
|
+
onClick: toggle,
|
|
44
|
+
"aria-label": toValue(toggleLabel),
|
|
45
|
+
})),
|
|
46
|
+
/**
|
|
47
|
+
* The element with the relevant toggletip content.
|
|
48
|
+
* Only simple, textual content is allowed.
|
|
49
|
+
*/
|
|
50
|
+
tooltip: {
|
|
51
|
+
ref: tooltipRef,
|
|
52
|
+
role: "status",
|
|
53
|
+
tabindex: "-1",
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
state: {
|
|
57
|
+
isVisible: _isVisible,
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
},
|
|
61
|
+
);
|