@sit-onyx/headless 1.0.0-beta.2 → 1.0.0-beta.21
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/README.md +1 -5
- package/package.json +11 -3
- package/src/composables/comboBox/SelectOnlyCombobox.vue +15 -8
- package/src/composables/comboBox/TestCombobox.ct.tsx +1 -1
- package/src/composables/comboBox/TestCombobox.vue +13 -10
- package/src/composables/comboBox/createComboBox.ts +34 -28
- package/src/composables/helpers/useDismissible.ts +19 -0
- package/src/composables/helpers/useGlobalListener.spec.ts +2 -2
- package/src/composables/helpers/useGlobalListener.ts +1 -1
- package/src/composables/helpers/useOutsideClick.spec.ts +117 -0
- package/src/composables/helpers/useOutsideClick.ts +45 -10
- package/src/composables/helpers/useTypeAhead.spec.ts +1 -1
- package/src/composables/helpers/useTypeAhead.ts +2 -2
- package/src/composables/listbox/TestListbox.ct.tsx +1 -1
- package/src/composables/listbox/TestListbox.vue +3 -1
- package/src/composables/listbox/createListbox.ts +28 -10
- package/src/composables/menuButton/TestMenuButton.ct.tsx +1 -1
- package/src/composables/menuButton/TestMenuButton.vue +4 -3
- package/src/composables/menuButton/createMenuButton.testing.ts +0 -19
- package/src/composables/menuButton/createMenuButton.ts +174 -119
- package/src/composables/navigationMenu/TestMenu.ct.tsx +1 -1
- package/src/composables/navigationMenu/TestMenu.vue +1 -1
- package/src/composables/navigationMenu/createMenu.testing.ts +2 -13
- package/src/composables/navigationMenu/createMenu.ts +6 -7
- package/src/composables/tabs/TestTabs.ct.tsx +12 -0
- package/src/composables/tabs/TestTabs.vue +28 -0
- package/src/composables/tabs/createTabs.testing.ts +151 -0
- package/src/composables/tabs/createTabs.ts +129 -0
- package/src/composables/tooltip/createToggletip.ts +58 -0
- package/src/composables/tooltip/createTooltip.ts +39 -97
- package/src/index.ts +11 -8
- package/src/playwright.ts +5 -3
- package/src/utils/builder.ts +108 -12
- package/src/utils/keyboard.spec.ts +1 -1
- package/src/utils/keyboard.ts +1 -1
- package/src/utils/math.spec.ts +1 -1
- package/src/utils/object.spec.ts +1 -1
- package/src/utils/timer.ts +10 -3
- package/src/utils/types.ts +10 -0
- package/src/utils/vitest.ts +2 -2
- package/src/utils/id.ts +0 -14
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { computed, ref, unref, watchEffect, type MaybeRef, type Ref } from "vue";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import { useTypeAhead } from "../helpers/useTypeAhead";
|
|
1
|
+
import { computed, nextTick, ref, unref, useId, watchEffect, type MaybeRef, type Ref } from "vue";
|
|
2
|
+
import { createBuilder, type VBindAttributes } from "../../utils/builder.js";
|
|
3
|
+
import type { Nullable } from "../../utils/types.js";
|
|
4
|
+
import { useTypeAhead } from "../helpers/useTypeAhead.js";
|
|
5
5
|
|
|
6
6
|
export type ListboxValue = string | number | boolean;
|
|
7
7
|
|
|
@@ -10,19 +10,27 @@ export type CreateListboxOptions<TValue extends ListboxValue, TMultiple extends
|
|
|
10
10
|
* Aria label for the listbox.
|
|
11
11
|
*/
|
|
12
12
|
label: MaybeRef<string>;
|
|
13
|
+
/**
|
|
14
|
+
* Aria description for the listbox.
|
|
15
|
+
*/
|
|
16
|
+
description?: MaybeRef<Nullable<string>>;
|
|
13
17
|
/**
|
|
14
18
|
* Value of currently (visually) active option.
|
|
15
19
|
*/
|
|
16
|
-
activeOption: Ref<TValue
|
|
20
|
+
activeOption: Ref<Nullable<TValue>>;
|
|
17
21
|
/**
|
|
18
22
|
* Wether the listbox is controlled from the outside, e.g. by a combobox.
|
|
19
23
|
* This disables keyboard events and makes the listbox not focusable.
|
|
20
24
|
*/
|
|
21
25
|
controlled?: boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Controls the opened/visible state of the listbox. When expanded the activeOption can be controlled via the keyboard.
|
|
28
|
+
*/
|
|
29
|
+
isExpanded?: MaybeRef<boolean>;
|
|
22
30
|
/**
|
|
23
31
|
* Whether the listbox is multiselect.
|
|
24
32
|
*/
|
|
25
|
-
multiple?: MaybeRef<TMultiple
|
|
33
|
+
multiple?: MaybeRef<Nullable<TMultiple>>;
|
|
26
34
|
/**
|
|
27
35
|
* Hook when an option is selected.
|
|
28
36
|
*/
|
|
@@ -77,6 +85,7 @@ export const createListbox = createBuilder(
|
|
|
77
85
|
options: CreateListboxOptions<TValue, TMultiple>,
|
|
78
86
|
) => {
|
|
79
87
|
const isMultiselect = computed(() => unref(options.multiple) ?? false);
|
|
88
|
+
const isExpanded = computed(() => unref(options.isExpanded) ?? false);
|
|
80
89
|
|
|
81
90
|
/**
|
|
82
91
|
* Map for option IDs. key = option value, key = ID for the HTML element
|
|
@@ -85,7 +94,7 @@ export const createListbox = createBuilder(
|
|
|
85
94
|
|
|
86
95
|
const getOptionId = (value: TValue) => {
|
|
87
96
|
if (!descendantKeyIdMap.has(value)) {
|
|
88
|
-
descendantKeyIdMap.set(value,
|
|
97
|
+
descendantKeyIdMap.set(value, useId());
|
|
89
98
|
}
|
|
90
99
|
return descendantKeyIdMap.get(value)!;
|
|
91
100
|
};
|
|
@@ -96,10 +105,17 @@ export const createListbox = createBuilder(
|
|
|
96
105
|
const isFocused = ref(false);
|
|
97
106
|
|
|
98
107
|
// scroll currently active option into view if needed
|
|
99
|
-
watchEffect(() => {
|
|
100
|
-
if (
|
|
108
|
+
watchEffect(async () => {
|
|
109
|
+
if (
|
|
110
|
+
!isExpanded.value ||
|
|
111
|
+
options.activeOption.value == undefined ||
|
|
112
|
+
(!isFocused.value && !options.controlled)
|
|
113
|
+
) {
|
|
101
114
|
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
102
117
|
const id = getOptionId(options.activeOption.value);
|
|
118
|
+
await nextTick();
|
|
103
119
|
document.getElementById(id)?.scrollIntoView({ block: "nearest", inline: "nearest" });
|
|
104
120
|
});
|
|
105
121
|
|
|
@@ -152,18 +168,20 @@ export const createListbox = createBuilder(
|
|
|
152
168
|
}
|
|
153
169
|
};
|
|
154
170
|
|
|
155
|
-
const listbox = computed<
|
|
171
|
+
const listbox = computed<VBindAttributes>(() =>
|
|
156
172
|
options.controlled
|
|
157
173
|
? {
|
|
158
174
|
role: "listbox",
|
|
159
175
|
"aria-multiselectable": isMultiselect.value,
|
|
160
176
|
"aria-label": unref(options.label),
|
|
177
|
+
"aria-description": options.description,
|
|
161
178
|
tabindex: "-1",
|
|
162
179
|
}
|
|
163
180
|
: {
|
|
164
181
|
role: "listbox",
|
|
165
182
|
"aria-multiselectable": isMultiselect.value,
|
|
166
183
|
"aria-label": unref(options.label),
|
|
184
|
+
"aria-description": options.description,
|
|
167
185
|
tabindex: "0",
|
|
168
186
|
"aria-activedescendant":
|
|
169
187
|
options.activeOption.value != undefined
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { test } from "@playwright/experimental-ct-vue";
|
|
2
|
-
import { menuButtonTesting } from "./createMenuButton.testing";
|
|
2
|
+
import { menuButtonTesting } from "./createMenuButton.testing.js";
|
|
3
3
|
import TestMenuButton from "./TestMenuButton.vue";
|
|
4
4
|
|
|
5
5
|
test("menuButton", async ({ mount, page }) => {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<script lang="ts" setup>
|
|
2
2
|
import { ref } from "vue";
|
|
3
|
-
import { createMenuButton } from "./createMenuButton";
|
|
3
|
+
import { createMenuButton } from "./createMenuButton.js";
|
|
4
4
|
|
|
5
5
|
const items = Array.from({ length: 10 }, (_, index) => {
|
|
6
6
|
const id = index + 1;
|
|
@@ -10,15 +10,16 @@ const items = Array.from({ length: 10 }, (_, index) => {
|
|
|
10
10
|
const activeItem = ref<string>();
|
|
11
11
|
const isExpanded = ref(false);
|
|
12
12
|
const onToggle = () => (isExpanded.value = !isExpanded.value);
|
|
13
|
+
const trigger = ref<"click" | "hover">("hover");
|
|
13
14
|
|
|
14
15
|
const {
|
|
15
16
|
elements: { root, button, menu, menuItem, listItem },
|
|
16
|
-
} = createMenuButton({ isExpanded, onToggle });
|
|
17
|
+
} = createMenuButton({ isExpanded, onToggle, trigger });
|
|
17
18
|
</script>
|
|
18
19
|
|
|
19
20
|
<template>
|
|
20
21
|
<div v-bind="root">
|
|
21
|
-
<button v-bind="button">Toggle nav menu</button>
|
|
22
|
+
<button v-bind="button" type="button">Toggle nav menu</button>
|
|
22
23
|
<ul v-show="isExpanded" v-bind="menu">
|
|
23
24
|
<li v-for="item in items" v-bind="listItem" :key="item.value">
|
|
24
25
|
<a v-bind="menuItem({ active: activeItem === item.value })" href="#">{{ item.label }}</a>
|
|
@@ -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,
|
|
2
|
-
import { createBuilder } from "../../utils/builder";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
1
|
+
import { computed, toValue, useId, watch, type MaybeRef, type Ref } from "vue";
|
|
2
|
+
import { createBuilder, createElRef } from "../../utils/builder.js";
|
|
3
|
+
import { debounce } from "../../utils/timer.js";
|
|
4
|
+
import { useGlobalEventListener } from "../helpers/useGlobalListener.js";
|
|
5
|
+
import { useOutsideClick } from "../helpers/useOutsideClick.js";
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
66
|
-
nextMenuItem?.focus();
|
|
67
|
-
};
|
|
30
|
+
const position = computed(() => toValue(options.position) ?? "bottom");
|
|
68
31
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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 "
|
|
86
|
-
|
|
87
|
-
focusRelativeItem("last");
|
|
77
|
+
case "prev":
|
|
78
|
+
nextIndex = currentIndex - 1;
|
|
88
79
|
break;
|
|
89
|
-
case "
|
|
90
|
-
|
|
91
|
-
(event.target as HTMLElement).click();
|
|
80
|
+
case "first":
|
|
81
|
+
nextIndex = 0;
|
|
92
82
|
break;
|
|
93
|
-
case "
|
|
94
|
-
|
|
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
|
-
|
|
102
|
-
|
|
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
|
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { test } from "@playwright/experimental-ct-vue";
|
|
2
|
-
import { navigationTesting } from "./createMenu.testing";
|
|
2
|
+
import { navigationTesting } from "./createMenu.testing.js";
|
|
3
3
|
import TestMenu from "./TestMenu.vue";
|
|
4
4
|
|
|
5
5
|
test("navigationMenu", async ({ mount, page }) => {
|
|
@@ -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
|
|
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,7 +1,6 @@
|
|
|
1
|
-
import { unref, type MaybeRef } from "vue";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import { MathUtils } from "../../utils/math";
|
|
1
|
+
import { unref, useId, type MaybeRef } from "vue";
|
|
2
|
+
import { createBuilder } from "../../utils/builder.js";
|
|
3
|
+
import { MathUtils } from "../../utils/math.js";
|
|
5
4
|
|
|
6
5
|
type CreateNavigationMenu = {
|
|
7
6
|
/**
|
|
@@ -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 =
|
|
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
|
|
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.js";
|
|
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.js";
|
|
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>
|