Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(OnyxTab): support disabled and skeleton property #2069

Merged
merged 13 commits into from
Nov 13, 2024
7 changes: 7 additions & 0 deletions .changeset/dull-kids-drum.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 12 additions & 0 deletions .changeset/tall-shirts-learn.md
Original file line number Diff line number Diff line change
@@ -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
<button v-bind="tab({ value: 'tab-1', disabled: true })" />
```
7 changes: 6 additions & 1 deletion apps/docs/src/index.data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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" },
];
Expand Down
35 changes: 35 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,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();
};

/**
Expand Down
46 changes: 29 additions & 17 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);

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();
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,8 +107,9 @@ 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,
tabindex: isSelected && !data.disabled ? 0 : -1,
} as const;
};
}),
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
14 changes: 14 additions & 0 deletions packages/sit-onyx/src/components/OnyxTab/OnyxTab.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
67 changes: 53 additions & 14 deletions packages/sit-onyx/src/components/OnyxTab/OnyxTab.vue
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
<script lang="ts" setup>
import { computed, inject } from "vue";
import { useDensity } from "../../composables/density";
import { SKELETON_INJECTED_SYMBOL, useSkeletonContext } from "../../composables/useSkeletonState";
import OnyxSkeleton from "../OnyxSkeleton/OnyxSkeleton.vue";
import { TABS_INJECTION_KEY } from "../OnyxTabs/types";
import type { OnyxTabProps } from "./types";

const props = defineProps<OnyxTabProps>();
const props = withDefaults(defineProps<OnyxTabProps>(), {
disabled: false,
skeleton: SKELETON_INJECTED_SYMBOL,
});

defineSlots<{
/**
Expand All @@ -19,12 +24,20 @@ defineSlots<{

const { densityClass } = useDensity(props);
const tabsContext = inject(TABS_INJECTION_KEY);

const tab = computed(() => tabsContext?.headless.elements.tab.value({ value: props.value }));
const skeleton = useSkeletonContext(props);

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]" v-bind="tab" />
<button
v-else
:class="[
'onyx-tab',
'onyx-text--large',
Expand All @@ -33,6 +46,7 @@ const tab = computed(() => tabsContext?.headless.elements.tab.value({ value: pro
]"
v-bind="tab"
type="button"
:disabled="props.disabled"
>
<div class="onyx-tab__label">
<slot name="tab">{{ props.label }}</slot>
Expand All @@ -59,13 +73,19 @@ const tab = computed(() => tabsContext?.headless.elements.tab.value({ value: pro
<style lang="scss">
@use "../../styles/mixins/layers.scss";

.onyx-tab,
.onyx-tab-skeleton {
--onyx-tab-padding-vertical: var(--onyx-density-xs);
--onyx-tab-line-height: 1.75rem;
--onyx-tab-highlight-gap: var(--onyx-density-3xs);
}

.onyx-tab {
@include layers.component() {
font-family: var(--onyx-font-family);
color: var(--onyx-color-text-icons-neutral-medium);
border-radius: var(--onyx-radius-sm);
padding: var(--onyx-density-xs) var(--onyx-density-md);
cursor: pointer;
padding: var(--onyx-tab-padding-vertical) var(--onyx-density-md);
font-weight: 600;

// reset button styles
Expand All @@ -91,17 +111,25 @@ const tab = computed(() => tabsContext?.headless.elements.tab.value({ value: pro
}
}

&:hover,
&:focus-visible {
background-color: var(--onyx-color-base-neutral-200);
}
&:enabled {
cursor: pointer;

&:hover,
&:focus-visible {
background-color: var(--onyx-color-base-neutral-200);
}

&:focus-visible {
outline: 0.25rem solid var(--onyx-color-base-primary-200);
&:focus-visible {
outline: 0.25rem solid var(--onyx-color-base-primary-200);
}

&:active {
color: var(--onyx-color-text-icons-primary-bold);
}
}

&:active {
color: var(--onyx-color-text-icons-primary-bold);
&:disabled {
color: var(--onyx-color-text-icons-neutral-soft);
}

&__label {
Expand All @@ -110,13 +138,24 @@ const tab = computed(() => tabsContext?.headless.elements.tab.value({ value: pro
justify-content: center;
gap: var(--onyx-density-xs);
position: relative;
padding-bottom: var(--onyx-density-3xs);
padding-bottom: var(--onyx-tab-highlight-gap);
line-height: var(--onyx-tab-line-height);
}

&__panel {
font-family: var(--onyx-font-family);
color: var(--onyx-color-text-icons-neutral-intense);
}

&-skeleton {
width: var(--onyx-density-4xl);
height: calc(
var(--onyx-tab-line-height) + 2 * var(--onyx-tab-padding-vertical) +
var(--onyx-tab-highlight-gap)
);
display: inline-block;
vertical-align: middle;
}
}
}
</style>
9 changes: 9 additions & 0 deletions packages/sit-onyx/src/components/OnyxTab/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { DensityProp } from "../../composables/density";
import type { SkeletonInjected } from "../../composables/useSkeletonState";

export type OnyxTabProps = DensityProp & {
/**
Expand All @@ -9,4 +10,12 @@ export type OnyxTabProps = DensityProp & {
* Tab label to display. Alternatively, the `tab` slot can be used.
*/
label?: string;
/**
* Whether the input should be disabled and prevent the user from interacting with it.
larsrickert marked this conversation as resolved.
Show resolved Hide resolved
*/
disabled?: boolean;
/**
* Whether to show a skeleton tab.
*/
skeleton?: SkeletonInjected;
};
10 changes: 7 additions & 3 deletions packages/sit-onyx/src/components/OnyxTabs/OnyxTabs.ct.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,24 +13,28 @@ 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 (
<OnyxTabs
label="Example tabs"
modelValue="tab-1"
density={column}
stretched={type === "stretched"}
style={{ width: type === "stretched" ? "24rem" : undefined }}
style={{ width: type === "stretched" ? "40rem" : undefined }}
skeleton={row === "skeleton"}
>
<OnyxTab label="Tab 1" value="tab-1">
Panel content 1...
</OnyxTab>
<OnyxTab label="Tab 2" value="tab-2">
Panel content 2...
</OnyxTab>
<OnyxTab label="Tab 3 (disabled)" value="tab-3" disabled>
Panel content 3...
</OnyxTab>
</OnyxTabs>
);
},
Expand Down
13 changes: 11 additions & 2 deletions packages/sit-onyx/src/components/OnyxTabs/OnyxTabs.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -59,3 +61,10 @@ export const Stretched = {
stretched: true,
},
} satisfies Story;

export const Skeleton = {
args: {
...Default.args,
skeleton: true,
},
} satisfies Story;
Loading
Loading