diff --git a/.changeset/little-avocados-breathe.md b/.changeset/little-avocados-breathe.md new file mode 100644 index 000000000..803640325 --- /dev/null +++ b/.changeset/little-avocados-breathe.md @@ -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. diff --git a/packages/headless/src/composables/listbox/createListbox.ts b/packages/headless/src/composables/listbox/createListbox.ts index 58f500d1f..ad791ec8c 100644 --- a/packages/headless/src/composables/listbox/createListbox.ts +++ b/packages/headless/src/composables/listbox/createListbox.ts @@ -93,7 +93,7 @@ export const createListbox = createBuilder( const getOptionId = (value: TValue) => { if (!descendantKeyIdMap.has(value)) { - descendantKeyIdMap.set(value, useId() ?? value.toString()); + descendantKeyIdMap.set(value, useId()); } return descendantKeyIdMap.get(value)!; }; diff --git a/packages/headless/src/composables/tabs/TestTabs.ct.tsx b/packages/headless/src/composables/tabs/TestTabs.ct.tsx new file mode 100644 index 000000000..b50da96e8 --- /dev/null +++ b/packages/headless/src/composables/tabs/TestTabs.ct.tsx @@ -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(); + + await tabsTesting({ + page, + tablist: component.getByRole("tablist"), + }); +}); diff --git a/packages/headless/src/composables/tabs/TestTabs.vue b/packages/headless/src/composables/tabs/TestTabs.vue new file mode 100644 index 000000000..28ea1f204 --- /dev/null +++ b/packages/headless/src/composables/tabs/TestTabs.vue @@ -0,0 +1,26 @@ + + + diff --git a/packages/headless/src/composables/tabs/createTabs.testing.ts b/packages/headless/src/composables/tabs/createTabs.testing.ts new file mode 100644 index 000000000..f77089e28 --- /dev/null +++ b/packages/headless/src/composables/tabs/createTabs.testing.ts @@ -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, + ); +}; diff --git a/packages/headless/src/composables/tabs/createTabs.ts b/packages/headless/src/composables/tabs/createTabs.ts new file mode 100644 index 000000000..423063009 --- /dev/null +++ b/packages/headless/src/composables/tabs/createTabs.ts @@ -0,0 +1,72 @@ +import { computed, unref, useId, type MaybeRef, type Ref } from "vue"; +import { createBuilder } from "../../utils/builder"; + +type CreateTabsOptions = { + /** + * Label of the tablist. + */ + label: MaybeRef; + /** + * Currently selected tab. + */ + selectedTab: Ref; + /** + * 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((options: CreateTabsOptions) => { + /** + * 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(); + + 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; + }; + }), + }, + }; +}); diff --git a/packages/headless/src/index.ts b/packages/headless/src/index.ts index b21f96a9f..a3c633b6d 100644 --- a/packages/headless/src/index.ts +++ b/packages/headless/src/index.ts @@ -3,6 +3,7 @@ export * from "./composables/helpers/useGlobalListener"; export * from "./composables/listbox/createListbox"; export * from "./composables/menuButton/createMenuButton"; export * from "./composables/navigationMenu/createMenu"; +export * from "./composables/tabs/createTabs"; export * from "./composables/tooltip/createToggletip"; export * from "./composables/tooltip/createTooltip"; export * from "./utils/builder"; diff --git a/packages/headless/src/playwright.ts b/packages/headless/src/playwright.ts index a4754dc48..c9b41f283 100644 --- a/packages/headless/src/playwright.ts +++ b/packages/headless/src/playwright.ts @@ -2,3 +2,4 @@ export * from "./composables/comboBox/createComboBox.testing"; export * from "./composables/listbox/createListbox.testing"; export * from "./composables/menuButton/createMenuButton.testing"; export * from "./composables/navigationMenu/createMenu.testing"; +export * from "./composables/tabs/createTabs.testing"; diff --git a/packages/sit-onyx/src/components/OnyxFormElement/OnyxFormElement.vue b/packages/sit-onyx/src/components/OnyxFormElement/OnyxFormElement.vue index d5e53745c..dd2102a17 100644 --- a/packages/sit-onyx/src/components/OnyxFormElement/OnyxFormElement.vue +++ b/packages/sit-onyx/src/components/OnyxFormElement/OnyxFormElement.vue @@ -7,7 +7,7 @@ import type { OnyxFormElementProps } from "./types"; const props = withDefaults(defineProps(), { required: false, - id: () => useId() ?? "", + id: () => useId(), }); const { t } = injectI18n(); diff --git a/packages/sit-onyx/src/components/OnyxRadioGroup/OnyxRadioGroup.vue b/packages/sit-onyx/src/components/OnyxRadioGroup/OnyxRadioGroup.vue index e7febe5bf..1bc23595f 100644 --- a/packages/sit-onyx/src/components/OnyxRadioGroup/OnyxRadioGroup.vue +++ b/packages/sit-onyx/src/components/OnyxRadioGroup/OnyxRadioGroup.vue @@ -10,7 +10,7 @@ import OnyxRadioButton from "../OnyxRadioButton/OnyxRadioButton.vue"; import type { OnyxRadioGroupProps } from "./types"; const props = withDefaults(defineProps>(), { - name: () => useId() ?? "", // the name must be globally unique + name: () => useId(), // the name must be globally unique direction: "vertical", required: false, disabled: FORM_INJECTED_SYMBOL,