Skip to content

Commit

Permalink
update keyboard support to skip disabled tabs
Browse files Browse the repository at this point in the history
  • Loading branch information
larsrickert committed Nov 12, 2024
1 parent aa06fac commit 0118884
Show file tree
Hide file tree
Showing 4 changed files with 69 additions and 18 deletions.
18 changes: 18 additions & 0 deletions packages/headless/src/composables/tabs/createTabs.testing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,24 @@ export const tabsTesting = async (options: TabsTestingOptions) => {
await options.page.keyboard.press("Space");
const { tabId: tabIdFirst, panelId: panelIdFirst } = await expectTabAttributes(firstTab, true);
await expectPanelAttributes(options.page.locator(`#${panelIdFirst}`), tabIdFirst);

// should skip disabled tabs when using the keyboard
await firstTab.click();
await secondTab.evaluate((element) => (element.ariaDisabled = "true"));
await expect(secondTab, "should disable second tab when setting aria-disabled").toBeDisabled();

await options.page.keyboard.press("ArrowRight");
await expect(secondTab, "should not focus second tab if its aria-disabled").not.toBeFocused();
await expect(
options.tablist.getByRole("tab").nth(2),
"should focus next tab after disabled one when pressing arrow right",
).toBeFocused();

await options.page.keyboard.press("ArrowLeft");
await expect(
firstTab,
"should focus tab before disabled one when pressing arrow left",
).toBeFocused();
};

/**
Expand Down
44 changes: 28 additions & 16 deletions packages/headless/src/composables/tabs/createTabs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,30 +37,41 @@ export const createTabs = createBuilder(<T extends PropertyKey>(options: CreateT
const handleKeydown = (event: KeyboardEvent) => {
const tab = event.target as Element;

const focusFirstTab = () => {
const element = tab.parentElement?.querySelector('[role="tab"]');
const enabledTabs = Array.from(
tab.parentElement?.querySelectorAll('[role="tab"]') ?? [],
).filter((tab) => tab.ariaDisabled !== "true");

const currentTabIndex = enabledTabs.indexOf(tab);
if (currentTabIndex === -1) return;

const focusElement = (element?: Element | null) => {
if (element instanceof HTMLElement) element.focus();
};

const focusLastTab = () => {
const element = Array.from(tab.parentElement?.querySelectorAll('[role="tab"]') ?? []).at(-1);
if (element instanceof HTMLElement) element.focus();
const focusFirstTab = () => focusElement(enabledTabs.at(0));
const focusLastTab = () => focusElement(enabledTabs.at(-1));

/**
* Focuses the next/previous tab. Will ignore/skip disabled ones.
*/
const focusTab = (direction: "next" | "previous") => {
const newIndex = direction === "next" ? currentTabIndex + 1 : currentTabIndex - 1;

if (newIndex < 0) {
return focusLastTab();
} else if (newIndex >= enabledTabs.length) {
return focusFirstTab();
}

return focusElement(enabledTabs.at(newIndex));
};

switch (event.key) {
case "ArrowRight":
if (tab.nextElementSibling && tab.nextElementSibling instanceof HTMLElement) {
tab.nextElementSibling.focus();
} else {
focusFirstTab();
}
focusTab("next");
break;
case "ArrowLeft":
if (tab.previousElementSibling && tab.previousElementSibling instanceof HTMLElement) {
tab.previousElementSibling.focus();
} else {
focusLastTab();
}
focusTab("previous");
break;
case "Home":
focusFirstTab();
Expand All @@ -86,7 +97,7 @@ export const createTabs = createBuilder(<T extends PropertyKey>(options: CreateT
onKeydown: handleKeydown,
})),
tab: computed(() => {
return (data: { value: T }) => {
return (data: { value: T; disabled?: boolean }) => {
const { tabId: selectedTabId } = getId(unref(options.selectedTab));
const { tabId, panelId } = getId(data.value);
const isSelected = tabId === selectedTabId;
Expand All @@ -96,6 +107,7 @@ export const createTabs = createBuilder(<T extends PropertyKey>(options: CreateT
role: "tab",
"aria-selected": isSelected,
"aria-controls": panelId,
"aria-disabled": data.disabled ? true : undefined,
onClick: () => options.onSelect?.(data.value),
tabindex: isSelected ? 0 : -1,
} as const;
Expand Down
9 changes: 7 additions & 2 deletions packages/sit-onyx/src/components/OnyxTab/OnyxTab.vue
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,16 @@ const { densityClass } = useDensity(props);
const tabsContext = inject(TABS_INJECTION_KEY);
const skeleton = useSkeletonContext(props);
const tab = computed(() => tabsContext?.headless.elements.tab.value({ value: props.value }));
const tab = computed(() =>
tabsContext?.headless.elements.tab.value({
value: props.value,
disabled: props.disabled || !!skeleton.value,
}),
);
</script>

<template>
<OnyxSkeleton v-if="skeleton" :class="['onyx-tab-skeleton', densityClass]" />
<OnyxSkeleton v-if="skeleton" :class="['onyx-tab-skeleton', densityClass]" v-bind="tab" />
<button
v-else
:class="[
Expand Down
16 changes: 16 additions & 0 deletions packages/sit-onyx/src/components/OnyxTabs/OnyxTabs.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,22 @@ export const Default = {
tab: () => [h(OnyxIcon, { icon: placeholder }), "Tab 4"],
},
),
h(
OnyxTab,
{ value: "tab-5", skeleton: true },
{
default: "Panel content 5...",
tab: () => [h(OnyxIcon, { icon: placeholder }), "Tab 5"],
},
),
h(
OnyxTab,
{ value: "tab-6" },
{
default: "Panel content 6...",
tab: () => [h(OnyxIcon, { icon: placeholder }), "Tab 6"],
},
),
],
},
} satisfies Story;
Expand Down

0 comments on commit 0118884

Please sign in to comment.