@sit-onyx/headless 1.0.0-beta.8 → 1.0.0-beta.9
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 +3 -3
- package/src/composables/comboBox/TestCombobox.vue +8 -4
- package/src/composables/listbox/createListbox.ts +1 -1
- package/src/composables/tabs/TestTabs.ct.tsx +13 -0
- package/src/composables/tabs/TestTabs.vue +26 -0
- package/src/composables/tabs/createTabs.testing.ts +78 -0
- package/src/composables/tabs/createTabs.ts +72 -0
- package/src/index.ts +2 -0
- package/src/playwright.ts +1 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sit-onyx/headless",
|
|
3
3
|
"description": "Headless composables for Vue",
|
|
4
|
-
"version": "1.0.0-beta.
|
|
4
|
+
"version": "1.0.0-beta.9",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "Schwarz IT KG",
|
|
7
7
|
"license": "Apache-2.0",
|
|
@@ -27,8 +27,8 @@
|
|
|
27
27
|
"vue": ">= 3.5.0"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
|
30
|
-
"@vue/compiler-dom": "
|
|
31
|
-
"vue": "
|
|
30
|
+
"@vue/compiler-dom": "3.5.12",
|
|
31
|
+
"vue": "3.5.12"
|
|
32
32
|
},
|
|
33
33
|
"scripts": {
|
|
34
34
|
"build": "vue-tsc --build --force",
|
|
@@ -56,11 +56,11 @@ defineExpose({ comboBox });
|
|
|
56
56
|
<div ref="comboboxRef">
|
|
57
57
|
<input v-bind="input" v-model="searchTerm" @keydown.arrow-down="isExpanded = true" />
|
|
58
58
|
|
|
59
|
-
<button v-bind="button">
|
|
60
|
-
<template v-if="isExpanded"
|
|
61
|
-
<template v-else
|
|
59
|
+
<button v-bind="button" type="button">
|
|
60
|
+
<template v-if="isExpanded"> ⬆️ </template>
|
|
61
|
+
<template v-else> ⬇️ </template>
|
|
62
62
|
</button>
|
|
63
|
-
<ul v-bind="listbox" :class="{ hidden: !isExpanded }"
|
|
63
|
+
<ul v-bind="listbox" :class="{ list: true, hidden: !isExpanded }">
|
|
64
64
|
<li
|
|
65
65
|
v-for="e in filteredOptions"
|
|
66
66
|
:key="e"
|
|
@@ -74,6 +74,10 @@ defineExpose({ comboBox });
|
|
|
74
74
|
</template>
|
|
75
75
|
|
|
76
76
|
<style>
|
|
77
|
+
.list {
|
|
78
|
+
width: 400px;
|
|
79
|
+
}
|
|
80
|
+
|
|
77
81
|
.hidden {
|
|
78
82
|
display: none;
|
|
79
83
|
}
|
|
@@ -93,7 +93,7 @@ export const createListbox = createBuilder(
|
|
|
93
93
|
|
|
94
94
|
const getOptionId = (value: TValue) => {
|
|
95
95
|
if (!descendantKeyIdMap.has(value)) {
|
|
96
|
-
descendantKeyIdMap.set(value, useId()
|
|
96
|
+
descendantKeyIdMap.set(value, useId());
|
|
97
97
|
}
|
|
98
98
|
return descendantKeyIdMap.get(value)!;
|
|
99
99
|
};
|
|
@@ -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,78 @@
|
|
|
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
|
+
const tab2 = options.tablist.locator('[aria-selected="true"]').first();
|
|
30
|
+
await tab2.click();
|
|
31
|
+
|
|
32
|
+
const { tabId: tabId2, panelId: panelId2 } = await expectTabAttributes(tab2, true);
|
|
33
|
+
await expectPanelAttributes(options.page.locator(`#${panelId2}`), tabId2);
|
|
34
|
+
|
|
35
|
+
await expect(options.page.getByRole("tabpanel"), "should hide previous panel").toHaveCount(1);
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Executes accessibility tests for a single tab.
|
|
40
|
+
*
|
|
41
|
+
* @param tab Locator of the tab.
|
|
42
|
+
* @param selected Whether the tab is expected to be selected
|
|
43
|
+
*/
|
|
44
|
+
const expectTabAttributes = async (tab: Locator, selected: boolean) => {
|
|
45
|
+
await expect(tab, 'tab must have role "tab"').toHaveRole("tab");
|
|
46
|
+
await expect(tab, "tab must have an ID").toHaveAttribute("id");
|
|
47
|
+
await expect(tab, 'tab must have "aria-selected" set').toHaveAttribute(
|
|
48
|
+
"aria-selected",
|
|
49
|
+
String(selected),
|
|
50
|
+
);
|
|
51
|
+
await expect(tab, 'tab must have "aria-controls" set').toHaveAttribute("aria-controls");
|
|
52
|
+
|
|
53
|
+
if (selected) {
|
|
54
|
+
await expect(tab, "selected tab should be focusable").toHaveAttribute("tabindex", "0");
|
|
55
|
+
} else {
|
|
56
|
+
await expect(tab, "unselected tab should NOT be focusable").toHaveAttribute("tabindex", "-1");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const tabId = (await tab.getAttribute("id"))!;
|
|
60
|
+
const panelId = (await tab.getAttribute("aria-controls"))!;
|
|
61
|
+
return { tabId, panelId };
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Executes accessibility tests for a single tab panel.
|
|
66
|
+
*
|
|
67
|
+
* @param panel Locator of the panel
|
|
68
|
+
* @param tabId Corresponding tab id
|
|
69
|
+
*/
|
|
70
|
+
const expectPanelAttributes = async (panel: Locator, tabId: string) => {
|
|
71
|
+
await expect(panel, "panel should be visible").toBeVisible();
|
|
72
|
+
await expect(panel, 'panel must have role "tabpanel"').toHaveRole("tabpanel");
|
|
73
|
+
await expect(panel, "panel must have an ID").toHaveAttribute("id");
|
|
74
|
+
await expect(panel, 'panel must have "aria-labelledby" set').toHaveAttribute(
|
|
75
|
+
"aria-labelledby",
|
|
76
|
+
tabId,
|
|
77
|
+
);
|
|
78
|
+
};
|
|
@@ -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<PropertyKey, { tabId: string; panelId: string }>();
|
|
29
|
+
|
|
30
|
+
const getId = (value: PropertyKey) => {
|
|
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
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
export * from "./composables/comboBox/createComboBox";
|
|
2
|
+
export * from "./composables/helpers/useGlobalListener";
|
|
2
3
|
export * from "./composables/listbox/createListbox";
|
|
3
4
|
export * from "./composables/menuButton/createMenuButton";
|
|
4
5
|
export * from "./composables/navigationMenu/createMenu";
|
|
6
|
+
export * from "./composables/tabs/createTabs";
|
|
5
7
|
export * from "./composables/tooltip/createToggletip";
|
|
6
8
|
export * from "./composables/tooltip/createTooltip";
|
|
7
9
|
export * from "./utils/builder";
|
package/src/playwright.ts
CHANGED
|
@@ -2,3 +2,4 @@ export * from "./composables/comboBox/createComboBox.testing";
|
|
|
2
2
|
export * from "./composables/listbox/createListbox.testing";
|
|
3
3
|
export * from "./composables/menuButton/createMenuButton.testing";
|
|
4
4
|
export * from "./composables/navigationMenu/createMenu.testing";
|
|
5
|
+
export * from "./composables/tabs/createTabs.testing";
|