Skip to content

Commit

Permalink
feat: implement basic OnyxTabs component (#2040)
Browse files Browse the repository at this point in the history
Relates to #2018

Implement basic `OnyxTabs` and `OnyxTab` component.
  • Loading branch information
larsrickert authored Nov 11, 2024
1 parent 5180abb commit fda8a30
Show file tree
Hide file tree
Showing 23 changed files with 468 additions and 3 deletions.
5 changes: 5 additions & 0 deletions .changeset/brown-doors-wink.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"sit-onyx": minor
---

Implement basic OnyxTabs and OnyxTab component.
5 changes: 5 additions & 0 deletions .changeset/mighty-lies-burn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@sit-onyx/headless": patch
---

fix(tabsTesting): correctly test switching to second tab
17 changes: 17 additions & 0 deletions apps/demo-app/src/views/HomeView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ import {
OnyxSkeleton,
OnyxStepper,
OnyxSwitch,
OnyxTab,
OnyxTable,
OnyxTabs,
OnyxTag,
OnyxTextarea,
OnyxTimer,
Expand Down Expand Up @@ -55,6 +57,7 @@ const COMPONENTS = [
"OnyxStepper",
"OnyxSwitch",
"OnyxTable",
"OnyxTabs",
"OnyxTag",
"OnyxTextarea",
"OnyxTimer",
Expand Down Expand Up @@ -126,6 +129,7 @@ const tableData = [
];
const currentPage = ref(1);
const selectedTab = ref("tab-1");
</script>

<template>
Expand Down Expand Up @@ -255,6 +259,19 @@ const currentPage = ref(1);
</OnyxTable>
</template>

<OnyxTabs v-if="show('OnyxTabs')" v-model="selectedTab" label="Example tabs">
<OnyxTab label="Tab 1" value="tab-1">Tab panel content 1...</OnyxTab>
<OnyxTab value="tab-2">
Tab panel content 2...

<template #tab>
Tab 2
<OnyxBadge color="warning" dot />
</template>
</OnyxTab>
<OnyxTab label="Tab 3" value="tab-3">Tab panel content 3...</OnyxTab>
</OnyxTabs>

<OnyxTag v-if="show('OnyxTag')" label="Example tag" :icon="emojiHappy2" />

<OnyxTextarea
Expand Down
3 changes: 2 additions & 1 deletion packages/headless/src/composables/tabs/createTabs.testing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ export const tabsTesting = async (options: TabsTestingOptions) => {
await expectPanelAttributes(options.page.locator(`#${panelId}`), tabId);

// ACT (switch tab)
const tab2 = options.tablist.locator('[aria-selected="true"]').first();
let tab2 = options.tablist.locator('[aria-selected="false"]').first();
tab2 = options.tablist.locator(`#${await tab2.getAttribute("id")}`);
await tab2.click();

const { tabId: tabId2, panelId: panelId2 } = await expectTabAttributes(tab2, true);
Expand Down
4 changes: 2 additions & 2 deletions packages/headless/src/composables/tabs/createTabs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@ export const createTabs = createBuilder(<T extends PropertyKey>(options: CreateT
* 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 idMap = new Map<T, { tabId: string; panelId: string }>();

const getId = (value: PropertyKey) => {
const getId = (value: T) => {
if (!idMap.has(value)) {
idMap.set(value, { tabId: useId(), panelId: useId() });
}
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.
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.
24 changes: 24 additions & 0 deletions packages/sit-onyx/src/components/OnyxTab/OnyxTab.stories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { Meta, StoryObj } from "@storybook/vue3";
import OnyxTab from "./OnyxTab.vue";

/**
* A single tab component. Only intended to be used with the [OnyxTabs](/docs/navigation-tabs--docs) component.
*/
const meta: Meta<typeof OnyxTab> = {
title: "Support/Tab",
component: OnyxTab,
argTypes: {
tab: { control: { disable: true } },
},
};

export default meta;
type Story = StoryObj<typeof OnyxTab>;

export const Default = {
args: {
value: "tab-1",
label: "Tab 1",
default: "Panel content 1...",
},
} satisfies Story;
122 changes: 122 additions & 0 deletions packages/sit-onyx/src/components/OnyxTab/OnyxTab.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
<script lang="ts" setup>
import { computed, inject } from "vue";
import { useDensity } from "../../composables/density";
import { TABS_INJECTION_KEY } from "../OnyxTabs/types";
import type { OnyxTabProps } from "./types";
const props = defineProps<OnyxTabProps>();
defineSlots<{
/**
* Tab panel / content.
*/
default(): unknown;
/**
* Optional slot to override the tab content. By default, the `label` property will be displayed.
*/
tab?(): unknown;
}>();
const { densityClass } = useDensity(props);
const tabsContext = inject(TABS_INJECTION_KEY);
const tab = computed(() => tabsContext?.headless.elements.tab.value({ value: props.value }));
</script>

<template>
<button
:class="[
'onyx-tab',
'onyx-text--large',
densityClass,
tab?.['aria-selected'] ? 'onyx-tab--selected' : '',
]"
v-bind="tab"
type="button"
>
<div class="onyx-tab__label">
<slot name="tab">{{ props.label }}</slot>
</div>
</button>
<!-- The <Teleport> is used because we want to offer a nice API for the user
so he can provide both tab and the panel content in one "OnyxTab" component.
However, for the accessibility pattern (see https://www.w3.org/WAI/ARIA/apg/patterns/tabs/),
we need a separated HTML structure where the tab and the panel must not be nested.
The <Teleport> will allow us to achieve this by moving the panel content to the `OnyxTabs` component.
-->
<Teleport :to="tabsContext?.panelRef.value" :disabled="!tabsContext?.panelRef.value" defer>
<div
v-if="tab?.['aria-selected']"
v-bind="tabsContext?.headless.elements.tabpanel.value({ value: props.value })"
class="onyx-tab__panel"
>
<slot></slot>
</div>
</Teleport>
</template>
<style lang="scss">
@use "../../styles/mixins/layers.scss";
.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;
font-weight: 600;
// reset button styles
border: none;
background-color: transparent;
&--selected {
color: var(--onyx-color-text-icons-neutral-intense);
.onyx-tab__label {
&::after {
content: "";
height: 0.125rem;
background-color: var(--onyx-color-base-primary-500);
width: calc(100% - 2 * var(--onyx-density-xs));
min-width: 1rem;
position: absolute;
left: 50%;
bottom: 0;
transform: translateX(-50%);
}
}
}
&:hover,
&:focus-visible {
background-color: var(--onyx-color-base-neutral-200);
}
&:focus-visible {
outline: 0.25rem solid var(--onyx-color-base-primary-200);
}
&:active {
color: var(--onyx-color-text-icons-primary-bold);
}
&__label {
display: flex;
align-items: center;
justify-content: center;
gap: var(--onyx-density-xs);
position: relative;
padding-bottom: var(--onyx-density-3xs);
}
&__panel {
font-family: var(--onyx-font-family);
color: var(--onyx-color-text-icons-neutral-intense);
}
}
}
</style>
12 changes: 12 additions & 0 deletions packages/sit-onyx/src/components/OnyxTab/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { DensityProp } from "../../composables/density";

export type OnyxTabProps = DensityProp & {
/**
* Value of the tab when its selected. Will be the `modelValue` / `v-model` of the `OnyxTabs` component.
*/
value: PropertyKey;
/**
* Tab label to display. Alternatively, the `tab` slot can be used.
*/
label?: string;
};
101 changes: 101 additions & 0 deletions packages/sit-onyx/src/components/OnyxTabs/OnyxTabs.ct.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { tabsTesting } from "@sit-onyx/headless/playwright";
import { DENSITIES } from "../../composables/density";
import { expect, test } from "../../playwright/a11y";
import { executeMatrixScreenshotTest, mockPlaywrightIcon } from "../../playwright/screenshots";
import OnyxBadge from "../OnyxBadge/OnyxBadge.vue";
import OnyxIcon from "../OnyxIcon/OnyxIcon.vue";
import OnyxTab from "../OnyxTab/OnyxTab.vue";
import OnyxTabs from "./OnyxTabs.vue";
import TestWrapperCt from "./TestWrapper.ct.vue";

for (const type of ["default", "stretched"] as const) {
test.describe(`Screenshot tests (${type})`, () => {
executeMatrixScreenshotTest({
name: `Tabs (${type})`,
columns: DENSITIES,
rows: ["default", "hover", "active", "focus-visible"],
// TODO: remove when contrast issues are fixed in https://github.com/SchwarzIT/onyx/issues/410
disabledAccessibilityRules: ["color-contrast"],
component: (column) => {
return (
<OnyxTabs
label="Example tabs"
modelValue="tab-1"
density={column}
stretched={type === "stretched"}
style={{ width: type === "stretched" ? "24rem" : undefined }}
>
<OnyxTab label="Tab 1" value="tab-1">
Panel content 1...
</OnyxTab>
<OnyxTab label="Tab 2" value="tab-2">
Panel content 2...
</OnyxTab>
</OnyxTabs>
);
},
beforeScreenshot: async (component, page, column, row) => {
const tab1 = component.getByRole("tab", { name: "Tab 1" });
if (row === "hover") await tab1.hover();
if (row === "focus-visible") await page.keyboard.press("Tab");
if (row === "active") {
const box = (await tab1.boundingBox())!;
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
}
},
});
});
}

test.describe("Screenshot tests (custom content)", () => {
executeMatrixScreenshotTest({
name: "Tabs (custom content)",
columns: DENSITIES,
rows: ["icon", "badge", "icon-badge"],
component: (column, row) => {
return (
<OnyxTabs label="Example tabs" modelValue="tab-1" density={column}>
<OnyxTab value="tab-1">
<span>Panel content 1...</span>

<template v-slot:tab>
{row.includes("icon") && <OnyxIcon icon={mockPlaywrightIcon} />}
<span>Tab 1</span>
{row.includes("badge") && <OnyxBadge color="warning" dot />}
</template>
</OnyxTab>

<OnyxTab value="tab-2">
<span>Panel content 2...</span>

<template v-slot:tab>
{row.includes("icon") && <OnyxIcon icon={mockPlaywrightIcon} />}
<span>Tab 2</span>
{row.includes("badge") && <OnyxBadge color="warning" dot />}
</template>
</OnyxTab>
</OnyxTabs>
);
},
});
});

test("should pass accessibility tests", async ({ mount, makeAxeBuilder, page }) => {
// ARRANGE
const component = await mount(<TestWrapperCt />);

// ACT
const accessibilityScanResults = await makeAxeBuilder()
// TODO: remove when contrast issues are fixed in https://github.com/SchwarzIT/onyx/issues/410
.disableRules(["color-contrast"])
.analyze();

// ASSERT
expect(accessibilityScanResults.violations).toEqual([]);

await tabsTesting({
page,
tablist: component.getByRole("tablist"),
});
});
51 changes: 51 additions & 0 deletions packages/sit-onyx/src/components/OnyxTabs/OnyxTabs.stories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import placeholder from "@sit-onyx/icons/placeholder.svg?raw";
import type { Meta, StoryObj } from "@storybook/vue3";
import { h } from "vue";
import OnyxBadge from "../OnyxBadge/OnyxBadge.vue";
import OnyxIcon from "../OnyxIcon/OnyxIcon.vue";
import OnyxTab from "../OnyxTab/OnyxTab.vue";
import OnyxTabs from "./OnyxTabs.vue";

const meta: Meta<typeof OnyxTabs> = {
title: "Navigation/Tabs",
component: OnyxTabs,
argTypes: {
default: { control: { disable: true } },
},
};

export default meta;
type Story = StoryObj<typeof OnyxTabs>;

export const Default = {
args: {
label: "Example tabs",
modelValue: "tab-1",
default: () => [
h(OnyxTab, { value: "tab-1", label: "Tab 1" }, "Panel content 1..."),
h(
OnyxTab,
{ value: "tab-2" },
{
default: "Panel content 2...",
tab: () => ["Tab 2", h(OnyxBadge, { dot: true, color: "warning" })],
},
),
h(
OnyxTab,
{ value: "tab-3" },
{
default: "Panel content 3...",
tab: () => [h(OnyxIcon, { icon: placeholder }), "Tab 3"],
},
),
],
},
} satisfies Story;

export const Stretched = {
args: {
...Default.args,
stretched: true,
},
} satisfies Story;
Loading

0 comments on commit fda8a30

Please sign in to comment.