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 @@
+
+
+
+
+
+
+
+
+
+
Tab content 1
+
Tab content 2
+
+
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,