-
Notifications
You must be signed in to change notification settings - Fork 6
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(headless): implement basic tabs composable (#2038)
Relates to #2018 Implement basic headless composable for [tabs](https://www.w3.org/WAI/ARIA/apg/patterns/tabs/).
- Loading branch information
1 parent
a544a1e
commit 616550f
Showing
10 changed files
with
201 additions
and
3 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
--- | ||
"@sit-onyx/headless": minor | ||
--- | ||
|
||
feat(headless): implement basic tabs composable #2038 | ||
|
||
The package now supports a `createTabs` composable and `tabsTesting()` Playwright utility. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
import { test } from "@playwright/experimental-ct-vue"; | ||
import TestTabs from "./TestTabs.vue"; | ||
import { tabsTesting } from "./createTabs.testing"; | ||
|
||
// eslint-disable-next-line playwright/expect-expect | ||
test("tabs", async ({ mount, page }) => { | ||
const component = await mount(<TestTabs />); | ||
|
||
await tabsTesting({ | ||
page, | ||
tablist: component.getByRole("tablist"), | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,26 @@ | ||
<script lang="ts" setup> | ||
import { ref } from "vue"; | ||
import { createTabs } from "./createTabs"; | ||
const selectedTab = ref("tab-1"); | ||
const { | ||
elements: { tablist, tab, tabpanel }, | ||
} = createTabs({ | ||
label: "Tablist label", | ||
selectedTab, | ||
onSelect: (tab) => (selectedTab.value = tab), | ||
}); | ||
</script> | ||
|
||
<template> | ||
<div> | ||
<div v-bind="tablist"> | ||
<button v-bind="tab({ value: 'tab-1' })" type="button">Tab 1</button> | ||
<button v-bind="tab({ value: 'tab-2' })" type="button">Tab 2</button> | ||
</div> | ||
|
||
<div v-if="selectedTab === 'tab-1'" v-bind="tabpanel({ value: 'tab-1' })">Tab content 1</div> | ||
<div v-if="selectedTab === 'tab-2'" v-bind="tabpanel({ value: 'tab-2' })">Tab content 2</div> | ||
</div> | ||
</template> |
78 changes: 78 additions & 0 deletions
78
packages/headless/src/composables/tabs/createTabs.testing.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,78 @@ | ||
import { expect } from "@playwright/experimental-ct-vue"; | ||
import type { Locator, Page } from "@playwright/test"; | ||
|
||
export type TabsTestingOptions = { | ||
page: Page; | ||
/** | ||
* Locator of the tabs component. | ||
*/ | ||
tablist: Locator; | ||
}; | ||
|
||
/** | ||
* Playwright utility for executing accessibility testing for tabs. | ||
* Will check aria attributes and keyboard shortcuts as defined in https://www.w3.org/WAI/ARIA/apg/patterns/tabs/ | ||
*/ | ||
export const tabsTesting = async (options: TabsTestingOptions) => { | ||
await expect(options.tablist, 'tablist element must have role "tablist"').toHaveRole("tablist"); | ||
await expect(options.tablist, "tablist must have an accessible label").toHaveAttribute( | ||
"aria-label", | ||
); | ||
|
||
const activeTab = options.tablist.locator('[aria-selected="true"]'); | ||
await expect(activeTab, "must have an initially active tab").toBeVisible(); | ||
|
||
const { tabId, panelId } = await expectTabAttributes(activeTab, true); | ||
await expectPanelAttributes(options.page.locator(`#${panelId}`), tabId); | ||
|
||
// ACT (switch tab) | ||
const tab2 = options.tablist.locator('[aria-selected="true"]').first(); | ||
await tab2.click(); | ||
|
||
const { tabId: tabId2, panelId: panelId2 } = await expectTabAttributes(tab2, true); | ||
await expectPanelAttributes(options.page.locator(`#${panelId2}`), tabId2); | ||
|
||
await expect(options.page.getByRole("tabpanel"), "should hide previous panel").toHaveCount(1); | ||
}; | ||
|
||
/** | ||
* Executes accessibility tests for a single tab. | ||
* | ||
* @param tab Locator of the tab. | ||
* @param selected Whether the tab is expected to be selected | ||
*/ | ||
const expectTabAttributes = async (tab: Locator, selected: boolean) => { | ||
await expect(tab, 'tab must have role "tab"').toHaveRole("tab"); | ||
await expect(tab, "tab must have an ID").toHaveAttribute("id"); | ||
await expect(tab, 'tab must have "aria-selected" set').toHaveAttribute( | ||
"aria-selected", | ||
String(selected), | ||
); | ||
await expect(tab, 'tab must have "aria-controls" set').toHaveAttribute("aria-controls"); | ||
|
||
if (selected) { | ||
await expect(tab, "selected tab should be focusable").toHaveAttribute("tabindex", "0"); | ||
} else { | ||
await expect(tab, "unselected tab should NOT be focusable").toHaveAttribute("tabindex", "-1"); | ||
} | ||
|
||
const tabId = (await tab.getAttribute("id"))!; | ||
const panelId = (await tab.getAttribute("aria-controls"))!; | ||
return { tabId, panelId }; | ||
}; | ||
|
||
/** | ||
* Executes accessibility tests for a single tab panel. | ||
* | ||
* @param panel Locator of the panel | ||
* @param tabId Corresponding tab id | ||
*/ | ||
const expectPanelAttributes = async (panel: Locator, tabId: string) => { | ||
await expect(panel, "panel should be visible").toBeVisible(); | ||
await expect(panel, 'panel must have role "tabpanel"').toHaveRole("tabpanel"); | ||
await expect(panel, "panel must have an ID").toHaveAttribute("id"); | ||
await expect(panel, 'panel must have "aria-labelledby" set').toHaveAttribute( | ||
"aria-labelledby", | ||
tabId, | ||
); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,72 @@ | ||
import { computed, unref, useId, type MaybeRef, type Ref } from "vue"; | ||
import { createBuilder } from "../../utils/builder"; | ||
|
||
type CreateTabsOptions<TKey extends PropertyKey = PropertyKey> = { | ||
/** | ||
* Label of the tablist. | ||
*/ | ||
label: MaybeRef<string>; | ||
/** | ||
* Currently selected tab. | ||
*/ | ||
selectedTab: Ref<TKey>; | ||
/** | ||
* Called when the user selects a tab. | ||
*/ | ||
onSelect?: (selectedTabValue: TKey) => void; | ||
}; | ||
|
||
/** | ||
* Composable for implementing accessible tabs. | ||
* Based on https://www.w3.org/WAI/ARIA/apg/patterns/tabs/ | ||
*/ | ||
export const createTabs = createBuilder(<T extends PropertyKey>(options: CreateTabsOptions<T>) => { | ||
/** | ||
* Map for looking up tab and panel IDs for given tab keys/values defined by the user. | ||
* Key = custom value from the user, value = random generated tab and panel ID | ||
*/ | ||
const idMap = new Map<PropertyKey, { tabId: string; panelId: string }>(); | ||
|
||
const getId = (value: PropertyKey) => { | ||
if (!idMap.has(value)) { | ||
idMap.set(value, { tabId: useId(), panelId: useId() }); | ||
} | ||
return idMap.get(value)!; | ||
}; | ||
|
||
return { | ||
elements: { | ||
tablist: computed(() => ({ | ||
role: "tablist", | ||
"aria-label": unref(options.label), | ||
})), | ||
tab: computed(() => { | ||
return (data: { value: T }) => { | ||
const { tabId: selectedTabId } = getId(unref(options.selectedTab)); | ||
const { tabId, panelId } = getId(data.value); | ||
const isSelected = tabId === selectedTabId; | ||
|
||
return { | ||
id: tabId, | ||
role: "tab", | ||
"aria-selected": isSelected, | ||
"aria-controls": panelId, | ||
onClick: () => options.onSelect?.(data.value), | ||
tabindex: isSelected ? 0 : -1, | ||
} as const; | ||
}; | ||
}), | ||
tabpanel: computed(() => { | ||
return (data: { value: T }) => { | ||
const { tabId, panelId } = getId(data.value); | ||
|
||
return { | ||
id: panelId, | ||
role: "tabpanel", | ||
"aria-labelledby": tabId, | ||
} as const; | ||
}; | ||
}), | ||
}, | ||
}; | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters