@sit-onyx/headless 1.0.0-beta.21 → 1.0.0-beta.23
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/dist/composables/comboBox/SelectOnlyCombobox.d.vue.ts +299 -0
- package/dist/composables/comboBox/TestCombobox.ct.d.ts +1 -0
- package/dist/composables/comboBox/TestCombobox.d.vue.ts +299 -0
- package/dist/composables/comboBox/createComboBox.d.ts +370 -0
- package/dist/composables/comboBox/createComboBox.testing.d.ts +10 -0
- package/dist/composables/helpers/useDismissible.d.ts +10 -0
- package/dist/composables/helpers/useGlobalListener.d.ts +10 -0
- package/dist/composables/helpers/useGlobalListener.spec.d.ts +1 -0
- package/dist/composables/helpers/useOutsideClick.d.ts +26 -0
- package/dist/composables/helpers/useOutsideClick.spec.d.ts +1 -0
- package/dist/composables/helpers/useTypeAhead.d.ts +11 -0
- package/dist/composables/helpers/useTypeAhead.spec.d.ts +1 -0
- package/dist/composables/listbox/TestListbox.ct.d.ts +1 -0
- package/dist/composables/listbox/TestListbox.d.vue.ts +2 -0
- package/dist/composables/listbox/createListbox.d.ts +102 -0
- package/dist/composables/listbox/createListbox.testing.d.ts +24 -0
- package/dist/composables/menuButton/TestMenuButton.ct.d.ts +1 -0
- package/dist/composables/menuButton/TestMenuButton.d.vue.ts +2 -0
- package/dist/composables/menuButton/createMenuButton.d.ts +78 -0
- package/dist/composables/menuButton/createMenuButton.testing.d.ts +24 -0
- package/dist/composables/navigationMenu/TestMenu.ct.d.ts +1 -0
- package/dist/composables/navigationMenu/TestMenu.d.vue.ts +2 -0
- package/dist/composables/navigationMenu/createMenu.d.ts +21 -0
- package/dist/composables/navigationMenu/createMenu.testing.d.ts +16 -0
- package/dist/composables/tabs/TestTabs.ct.d.ts +1 -0
- package/dist/composables/tabs/TestTabs.d.vue.ts +2 -0
- package/dist/composables/tabs/createTabs.d.ts +48 -0
- package/dist/composables/tabs/createTabs.testing.d.ts +13 -0
- package/dist/composables/tooltip/createToggletip.d.ts +36 -0
- package/dist/composables/tooltip/createTooltip.d.ts +42 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +1089 -0
- package/dist/playwright.d.ts +5 -0
- package/dist/playwright.js +369 -0
- package/dist/utils/builder.d.ts +85 -0
- package/dist/utils/keyboard.d.ts +26 -0
- package/dist/utils/keyboard.spec.d.ts +1 -0
- package/dist/utils/math.d.ts +6 -0
- package/dist/utils/math.spec.d.ts +1 -0
- package/dist/utils/object.d.ts +5 -0
- package/dist/utils/object.spec.d.ts +1 -0
- package/dist/utils/timer.d.ts +10 -0
- package/{src/utils/types.ts → dist/utils/types.d.ts} +4 -12
- package/dist/utils/vitest.d.ts +12 -0
- package/package.json +18 -9
- package/src/composables/comboBox/SelectOnlyCombobox.vue +0 -90
- package/src/composables/comboBox/TestCombobox.ct.tsx +0 -24
- package/src/composables/comboBox/TestCombobox.vue +0 -84
- package/src/composables/comboBox/createComboBox.testing.ts +0 -168
- package/src/composables/comboBox/createComboBox.ts +0 -280
- package/src/composables/helpers/useDismissible.ts +0 -19
- package/src/composables/helpers/useGlobalListener.spec.ts +0 -93
- package/src/composables/helpers/useGlobalListener.ts +0 -64
- package/src/composables/helpers/useOutsideClick.spec.ts +0 -117
- package/src/composables/helpers/useOutsideClick.ts +0 -69
- package/src/composables/helpers/useTypeAhead.spec.ts +0 -29
- package/src/composables/helpers/useTypeAhead.ts +0 -26
- package/src/composables/listbox/TestListbox.ct.tsx +0 -17
- package/src/composables/listbox/TestListbox.vue +0 -92
- package/src/composables/listbox/createListbox.testing.ts +0 -141
- package/src/composables/listbox/createListbox.ts +0 -234
- package/src/composables/menuButton/TestMenuButton.ct.tsx +0 -14
- package/src/composables/menuButton/TestMenuButton.vue +0 -29
- package/src/composables/menuButton/createMenuButton.testing.ts +0 -91
- package/src/composables/menuButton/createMenuButton.ts +0 -206
- package/src/composables/navigationMenu/TestMenu.ct.tsx +0 -12
- package/src/composables/navigationMenu/TestMenu.vue +0 -16
- package/src/composables/navigationMenu/createMenu.testing.ts +0 -37
- package/src/composables/navigationMenu/createMenu.ts +0 -55
- package/src/composables/tabs/TestTabs.ct.tsx +0 -12
- package/src/composables/tabs/TestTabs.vue +0 -28
- package/src/composables/tabs/createTabs.testing.ts +0 -151
- package/src/composables/tabs/createTabs.ts +0 -129
- package/src/composables/tooltip/createToggletip.ts +0 -58
- package/src/composables/tooltip/createTooltip.ts +0 -71
- package/src/index.ts +0 -11
- package/src/playwright.ts +0 -5
- package/src/utils/builder.ts +0 -135
- package/src/utils/keyboard.spec.ts +0 -53
- package/src/utils/keyboard.ts +0 -351
- package/src/utils/math.spec.ts +0 -14
- package/src/utils/math.ts +0 -6
- package/src/utils/object.spec.ts +0 -33
- package/src/utils/object.ts +0 -8
- package/src/utils/timer.ts +0 -22
- package/src/utils/vitest.ts +0 -36
|
@@ -1,234 +0,0 @@
|
|
|
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
|
-
|
|
6
|
-
export type ListboxValue = string | number | boolean;
|
|
7
|
-
|
|
8
|
-
export type CreateListboxOptions<TValue extends ListboxValue, TMultiple extends boolean = false> = {
|
|
9
|
-
/**
|
|
10
|
-
* Aria label for the listbox.
|
|
11
|
-
*/
|
|
12
|
-
label: MaybeRef<string>;
|
|
13
|
-
/**
|
|
14
|
-
* Aria description for the listbox.
|
|
15
|
-
*/
|
|
16
|
-
description?: MaybeRef<Nullable<string>>;
|
|
17
|
-
/**
|
|
18
|
-
* Value of currently (visually) active option.
|
|
19
|
-
*/
|
|
20
|
-
activeOption: Ref<Nullable<TValue>>;
|
|
21
|
-
/**
|
|
22
|
-
* Wether the listbox is controlled from the outside, e.g. by a combobox.
|
|
23
|
-
* This disables keyboard events and makes the listbox not focusable.
|
|
24
|
-
*/
|
|
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>;
|
|
30
|
-
/**
|
|
31
|
-
* Whether the listbox is multiselect.
|
|
32
|
-
*/
|
|
33
|
-
multiple?: MaybeRef<Nullable<TMultiple>>;
|
|
34
|
-
/**
|
|
35
|
-
* Hook when an option is selected.
|
|
36
|
-
*/
|
|
37
|
-
onSelect?: (value: TValue) => void;
|
|
38
|
-
/**
|
|
39
|
-
* Hook when the first option should be activated.
|
|
40
|
-
*/
|
|
41
|
-
onActivateFirst?: () => void;
|
|
42
|
-
/**
|
|
43
|
-
* Hook when the last option should be activated.
|
|
44
|
-
*/
|
|
45
|
-
onActivateLast?: () => void;
|
|
46
|
-
/**
|
|
47
|
-
* Hook when the next option should be activated.
|
|
48
|
-
*/
|
|
49
|
-
onActivateNext?: (currentValue: TValue) => void;
|
|
50
|
-
/**
|
|
51
|
-
* Hook when the previous option should be activated.
|
|
52
|
-
*/
|
|
53
|
-
onActivatePrevious?: (currentValue: TValue) => void;
|
|
54
|
-
/**
|
|
55
|
-
* Hook when the first option starting with the given label should be activated.
|
|
56
|
-
*/
|
|
57
|
-
onTypeAhead?: (key: string) => void;
|
|
58
|
-
} & (
|
|
59
|
-
| {
|
|
60
|
-
/**
|
|
61
|
-
* Optional aria label for the listbox.
|
|
62
|
-
*/
|
|
63
|
-
label?: MaybeRef<string>;
|
|
64
|
-
/**
|
|
65
|
-
* Wether the listbox is controlled from the outside, e.g. by a combobox.
|
|
66
|
-
* This disables keyboard events and makes the listbox not focusable.
|
|
67
|
-
*/
|
|
68
|
-
controlled: true;
|
|
69
|
-
}
|
|
70
|
-
| {
|
|
71
|
-
/**
|
|
72
|
-
* Aria label for the listbox.
|
|
73
|
-
*/
|
|
74
|
-
label: MaybeRef<string>;
|
|
75
|
-
controlled?: false;
|
|
76
|
-
}
|
|
77
|
-
);
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Composable for creating a accessibility-conform listbox.
|
|
81
|
-
* For supported keyboard shortcuts, see: https://www.w3.org/WAI/ARIA/apg/patterns/listbox/examples/listbox-scrollable/
|
|
82
|
-
*/
|
|
83
|
-
export const createListbox = createBuilder(
|
|
84
|
-
<TValue extends ListboxValue, TMultiple extends boolean = false>(
|
|
85
|
-
options: CreateListboxOptions<TValue, TMultiple>,
|
|
86
|
-
) => {
|
|
87
|
-
const isMultiselect = computed(() => unref(options.multiple) ?? false);
|
|
88
|
-
const isExpanded = computed(() => unref(options.isExpanded) ?? false);
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* Map for option IDs. key = option value, key = ID for the HTML element
|
|
92
|
-
*/
|
|
93
|
-
const descendantKeyIdMap = new Map<TValue, string>();
|
|
94
|
-
|
|
95
|
-
const getOptionId = (value: TValue) => {
|
|
96
|
-
if (!descendantKeyIdMap.has(value)) {
|
|
97
|
-
descendantKeyIdMap.set(value, useId());
|
|
98
|
-
}
|
|
99
|
-
return descendantKeyIdMap.get(value)!;
|
|
100
|
-
};
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* Whether the listbox element is focused.
|
|
104
|
-
*/
|
|
105
|
-
const isFocused = ref(false);
|
|
106
|
-
|
|
107
|
-
// scroll currently active option into view if needed
|
|
108
|
-
watchEffect(async () => {
|
|
109
|
-
if (
|
|
110
|
-
!isExpanded.value ||
|
|
111
|
-
options.activeOption.value == undefined ||
|
|
112
|
-
(!isFocused.value && !options.controlled)
|
|
113
|
-
) {
|
|
114
|
-
return;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
const id = getOptionId(options.activeOption.value);
|
|
118
|
-
await nextTick();
|
|
119
|
-
document.getElementById(id)?.scrollIntoView({ block: "nearest", inline: "nearest" });
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
const typeAhead = useTypeAhead((inputString) => options.onTypeAhead?.(inputString));
|
|
123
|
-
|
|
124
|
-
const handleKeydown = (event: KeyboardEvent) => {
|
|
125
|
-
switch (event.key) {
|
|
126
|
-
case " ":
|
|
127
|
-
event.preventDefault();
|
|
128
|
-
if (options.activeOption.value != undefined) {
|
|
129
|
-
options.onSelect?.(options.activeOption.value);
|
|
130
|
-
}
|
|
131
|
-
break;
|
|
132
|
-
|
|
133
|
-
case "ArrowUp":
|
|
134
|
-
event.preventDefault();
|
|
135
|
-
// if no option is active yet, activate the last option
|
|
136
|
-
if (options.activeOption.value == undefined) {
|
|
137
|
-
options.onActivateLast?.();
|
|
138
|
-
return;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
options.onActivatePrevious?.(options.activeOption.value);
|
|
142
|
-
break;
|
|
143
|
-
|
|
144
|
-
case "ArrowDown":
|
|
145
|
-
event.preventDefault();
|
|
146
|
-
// if no option is active yet, activate the first option
|
|
147
|
-
if (options.activeOption.value == undefined) {
|
|
148
|
-
options.onActivateFirst?.();
|
|
149
|
-
return;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
options.onActivateNext?.(options.activeOption.value);
|
|
153
|
-
break;
|
|
154
|
-
|
|
155
|
-
case "Home":
|
|
156
|
-
event.preventDefault();
|
|
157
|
-
options.onActivateFirst?.();
|
|
158
|
-
break;
|
|
159
|
-
|
|
160
|
-
case "End":
|
|
161
|
-
event.preventDefault();
|
|
162
|
-
options.onActivateLast?.();
|
|
163
|
-
break;
|
|
164
|
-
|
|
165
|
-
default:
|
|
166
|
-
// if printable characters are pressed, the first option/text starting with the typed characters should be active
|
|
167
|
-
typeAhead(event);
|
|
168
|
-
}
|
|
169
|
-
};
|
|
170
|
-
|
|
171
|
-
const listbox = computed<VBindAttributes>(() =>
|
|
172
|
-
options.controlled
|
|
173
|
-
? {
|
|
174
|
-
role: "listbox",
|
|
175
|
-
"aria-multiselectable": isMultiselect.value,
|
|
176
|
-
"aria-label": unref(options.label),
|
|
177
|
-
"aria-description": options.description,
|
|
178
|
-
tabindex: "-1",
|
|
179
|
-
}
|
|
180
|
-
: {
|
|
181
|
-
role: "listbox",
|
|
182
|
-
"aria-multiselectable": isMultiselect.value,
|
|
183
|
-
"aria-label": unref(options.label),
|
|
184
|
-
"aria-description": options.description,
|
|
185
|
-
tabindex: "0",
|
|
186
|
-
"aria-activedescendant":
|
|
187
|
-
options.activeOption.value != undefined
|
|
188
|
-
? getOptionId(options.activeOption.value)
|
|
189
|
-
: undefined,
|
|
190
|
-
onFocus: () => (isFocused.value = true),
|
|
191
|
-
onBlur: () => (isFocused.value = false),
|
|
192
|
-
onKeydown: handleKeydown,
|
|
193
|
-
},
|
|
194
|
-
);
|
|
195
|
-
|
|
196
|
-
return {
|
|
197
|
-
elements: {
|
|
198
|
-
listbox,
|
|
199
|
-
group: computed(() => {
|
|
200
|
-
return (options: { label: string }) => ({
|
|
201
|
-
role: "group",
|
|
202
|
-
"aria-label": options.label,
|
|
203
|
-
});
|
|
204
|
-
}),
|
|
205
|
-
option: computed(() => {
|
|
206
|
-
return (data: {
|
|
207
|
-
label: string;
|
|
208
|
-
value: TValue;
|
|
209
|
-
disabled?: boolean;
|
|
210
|
-
selected?: boolean;
|
|
211
|
-
}) => {
|
|
212
|
-
const selected = data.selected ?? false;
|
|
213
|
-
|
|
214
|
-
return {
|
|
215
|
-
id: getOptionId(data.value),
|
|
216
|
-
role: "option",
|
|
217
|
-
"aria-label": data.label,
|
|
218
|
-
"aria-disabled": data.disabled,
|
|
219
|
-
"aria-checked": isMultiselect.value ? selected : undefined,
|
|
220
|
-
"aria-selected": !isMultiselect.value ? selected : undefined,
|
|
221
|
-
onClick: () => !data.disabled && options.onSelect?.(data.value),
|
|
222
|
-
} as const;
|
|
223
|
-
};
|
|
224
|
-
}),
|
|
225
|
-
},
|
|
226
|
-
state: {
|
|
227
|
-
isFocused,
|
|
228
|
-
},
|
|
229
|
-
internals: {
|
|
230
|
-
getOptionId,
|
|
231
|
-
},
|
|
232
|
-
};
|
|
233
|
-
},
|
|
234
|
-
);
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
import { test } from "@playwright/experimental-ct-vue";
|
|
2
|
-
import { menuButtonTesting } from "./createMenuButton.testing.js";
|
|
3
|
-
import TestMenuButton from "./TestMenuButton.vue";
|
|
4
|
-
|
|
5
|
-
test("menuButton", async ({ mount, page }) => {
|
|
6
|
-
await mount(<TestMenuButton />);
|
|
7
|
-
|
|
8
|
-
await menuButtonTesting({
|
|
9
|
-
page,
|
|
10
|
-
button: page.getByRole("button"),
|
|
11
|
-
menu: page.locator("ul"),
|
|
12
|
-
menuItems: page.getByRole("menuitem"),
|
|
13
|
-
});
|
|
14
|
-
});
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
<script lang="ts" setup>
|
|
2
|
-
import { ref } from "vue";
|
|
3
|
-
import { createMenuButton } from "./createMenuButton.js";
|
|
4
|
-
|
|
5
|
-
const items = Array.from({ length: 10 }, (_, index) => {
|
|
6
|
-
const id = index + 1;
|
|
7
|
-
return { label: `Item ${id}`, value: `/href-${id}` };
|
|
8
|
-
});
|
|
9
|
-
|
|
10
|
-
const activeItem = ref<string>();
|
|
11
|
-
const isExpanded = ref(false);
|
|
12
|
-
const onToggle = () => (isExpanded.value = !isExpanded.value);
|
|
13
|
-
const trigger = ref<"click" | "hover">("hover");
|
|
14
|
-
|
|
15
|
-
const {
|
|
16
|
-
elements: { root, button, menu, menuItem, listItem },
|
|
17
|
-
} = createMenuButton({ isExpanded, onToggle, trigger });
|
|
18
|
-
</script>
|
|
19
|
-
|
|
20
|
-
<template>
|
|
21
|
-
<div v-bind="root">
|
|
22
|
-
<button v-bind="button" type="button">Toggle nav menu</button>
|
|
23
|
-
<ul v-show="isExpanded" v-bind="menu">
|
|
24
|
-
<li v-for="item in items" v-bind="listItem" :key="item.value">
|
|
25
|
-
<a v-bind="menuItem({ active: activeItem === item.value })" href="#">{{ item.label }}</a>
|
|
26
|
-
</li>
|
|
27
|
-
</ul>
|
|
28
|
-
</div>
|
|
29
|
-
</template>
|
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
import { expect } from "@playwright/experimental-ct-vue";
|
|
2
|
-
import type { Locator, Page } from "@playwright/test";
|
|
3
|
-
|
|
4
|
-
export type MenuButtonTestingOptions = {
|
|
5
|
-
/**
|
|
6
|
-
* Playwright page.
|
|
7
|
-
*/
|
|
8
|
-
page: Page;
|
|
9
|
-
/**
|
|
10
|
-
* Locator for the button element.
|
|
11
|
-
*/
|
|
12
|
-
button: Locator;
|
|
13
|
-
/**
|
|
14
|
-
* Menu, e.g. a `<ul>` element.
|
|
15
|
-
*/
|
|
16
|
-
menu: Locator;
|
|
17
|
-
/**
|
|
18
|
-
* List items (at least 3).
|
|
19
|
-
*/
|
|
20
|
-
menuItems: Locator;
|
|
21
|
-
};
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Playwright utility for executing accessibility testing for a navigation menu.
|
|
25
|
-
* Will check aria attributes and keyboard shortcuts as defined in https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/examples/menu-button-links.
|
|
26
|
-
*/
|
|
27
|
-
export const menuButtonTesting = async ({
|
|
28
|
-
page,
|
|
29
|
-
button,
|
|
30
|
-
menu,
|
|
31
|
-
menuItems,
|
|
32
|
-
}: MenuButtonTestingOptions) => {
|
|
33
|
-
await expect(
|
|
34
|
-
button,
|
|
35
|
-
'navigation menu should have an "aria-haspopup" attribute set to true',
|
|
36
|
-
).toHaveAttribute("aria-haspopup", "true");
|
|
37
|
-
|
|
38
|
-
await expect(button).toBeVisible();
|
|
39
|
-
|
|
40
|
-
// ensure correct navigation menu aria attributes
|
|
41
|
-
await expect(button, "button must have arial-controls attribute").toHaveAttribute(
|
|
42
|
-
"aria-controls",
|
|
43
|
-
);
|
|
44
|
-
await expect(button, "button must have aria-expanded attribute").toHaveAttribute(
|
|
45
|
-
"aria-expanded",
|
|
46
|
-
"false",
|
|
47
|
-
);
|
|
48
|
-
|
|
49
|
-
await page.keyboard.press("Tab");
|
|
50
|
-
await expect(button, "Button should be focused when pressing tab key").toBeFocused();
|
|
51
|
-
|
|
52
|
-
const firstItem = menuItems.first();
|
|
53
|
-
const secondItem = menuItems.nth(1);
|
|
54
|
-
const lastItem = menuItems.last();
|
|
55
|
-
|
|
56
|
-
await page.keyboard.press("Enter");
|
|
57
|
-
await expect(button, "button must have aria-expanded attribute").toHaveAttribute(
|
|
58
|
-
"aria-expanded",
|
|
59
|
-
"true",
|
|
60
|
-
);
|
|
61
|
-
await button.press("ArrowDown");
|
|
62
|
-
await expect(
|
|
63
|
-
firstItem,
|
|
64
|
-
"First item should be focused when pressing arrow down key",
|
|
65
|
-
).toBeFocused();
|
|
66
|
-
|
|
67
|
-
await menu.press("ArrowDown");
|
|
68
|
-
await expect(
|
|
69
|
-
secondItem,
|
|
70
|
-
"Second item should be focused when pressing arrow down key",
|
|
71
|
-
).toBeFocused();
|
|
72
|
-
|
|
73
|
-
await menu.press("ArrowUp");
|
|
74
|
-
await expect(firstItem, "First item should be focused when pressing arrow up key").toBeFocused();
|
|
75
|
-
|
|
76
|
-
await page.keyboard.press("Tab");
|
|
77
|
-
await expect(button, "Button should be focused when pressing tab key").not.toBeFocused();
|
|
78
|
-
|
|
79
|
-
await page.keyboard.press("Tab");
|
|
80
|
-
|
|
81
|
-
await menu.press("Home");
|
|
82
|
-
await expect(firstItem, "First item should be focused when pressing home key").toBeFocused();
|
|
83
|
-
|
|
84
|
-
await page.keyboard.press("Tab");
|
|
85
|
-
await expect(button, "Button should be focused when pressing tab key").not.toBeFocused();
|
|
86
|
-
|
|
87
|
-
await page.keyboard.press("Tab");
|
|
88
|
-
|
|
89
|
-
await menu.press("End");
|
|
90
|
-
await expect(lastItem, "Last item should be focused when pressing end key").toBeFocused();
|
|
91
|
-
};
|
|
@@ -1,206 +0,0 @@
|
|
|
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
|
-
|
|
7
|
-
type CreateMenuButtonOptions = {
|
|
8
|
-
isExpanded: Readonly<Ref<boolean>>;
|
|
9
|
-
trigger: Readonly<MaybeRef<"hover" | "click">>;
|
|
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">;
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Based on https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/examples/disclosure-navigation/
|
|
22
|
-
*/
|
|
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();
|
|
29
|
-
|
|
30
|
-
const position = computed(() => toValue(options.position) ?? "bottom");
|
|
31
|
-
|
|
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;
|
|
76
|
-
break;
|
|
77
|
-
case "prev":
|
|
78
|
-
nextIndex = currentIndex - 1;
|
|
79
|
-
break;
|
|
80
|
-
case "first":
|
|
81
|
-
nextIndex = 0;
|
|
82
|
-
break;
|
|
83
|
-
case "last":
|
|
84
|
-
nextIndex = menuItems.length - 1;
|
|
85
|
-
break;
|
|
86
|
-
}
|
|
87
|
-
}
|
|
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;
|
|
126
|
-
return {
|
|
127
|
-
onMouseenter: () => setExpanded(true),
|
|
128
|
-
onMouseleave: () => setExpanded(false, true),
|
|
129
|
-
};
|
|
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
|
-
};
|
|
192
|
-
|
|
193
|
-
return {
|
|
194
|
-
elements: {
|
|
195
|
-
listItem: {
|
|
196
|
-
role: "none",
|
|
197
|
-
},
|
|
198
|
-
menuItem: (data: { active?: boolean; disabled?: boolean }) => ({
|
|
199
|
-
"aria-current": data.active ? "page" : undefined,
|
|
200
|
-
"aria-disabled": data.disabled,
|
|
201
|
-
role: "menuitem",
|
|
202
|
-
onKeydown,
|
|
203
|
-
}),
|
|
204
|
-
},
|
|
205
|
-
};
|
|
206
|
-
});
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
import { test } from "@playwright/experimental-ct-vue";
|
|
2
|
-
import { navigationTesting } from "./createMenu.testing.js";
|
|
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
|
-
});
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
<script lang="ts" setup>
|
|
2
|
-
import TestMenuButton from "../menuButton/TestMenuButton.vue";
|
|
3
|
-
import { createNavigationMenu } from "./createMenu.js";
|
|
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>
|
|
@@ -1,37 +0,0 @@
|
|
|
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
|
-
};
|
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
import { unref, useId, type MaybeRef } from "vue";
|
|
2
|
-
import { createBuilder } from "../../utils/builder.js";
|
|
3
|
-
import { MathUtils } from "../../utils/math.js";
|
|
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 Array.from(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
|
-
});
|