diff --git a/.changeset/dull-kids-drum.md b/.changeset/dull-kids-drum.md new file mode 100644 index 000000000..b034027d3 --- /dev/null +++ b/.changeset/dull-kids-drum.md @@ -0,0 +1,7 @@ +--- +"sit-onyx": minor +--- + +feat(OnyxTab): support `disabled` and `skeleton` property + +Also support `skeleton` prop for `OnyxTabs` component which will put all child tab components into skeleton mode. diff --git a/.changeset/tall-shirts-learn.md b/.changeset/tall-shirts-learn.md new file mode 100644 index 000000000..d7a5eeca6 --- /dev/null +++ b/.changeset/tall-shirts-learn.md @@ -0,0 +1,12 @@ +--- +"@sit-onyx/headless": minor +--- + +feat(createTabs): support skipping disabled tabs for keyboard navigation + +Disabled tabs will now be skipped/ignored when navigating via keyboard. +You need to provide the disabled state when calling the `tabs()` element, e.g.: + +```vue + +``` diff --git a/apps/docs/src/index.data.ts b/apps/docs/src/index.data.ts index 4b7a5f695..11c7fdea1 100644 --- a/apps/docs/src/index.data.ts +++ b/apps/docs/src/index.data.ts @@ -99,6 +99,7 @@ export default defineLoader({ name: "DataGrid", dueDate: "12/2024", status: "in-progress", + href: "https://storybook.onyx.schwarz/?path=/docs/data-datagrid--docs", }, { name: "Headline", @@ -222,7 +223,11 @@ export default defineLoader({ { name: "Breadcrumb", status: getImplementedStatus("OnyxBreadcrumb") }, { name: "Table of Content", status: getImplementedStatus("OnyxTableOfContent") }, { name: "Wizard", status: getImplementedStatus("OnyxWizard") }, - { name: "Tabs", status: getImplementedStatus("OnyxTabs") }, + { + name: "Tabs", + status: getImplementedStatus("OnyxTabs"), + href: "https://storybook.onyx.schwarz/?path=/docs/navigation-tabs--docs", + }, { name: "Search", status: "in-progress", dueDate: "11/2024" }, { name: "Filters", status: "in-progress", dueDate: "11/2024" }, ]; diff --git a/packages/headless/src/composables/tabs/createTabs.testing.ts b/packages/headless/src/composables/tabs/createTabs.testing.ts index 8c050ed0a..088de056e 100644 --- a/packages/headless/src/composables/tabs/createTabs.testing.ts +++ b/packages/headless/src/composables/tabs/createTabs.testing.ts @@ -71,6 +71,41 @@ 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(); + + await secondTab.evaluate((element) => (element.ariaDisabled = null)); + await firstTab.evaluate((element) => (element.ariaDisabled = "true")); + await options.page.keyboard.press("Home"); + await expect( + secondTab, + "should focus second tab when pressing Home if first tab is disabled", + ).toBeFocused(); + + await firstTab.evaluate((element) => (element.ariaDisabled = null)); + await lastTab.evaluate((element) => (element.ariaDisabled = "true")); + await firstTab.focus(); + await options.page.keyboard.press("End"); + await expect( + options.tablist.getByRole("tab").nth(-2), + "should focus second last tab when pressing End if last tab is disabled", + ).toBeFocused(); }; /** diff --git a/packages/headless/src/composables/tabs/createTabs.ts b/packages/headless/src/composables/tabs/createTabs.ts index 3e6f4a4a3..537f3bd01 100644 --- a/packages/headless/src/composables/tabs/createTabs.ts +++ b/packages/headless/src/composables/tabs/createTabs.ts @@ -37,30 +37,41 @@ export const createTabs = createBuilder((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); + + 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") => { + if (currentTabIndex === -1) return; + 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(); @@ -86,7 +97,7 @@ export const createTabs = createBuilder((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; @@ -96,8 +107,9 @@ export const createTabs = createBuilder((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, + tabindex: isSelected && !data.disabled ? 0 : -1, } as const; }; }), diff --git a/packages/sit-onyx/playwright/snapshots/components/OnyxTabs/Tabs-default--chromium-linux.png b/packages/sit-onyx/playwright/snapshots/components/OnyxTabs/Tabs-default--chromium-linux.png index d5377223c..23454b1fa 100644 Binary files a/packages/sit-onyx/playwright/snapshots/components/OnyxTabs/Tabs-default--chromium-linux.png and b/packages/sit-onyx/playwright/snapshots/components/OnyxTabs/Tabs-default--chromium-linux.png differ diff --git a/packages/sit-onyx/playwright/snapshots/components/OnyxTabs/Tabs-default--firefox-linux.png b/packages/sit-onyx/playwright/snapshots/components/OnyxTabs/Tabs-default--firefox-linux.png index 6278d225a..c4129061a 100644 Binary files a/packages/sit-onyx/playwright/snapshots/components/OnyxTabs/Tabs-default--firefox-linux.png and b/packages/sit-onyx/playwright/snapshots/components/OnyxTabs/Tabs-default--firefox-linux.png differ diff --git a/packages/sit-onyx/playwright/snapshots/components/OnyxTabs/Tabs-default--webkit-linux.png b/packages/sit-onyx/playwright/snapshots/components/OnyxTabs/Tabs-default--webkit-linux.png index b0b80ffc8..98d411f50 100644 Binary files a/packages/sit-onyx/playwright/snapshots/components/OnyxTabs/Tabs-default--webkit-linux.png and b/packages/sit-onyx/playwright/snapshots/components/OnyxTabs/Tabs-default--webkit-linux.png differ diff --git a/packages/sit-onyx/playwright/snapshots/components/OnyxTabs/Tabs-stretched--chromium-linux.png b/packages/sit-onyx/playwright/snapshots/components/OnyxTabs/Tabs-stretched--chromium-linux.png index 427380567..379b580da 100644 Binary files a/packages/sit-onyx/playwright/snapshots/components/OnyxTabs/Tabs-stretched--chromium-linux.png and b/packages/sit-onyx/playwright/snapshots/components/OnyxTabs/Tabs-stretched--chromium-linux.png differ diff --git a/packages/sit-onyx/playwright/snapshots/components/OnyxTabs/Tabs-stretched--firefox-linux.png b/packages/sit-onyx/playwright/snapshots/components/OnyxTabs/Tabs-stretched--firefox-linux.png index 22b0eff61..6d3b0f873 100644 Binary files a/packages/sit-onyx/playwright/snapshots/components/OnyxTabs/Tabs-stretched--firefox-linux.png and b/packages/sit-onyx/playwright/snapshots/components/OnyxTabs/Tabs-stretched--firefox-linux.png differ diff --git a/packages/sit-onyx/playwright/snapshots/components/OnyxTabs/Tabs-stretched--webkit-linux.png b/packages/sit-onyx/playwright/snapshots/components/OnyxTabs/Tabs-stretched--webkit-linux.png index 6737f133b..85f4d9792 100644 Binary files a/packages/sit-onyx/playwright/snapshots/components/OnyxTabs/Tabs-stretched--webkit-linux.png and b/packages/sit-onyx/playwright/snapshots/components/OnyxTabs/Tabs-stretched--webkit-linux.png differ diff --git a/packages/sit-onyx/src/components/OnyxTab/OnyxTab.stories.ts b/packages/sit-onyx/src/components/OnyxTab/OnyxTab.stories.ts index b8beef02b..5a9d592d5 100644 --- a/packages/sit-onyx/src/components/OnyxTab/OnyxTab.stories.ts +++ b/packages/sit-onyx/src/components/OnyxTab/OnyxTab.stories.ts @@ -22,3 +22,17 @@ export const Default = { default: "Panel content 1...", }, } satisfies Story; + +export const Disabled = { + args: { + ...Default.args, + disabled: true, + }, +} satisfies Story; + +export const Skeleton = { + args: { + ...Default.args, + skeleton: true, + }, +} satisfies Story; diff --git a/packages/sit-onyx/src/components/OnyxTab/OnyxTab.vue b/packages/sit-onyx/src/components/OnyxTab/OnyxTab.vue index cde498ae7..5b29205e8 100644 --- a/packages/sit-onyx/src/components/OnyxTab/OnyxTab.vue +++ b/packages/sit-onyx/src/components/OnyxTab/OnyxTab.vue @@ -1,10 +1,15 @@ + {{ props.label }} @@ -59,13 +73,19 @@ const tab = computed(() => tabsContext?.headless.elements.tab.value({ value: pro diff --git a/packages/sit-onyx/src/components/OnyxTab/types.ts b/packages/sit-onyx/src/components/OnyxTab/types.ts index 9419c58d1..5f022dc1a 100644 --- a/packages/sit-onyx/src/components/OnyxTab/types.ts +++ b/packages/sit-onyx/src/components/OnyxTab/types.ts @@ -1,4 +1,5 @@ import type { DensityProp } from "../../composables/density"; +import type { SkeletonInjected } from "../../composables/useSkeletonState"; export type OnyxTabProps = DensityProp & { /** @@ -9,4 +10,12 @@ export type OnyxTabProps = DensityProp & { * Tab label to display. Alternatively, the `tab` slot can be used. */ label?: string; + /** + * Whether the tab should be disabled and prevent the user from interacting with it. + */ + disabled?: boolean; + /** + * Whether to show a skeleton tab. + */ + skeleton?: SkeletonInjected; }; diff --git a/packages/sit-onyx/src/components/OnyxTabs/OnyxTabs.ct.tsx b/packages/sit-onyx/src/components/OnyxTabs/OnyxTabs.ct.tsx index 1f034c1b3..468ae30d3 100644 --- a/packages/sit-onyx/src/components/OnyxTabs/OnyxTabs.ct.tsx +++ b/packages/sit-onyx/src/components/OnyxTabs/OnyxTabs.ct.tsx @@ -13,17 +13,18 @@ for (const type of ["default", "stretched"] as const) { executeMatrixScreenshotTest({ name: `Tabs (${type})`, columns: DENSITIES, - rows: ["default", "hover", "active", "focus-visible"], + rows: ["default", "hover", "active", "focus-visible", "skeleton"], // TODO: remove when contrast issues are fixed in https://github.com/SchwarzIT/onyx/issues/410 disabledAccessibilityRules: ["color-contrast"], - component: (column) => { + component: (column, row) => { return ( Panel content 1... @@ -31,6 +32,9 @@ for (const type of ["default", "stretched"] as const) { Panel content 2... + + Panel content 3... + ); }, diff --git a/packages/sit-onyx/src/components/OnyxTabs/OnyxTabs.stories.ts b/packages/sit-onyx/src/components/OnyxTabs/OnyxTabs.stories.ts index 858682840..53034f12b 100644 --- a/packages/sit-onyx/src/components/OnyxTabs/OnyxTabs.stories.ts +++ b/packages/sit-onyx/src/components/OnyxTabs/OnyxTabs.stories.ts @@ -43,12 +43,14 @@ export const Default = { ), h( OnyxTab, - { value: "tab-3" }, + { value: "tab-3", disabled: true }, { default: "Panel content 3...", - tab: () => [h(OnyxIcon, { icon: placeholder }), "Tab 3"], + tab: () => [h(OnyxIcon, { icon: placeholder }), "Tab 3 (disabled)"], }, ), + h(OnyxTab, { value: "tab-4", label: "Tab 4" }, "Panel content 4..."), + h(OnyxTab, { value: "tab-5", skeleton: true, label: "Tab 5" }, "Panel content 5..."), ], }, } satisfies Story; @@ -59,3 +61,10 @@ export const Stretched = { stretched: true, }, } satisfies Story; + +export const Skeleton = { + args: { + ...Default.args, + skeleton: true, + }, +} satisfies Story; diff --git a/packages/sit-onyx/src/components/OnyxTabs/OnyxTabs.vue b/packages/sit-onyx/src/components/OnyxTabs/OnyxTabs.vue index 68057df9d..b1b89d086 100644 --- a/packages/sit-onyx/src/components/OnyxTabs/OnyxTabs.vue +++ b/packages/sit-onyx/src/components/OnyxTabs/OnyxTabs.vue @@ -2,6 +2,7 @@ import { createTabs } from "@sit-onyx/headless"; import { provide, ref, toRef } from "vue"; import { useDensity } from "../../composables/density"; +import { provideSkeletonContext } from "../../composables/useSkeletonState"; import { TABS_INJECTION_KEY, type OnyxTabsProps, type TabsInjectionKey } from "./types"; const props = defineProps>(); @@ -29,6 +30,7 @@ defineSlots<{ }>(); const panelRef = ref(); +provideSkeletonContext(props); provide(TABS_INJECTION_KEY as TabsInjectionKey, { headless, panelRef }); @@ -62,7 +64,8 @@ provide(TABS_INJECTION_KEY as TabsInjectionKey, { headless, panelRef }); justify-content: space-between; } - .onyx-tab { + .onyx-tab, + .onyx-tab-skeleton { width: 100%; } } diff --git a/packages/sit-onyx/src/components/OnyxTabs/types.ts b/packages/sit-onyx/src/components/OnyxTabs/types.ts index c29045460..1f62e1c56 100644 --- a/packages/sit-onyx/src/components/OnyxTabs/types.ts +++ b/packages/sit-onyx/src/components/OnyxTabs/types.ts @@ -1,21 +1,23 @@ import type { createTabs } from "@sit-onyx/headless"; import type { InjectionKey, Ref } from "vue"; import type { DensityProp } from "../../composables/density"; +import type { SkeletonProvidedProp } from "../../composables/useSkeletonState"; -export type OnyxTabsProps = DensityProp & { - /** - * Label of the tabs. Needed for accessibility / screen readers. - */ - label: string; - /** - * Currently selected tab (`value` property of `OnyxTab` component). - */ - modelValue: TValue; - /** - * If `true`, the tabs will be stretched to the full available width. - */ - stretched?: boolean; -}; +export type OnyxTabsProps = DensityProp & + Partial & { + /** + * Label of the tabs. Needed for accessibility / screen readers. + */ + label: string; + /** + * Currently selected tab (`value` property of `OnyxTab` component). + */ + modelValue: TValue; + /** + * If `true`, the tabs will be stretched to the full available width. + */ + stretched?: boolean; + }; export type TabsInjectionKey = InjectionKey<{ /** diff --git a/packages/sit-onyx/src/composables/useSkeletonState.ts b/packages/sit-onyx/src/composables/useSkeletonState.ts index 9b9d3dbe6..27ae1a92e 100644 --- a/packages/sit-onyx/src/composables/useSkeletonState.ts +++ b/packages/sit-onyx/src/composables/useSkeletonState.ts @@ -9,6 +9,10 @@ const SKELETON_INJECTION_KEY = Symbol() as InjectionKey< * It's value is provided, so that it can be used in child components. */ export type SkeletonProvidedProp = { + /** + * Whether to show all supported child components as skeleton. + * Can be overridden on each child component if necessary. + */ skeleton: boolean; };