@sit-onyx/headless 1.0.0-beta.10 → 1.0.0-beta.12
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 +1 -1
- package/src/composables/comboBox/SelectOnlyCombobox.vue +5 -2
- package/src/composables/comboBox/createComboBox.ts +8 -8
- package/src/composables/menuButton/TestMenuButton.vue +1 -1
- package/src/composables/tabs/TestTabs.vue +2 -0
- package/src/composables/tabs/createTabs.testing.ts +80 -8
- package/src/composables/tabs/createTabs.ts +59 -2
package/package.json
CHANGED
|
@@ -57,11 +57,11 @@ defineExpose({ comboBox });
|
|
|
57
57
|
@keydown.arrow-down="isExpanded = true"
|
|
58
58
|
/>
|
|
59
59
|
|
|
60
|
-
<button v-bind="button">
|
|
60
|
+
<button v-bind="button" type="button">
|
|
61
61
|
<template v-if="isExpanded">⬆️</template>
|
|
62
62
|
<template v-else>⬇️</template>
|
|
63
63
|
</button>
|
|
64
|
-
<ul v-bind="listbox" :class="{ hidden: !isExpanded }"
|
|
64
|
+
<ul class="listbox" v-bind="listbox" :class="{ hidden: !isExpanded }">
|
|
65
65
|
<li
|
|
66
66
|
v-for="e in options"
|
|
67
67
|
:key="e"
|
|
@@ -84,4 +84,7 @@ defineExpose({ comboBox });
|
|
|
84
84
|
[aria-selected="true"] {
|
|
85
85
|
background-color: red;
|
|
86
86
|
}
|
|
87
|
+
.listbox {
|
|
88
|
+
width: 400px;
|
|
89
|
+
}
|
|
87
90
|
</style>
|
|
@@ -209,13 +209,13 @@ export const createComboBox = createBuilder(
|
|
|
209
209
|
return handleNavigation(event);
|
|
210
210
|
};
|
|
211
211
|
|
|
212
|
-
const autocompleteInput =
|
|
213
|
-
autocomplete.value
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
212
|
+
const autocompleteInput = computed(() => {
|
|
213
|
+
if (autocomplete.value === "none") return null;
|
|
214
|
+
return {
|
|
215
|
+
"aria-autocomplete": autocomplete.value,
|
|
216
|
+
type: "text",
|
|
217
|
+
};
|
|
218
|
+
});
|
|
219
219
|
|
|
220
220
|
const {
|
|
221
221
|
elements: { option, group, listbox },
|
|
@@ -264,7 +264,7 @@ export const createComboBox = createBuilder(
|
|
|
264
264
|
activeOption.value != undefined ? getOptionId(activeOption.value) : undefined,
|
|
265
265
|
onInput: handleInput,
|
|
266
266
|
onKeydown: handleKeydown,
|
|
267
|
-
...autocompleteInput,
|
|
267
|
+
...autocompleteInput.value,
|
|
268
268
|
})),
|
|
269
269
|
/**
|
|
270
270
|
* An optional button to control the visibility of the popup.
|
|
@@ -18,7 +18,7 @@ const {
|
|
|
18
18
|
|
|
19
19
|
<template>
|
|
20
20
|
<div v-bind="root">
|
|
21
|
-
<button v-bind="button">Toggle nav menu</button>
|
|
21
|
+
<button v-bind="button" type="button">Toggle nav menu</button>
|
|
22
22
|
<ul v-show="isExpanded" v-bind="menu">
|
|
23
23
|
<li v-for="item in items" v-bind="listItem" :key="item.value">
|
|
24
24
|
<a v-bind="menuItem({ active: activeItem === item.value })" href="#">{{ item.label }}</a>
|
|
@@ -18,9 +18,11 @@ const {
|
|
|
18
18
|
<div v-bind="tablist">
|
|
19
19
|
<button v-bind="tab({ value: 'tab-1' })" type="button">Tab 1</button>
|
|
20
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>
|
|
21
22
|
</div>
|
|
22
23
|
|
|
23
24
|
<div v-if="selectedTab === 'tab-1'" v-bind="tabpanel({ value: 'tab-1' })">Tab content 1</div>
|
|
24
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>
|
|
25
27
|
</div>
|
|
26
28
|
</template>
|
|
@@ -4,7 +4,7 @@ import type { Locator, Page } from "@playwright/test";
|
|
|
4
4
|
export type TabsTestingOptions = {
|
|
5
5
|
page: Page;
|
|
6
6
|
/**
|
|
7
|
-
* Locator of the tabs component.
|
|
7
|
+
* Locator of the tabs component. Must have at least 3 tabs where the first one is initially selected.
|
|
8
8
|
*/
|
|
9
9
|
tablist: Locator;
|
|
10
10
|
};
|
|
@@ -19,21 +19,93 @@ export const tabsTesting = async (options: TabsTestingOptions) => {
|
|
|
19
19
|
"aria-label",
|
|
20
20
|
);
|
|
21
21
|
|
|
22
|
-
const
|
|
23
|
-
|
|
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();
|
|
24
25
|
|
|
25
|
-
const { tabId, panelId } = await expectTabAttributes(
|
|
26
|
+
const { tabId, panelId } = await expectTabAttributes(firstTab, true);
|
|
26
27
|
await expectPanelAttributes(options.page.locator(`#${panelId}`), tabId);
|
|
27
28
|
|
|
28
29
|
// ACT (switch tab)
|
|
29
|
-
|
|
30
|
-
tab2 = options.tablist.locator(`#${await tab2.getAttribute("id")}`);
|
|
31
|
-
await tab2.click();
|
|
30
|
+
await secondTab.click();
|
|
32
31
|
|
|
33
|
-
const { tabId: tabId2, panelId: panelId2 } = await expectTabAttributes(
|
|
32
|
+
const { tabId: tabId2, panelId: panelId2 } = await expectTabAttributes(secondTab, true);
|
|
34
33
|
await expectPanelAttributes(options.page.locator(`#${panelId2}`), tabId2);
|
|
34
|
+
await expect(secondTab, "second tab should be focused").toBeFocused();
|
|
35
35
|
|
|
36
36
|
await expect(options.page.getByRole("tabpanel"), "should hide previous panel").toHaveCount(1);
|
|
37
|
+
|
|
38
|
+
// keyboard support
|
|
39
|
+
await options.page.keyboard.press("ArrowLeft");
|
|
40
|
+
await expect(firstTab, "should focus previous tab when pressing arrow left").toBeFocused();
|
|
41
|
+
|
|
42
|
+
await options.page.keyboard.press("End");
|
|
43
|
+
await expect(lastTab, "should focus last tab when pressing End").toBeFocused();
|
|
44
|
+
|
|
45
|
+
await options.page.keyboard.press("ArrowRight");
|
|
46
|
+
await expect(
|
|
47
|
+
firstTab,
|
|
48
|
+
"should focus first tab when last tab is focused and pressing arrow right",
|
|
49
|
+
).toBeFocused();
|
|
50
|
+
|
|
51
|
+
await options.page.keyboard.press("ArrowRight");
|
|
52
|
+
await expect(secondTab, "should focus next tab when pressing arrow right").toBeFocused();
|
|
53
|
+
|
|
54
|
+
await options.page.keyboard.press("Home");
|
|
55
|
+
await expect(firstTab, "should focus first tab when pressing Home").toBeFocused();
|
|
56
|
+
|
|
57
|
+
await options.page.keyboard.press("ArrowLeft");
|
|
58
|
+
|
|
59
|
+
await expect(
|
|
60
|
+
lastTab,
|
|
61
|
+
"should focus last tab when first tab is focused and pressing arrow left",
|
|
62
|
+
).toBeFocused();
|
|
63
|
+
|
|
64
|
+
// should select when pressing Enter
|
|
65
|
+
await options.page.keyboard.press("Enter");
|
|
66
|
+
const { tabId: tabIdLast, panelId: panelIdLast } = await expectTabAttributes(lastTab, true);
|
|
67
|
+
await expectPanelAttributes(options.page.locator(`#${panelIdLast}`), tabIdLast);
|
|
68
|
+
|
|
69
|
+
// should select when pressing Space
|
|
70
|
+
await firstTab.focus();
|
|
71
|
+
await options.page.keyboard.press("Space");
|
|
72
|
+
const { tabId: tabIdFirst, panelId: panelIdFirst } = await expectTabAttributes(firstTab, true);
|
|
73
|
+
await expectPanelAttributes(options.page.locator(`#${panelIdFirst}`), tabIdFirst);
|
|
74
|
+
|
|
75
|
+
// should skip disabled tabs when using the keyboard
|
|
76
|
+
await firstTab.click();
|
|
77
|
+
await secondTab.evaluate((element) => (element.ariaDisabled = "true"));
|
|
78
|
+
await expect(secondTab, "should disable second tab when setting aria-disabled").toBeDisabled();
|
|
79
|
+
|
|
80
|
+
await options.page.keyboard.press("ArrowRight");
|
|
81
|
+
await expect(secondTab, "should not focus second tab if its aria-disabled").not.toBeFocused();
|
|
82
|
+
await expect(
|
|
83
|
+
options.tablist.getByRole("tab").nth(2),
|
|
84
|
+
"should focus next tab after disabled one when pressing arrow right",
|
|
85
|
+
).toBeFocused();
|
|
86
|
+
|
|
87
|
+
await options.page.keyboard.press("ArrowLeft");
|
|
88
|
+
await expect(
|
|
89
|
+
firstTab,
|
|
90
|
+
"should focus tab before disabled one when pressing arrow left",
|
|
91
|
+
).toBeFocused();
|
|
92
|
+
|
|
93
|
+
await secondTab.evaluate((element) => (element.ariaDisabled = null));
|
|
94
|
+
await firstTab.evaluate((element) => (element.ariaDisabled = "true"));
|
|
95
|
+
await options.page.keyboard.press("Home");
|
|
96
|
+
await expect(
|
|
97
|
+
secondTab,
|
|
98
|
+
"should focus second tab when pressing Home if first tab is disabled",
|
|
99
|
+
).toBeFocused();
|
|
100
|
+
|
|
101
|
+
await firstTab.evaluate((element) => (element.ariaDisabled = null));
|
|
102
|
+
await lastTab.evaluate((element) => (element.ariaDisabled = "true"));
|
|
103
|
+
await firstTab.focus();
|
|
104
|
+
await options.page.keyboard.press("End");
|
|
105
|
+
await expect(
|
|
106
|
+
options.tablist.getByRole("tab").nth(-2),
|
|
107
|
+
"should focus second last tab when pressing End if last tab is disabled",
|
|
108
|
+
).toBeFocused();
|
|
37
109
|
};
|
|
38
110
|
|
|
39
111
|
/**
|
|
@@ -34,14 +34,70 @@ export const createTabs = createBuilder(<T extends PropertyKey>(options: CreateT
|
|
|
34
34
|
return idMap.get(value)!;
|
|
35
35
|
};
|
|
36
36
|
|
|
37
|
+
const handleKeydown = (event: KeyboardEvent) => {
|
|
38
|
+
const tab = event.target as Element;
|
|
39
|
+
|
|
40
|
+
const enabledTabs = Array.from(
|
|
41
|
+
tab.parentElement?.querySelectorAll('[role="tab"]') ?? [],
|
|
42
|
+
).filter((tab) => tab.ariaDisabled !== "true");
|
|
43
|
+
|
|
44
|
+
const currentTabIndex = enabledTabs.indexOf(tab);
|
|
45
|
+
|
|
46
|
+
const focusElement = (element?: Element | null) => {
|
|
47
|
+
if (element instanceof HTMLElement) element.focus();
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const focusFirstTab = () => focusElement(enabledTabs.at(0));
|
|
51
|
+
const focusLastTab = () => focusElement(enabledTabs.at(-1));
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Focuses the next/previous tab. Will ignore/skip disabled ones.
|
|
55
|
+
*/
|
|
56
|
+
const focusTab = (direction: "next" | "previous") => {
|
|
57
|
+
if (currentTabIndex === -1) return;
|
|
58
|
+
const newIndex = direction === "next" ? currentTabIndex + 1 : currentTabIndex - 1;
|
|
59
|
+
|
|
60
|
+
if (newIndex < 0) {
|
|
61
|
+
return focusLastTab();
|
|
62
|
+
} else if (newIndex >= enabledTabs.length) {
|
|
63
|
+
return focusFirstTab();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return focusElement(enabledTabs.at(newIndex));
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
switch (event.key) {
|
|
70
|
+
case "ArrowRight":
|
|
71
|
+
focusTab("next");
|
|
72
|
+
break;
|
|
73
|
+
case "ArrowLeft":
|
|
74
|
+
focusTab("previous");
|
|
75
|
+
break;
|
|
76
|
+
case "Home":
|
|
77
|
+
focusFirstTab();
|
|
78
|
+
break;
|
|
79
|
+
case "End":
|
|
80
|
+
focusLastTab();
|
|
81
|
+
break;
|
|
82
|
+
case "Enter":
|
|
83
|
+
case " ":
|
|
84
|
+
{
|
|
85
|
+
const tabEntry = Array.from(idMap.entries()).find(([, { tabId }]) => tabId === tab.id);
|
|
86
|
+
if (tabEntry) options.onSelect?.(tabEntry[0]);
|
|
87
|
+
}
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
37
92
|
return {
|
|
38
93
|
elements: {
|
|
39
94
|
tablist: computed(() => ({
|
|
40
95
|
role: "tablist",
|
|
41
96
|
"aria-label": unref(options.label),
|
|
97
|
+
onKeydown: handleKeydown,
|
|
42
98
|
})),
|
|
43
99
|
tab: computed(() => {
|
|
44
|
-
return (data: { value: T }) => {
|
|
100
|
+
return (data: { value: T; disabled?: boolean }) => {
|
|
45
101
|
const { tabId: selectedTabId } = getId(unref(options.selectedTab));
|
|
46
102
|
const { tabId, panelId } = getId(data.value);
|
|
47
103
|
const isSelected = tabId === selectedTabId;
|
|
@@ -51,8 +107,9 @@ export const createTabs = createBuilder(<T extends PropertyKey>(options: CreateT
|
|
|
51
107
|
role: "tab",
|
|
52
108
|
"aria-selected": isSelected,
|
|
53
109
|
"aria-controls": panelId,
|
|
110
|
+
"aria-disabled": data.disabled ? true : undefined,
|
|
54
111
|
onClick: () => options.onSelect?.(data.value),
|
|
55
|
-
tabindex: isSelected ? 0 : -1,
|
|
112
|
+
tabindex: isSelected && !data.disabled ? 0 : -1,
|
|
56
113
|
} as const;
|
|
57
114
|
};
|
|
58
115
|
}),
|