Skip to content

Commit

Permalink
feat(headless): implement basic tabs composable (#2038)
Browse files Browse the repository at this point in the history
Relates to #2018

Implement basic headless composable for
[tabs](https://www.w3.org/WAI/ARIA/apg/patterns/tabs/).
  • Loading branch information
larsrickert authored Nov 4, 2024
1 parent a544a1e commit 616550f
Show file tree
Hide file tree
Showing 10 changed files with 201 additions and 3 deletions.
7 changes: 7 additions & 0 deletions .changeset/little-avocados-breathe.md
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.
2 changes: 1 addition & 1 deletion packages/headless/src/composables/listbox/createListbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)!;
};
Expand Down
13 changes: 13 additions & 0 deletions packages/headless/src/composables/tabs/TestTabs.ct.tsx
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"),
});
});
26 changes: 26 additions & 0 deletions packages/headless/src/composables/tabs/TestTabs.vue
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 packages/headless/src/composables/tabs/createTabs.testing.ts
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,
);
};
72 changes: 72 additions & 0 deletions packages/headless/src/composables/tabs/createTabs.ts
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;
};
}),
},
};
});
1 change: 1 addition & 0 deletions packages/headless/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
1 change: 1 addition & 0 deletions packages/headless/src/playwright.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type { OnyxFormElementProps } from "./types";
const props = withDefaults(defineProps<OnyxFormElementProps>(), {
required: false,
id: () => useId() ?? "",
id: () => useId(),
});
const { t } = injectI18n();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import OnyxRadioButton from "../OnyxRadioButton/OnyxRadioButton.vue";
import type { OnyxRadioGroupProps } from "./types";
const props = withDefaults(defineProps<OnyxRadioGroupProps<TValue>>(), {
name: () => useId() ?? "", // the name must be globally unique
name: () => useId(), // the name must be globally unique
direction: "vertical",
required: false,
disabled: FORM_INJECTED_SYMBOL,
Expand Down

0 comments on commit 616550f

Please sign in to comment.