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: restore design system Tabs api #4949

Merged
merged 4 commits into from
Oct 23, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useContext } from 'react';
import { TabsInternalContext } from './TabsProvider';

type TabPanelPropTypes = {
export type TabPanelPropTypes = {
id: string;
children: React.ReactNode | React.ReactNode[];
renderIf?: boolean;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@
export type TabsProviderPropTypes = {
defaultActiveKey?: string;
activeKey?: string;
onSelect?: (event: any, key: string) => void;

Check warning on line 9 in packages/design-system/src/components/Tabs/Primitive/TabsProvider.tsx

View workflow job for this annotation

GitHub Actions / ESLint Report Analysis

packages/design-system/src/components/Tabs/Primitive/TabsProvider.tsx#L9

[@typescript-eslint/no-explicit-any] Unexpected any. Specify a different type.
size?: string;
id?: string;
};

type WithChildren = {
Expand All @@ -24,12 +25,12 @@
defaultValueKey: 'defaultActiveKey',
onChangeKey: 'onSelect',
defaultValue: '',
selector: (e: any, id: string) => {

Check warning on line 28 in packages/design-system/src/components/Tabs/Primitive/TabsProvider.tsx

View workflow job for this annotation

GitHub Actions / ESLint Report Analysis

packages/design-system/src/components/Tabs/Primitive/TabsProvider.tsx#L28

[@typescript-eslint/no-explicit-any] Unexpected any. Specify a different type.
return id;
},
});
return (
<nav>
<nav id={props.id}>
<StackVertical gap="M">
<TabsInternalContext.Provider value={{ size: props.size, ...controlled }}>
{props.children}
Expand Down
56 changes: 45 additions & 11 deletions packages/design-system/src/components/Tabs/Tabs.test.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,60 @@
import { describe, it, expect } from '@jest/globals';
import { axe } from 'jest-axe';
import { render } from '@testing-library/react';
import { Tabs, TabPanel, Tab, TabsProvider } from './';
import { Tabs } from './';

jest.mock('@talend/utils', () => {
let i = 0;
return {
// we need stable but different uuid (is fixed to 42 by current mock)
randomUUID: () => `mocked-uuid-${i++}`,
};
});

describe('Tabs', () => {
it('should render accessible html', async () => {
// note we need to add the aria-label to be accessible
// TODO: make it required
const { container } = render(
<TabsProvider defaultActiveKey="profile">
<Tabs>
<Tab aria-controls="home" title="Home" />
<Tab aria-controls="profile" title="Profile" />
<Tab aria-controls="contact" title="Contact" disabled />
</Tabs>
<TabPanel id="home">Tab content for Home</TabPanel>
<TabPanel id="profile">Tab content for Profile</TabPanel>
<TabPanel id="contact">Tab content for Contact</TabPanel>
</TabsProvider>,
<Tabs.Container id="kit" defaultActiveKey="profile">
<Tabs.List>
<Tabs.Tab aria-controls="home" title="Home" />
<Tabs.Tab aria-controls="profile" title="Profile" />
<Tabs.Tab aria-controls="contact" title="Contact" disabled />
</Tabs.List>
<Tabs.Panel id="home">Tab content for Home</Tabs.Panel>
<Tabs.Panel id="profile">Tab content for Profile</Tabs.Panel>
<Tabs.Panel id="contact">Tab content for Contact</Tabs.Panel>
</Tabs.Container>,
);
expect(container.firstChild).toMatchSnapshot();
const results = await axe(document.body);
expect(results).toHaveNoViolations();
});
it('should render accessible html with old api', async () => {
render(
<Tabs
id="old"
tabs={[
{
tabTitle: 'Tabs 1',
tabContent: <>Tab 1</>,
},
{
tabTitle: 'Tabs 2',
tabContent: <>Tab 2</>,
},
{
tabTitle: {
title: 'Tabs 3',
icon: 'user',
},
tabContent: <>Tab 3</>,
},
]}
/>,
);
const results = await axe(document.body);
expect(results).toHaveNoViolations();
});
});
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`Tabs should render accessible html 1`] = `
<nav>
<nav
id="kit"
>
<div
class="theme-stack theme-justify-start theme-align-start theme-nowrap theme-column theme-block theme-gap-x-M theme-gap-y-M"
>
Expand Down
4 changes: 1 addition & 3 deletions packages/design-system/src/components/Tabs/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1 @@
export * from './Primitive/TabsProvider';
export * from './Primitive/Tabs';
export * from './Primitive/TabPanel';
export * from './variants/Tabs';
92 changes: 92 additions & 0 deletions packages/design-system/src/components/Tabs/variants/Tabs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { TabsProvider, TabsProviderPropTypes } from '../Primitive/TabsProvider';
import { Tabs as TabList, Tab, TabPropTypes } from '../Primitive/Tabs';
import { TabPanel, TabPanelPropTypes } from '../Primitive/TabPanel';
import { useEffect, useState } from 'react';
import { randomUUID } from '@talend/utils';

type TabTitlePropTypes = Omit<TabPropTypes, 'aria-controls'> & {
id?: string;
};

type TabItemPropTypes = {
tabTitle?: TabTitlePropTypes | string;
tabContent: React.ReactNode;
};

export type TabsProps = {
id?: string;
tabs: TabItemPropTypes[];
selectedId?: string;
size?: 'S' | 'M' | 'L';
};

export function Tabs(props: TabsProps) {
const [ids, setIds] = useState<string[]>([]);
useEffect(() => {
if (ids.length !== props.tabs.length) {
setIds(props.tabs.map(() => randomUUID()));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.tabs]);
if (props.tabs) {
const tabProviderProps: Partial<TabsProviderPropTypes> = {
id: props.id,
size: props.size,
defaultActiveKey: props.selectedId,
};
if (props.tabs.length > 0 && !props.selectedId) {
if (typeof props.tabs[0].tabTitle === 'string') {
tabProviderProps.defaultActiveKey = props.tabs[0].tabTitle;
} else if (typeof props.tabs[0].tabTitle === 'object') {
tabProviderProps.defaultActiveKey = props.tabs[0].tabTitle.id;
}
}
return (
<TabsProvider {...tabProviderProps}>
<TabList>
{props.tabs.map((tab: TabItemPropTypes, index: number) => {
const tabProps: Partial<TabPropTypes> = {};
if (typeof tab.tabTitle === 'string') {
tabProps['aria-controls'] = ids[index];
tabProps.title = tab.tabTitle;
} else if (typeof tab.tabTitle === 'object') {
tabProps['aria-controls'] = tab.tabTitle.id || ids[index];
tabProps.title = tab.tabTitle.title;
tabProps.icon = tab.tabTitle.icon;
tabProps.tag = tab.tabTitle.tag;
tabProps.tooltip = tab.tabTitle.tooltip;
tabProps.disabled = tab.tabTitle.disabled;
}
return <Tab key={index} {...(tabProps as TabPropTypes)} />;
})}
</TabList>
{props.tabs.map((tab: TabItemPropTypes, index: number) => {
const tabPanelProps: Partial<TabPanelPropTypes> = {};
if (typeof tab.tabTitle === 'string') {
tabPanelProps.id = ids[index];
} else if (typeof tab.tabTitle === 'object') {
tabPanelProps.id = tab.tabTitle.id || ids[index];
}
return (
<TabPanel key={index} {...(tabPanelProps as TabPanelPropTypes)}>
{tab.tabContent}
</TabPanel>
);
})}
</TabsProvider>
);
}
return null;
}

Tabs as typeof Tabs & {
Container: typeof TabsProvider;
List: typeof TabList;
Panel: typeof TabPanel;
Tab: typeof Tab;
};

Tabs.Container = TabsProvider;
Tabs.List = TabList;
Tabs.Panel = TabPanel;
Tabs.Tab = Tab;
146 changes: 86 additions & 60 deletions packages/design-system/src/stories/navigation/Tabs.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,102 +1,128 @@
import { useState } from 'react';
import { StackHorizontal, StackVertical, Tabs, TabsProvider, Tab, TabPanel } from '../../';
import { StackHorizontal, StackVertical, Tabs } from '../../';

export default { component: Tabs, title: 'Navigation/Tabs' };

export const Styles = () => (
<StackHorizontal gap="M" justify="spaceBetween">
<StackVertical gap="S" align="center">
<h2>Default</h2>
<TabsProvider defaultActiveKey="profile">
<Tabs>
<Tab aria-controls="home" title="Home" />
<Tab aria-controls="profile" title="Profile" />
<Tab aria-controls="contact" title="Contact" disabled />
</Tabs>
<TabPanel id="home">Tab content for Home</TabPanel>
<TabPanel id="profile">Tab content for Profile</TabPanel>
<TabPanel id="contact">Tab content for Contact</TabPanel>
</TabsProvider>
<Tabs.Container defaultActiveKey="profile">
<Tabs.List>
<Tabs.Tab aria-controls="home" title="Home" />
<Tabs.Tab aria-controls="profile" title="Profile" />
<Tabs.Tab aria-controls="contact" title="Contact" disabled />
</Tabs.List>
<Tabs.Panel id="home">Tab content for Home</Tabs.Panel>
<Tabs.Panel id="profile">Tab content for Profile</Tabs.Panel>
<Tabs.Panel id="contact">Tab content for Contact</Tabs.Panel>
</Tabs.Container>
</StackVertical>
<StackVertical gap="S" align="center">
<h2>Large</h2>
<TabsProvider size="L" defaultActiveKey="profile">
<Tabs>
<Tab aria-controls="home" title="Home" />
<Tab aria-controls="profile" title="Profile" />
<Tab aria-controls="contact" title="Contact" disabled />
</Tabs>
<TabPanel id="home">Tab content for Home</TabPanel>
<TabPanel id="profile">Tab content for Profile</TabPanel>
<TabPanel id="contact">Tab content for Contact</TabPanel>
</TabsProvider>
<Tabs.Container size="L" defaultActiveKey="profile">
<Tabs.List>
<Tabs.Tab aria-controls="home" title="Home" />
<Tabs.Tab aria-controls="profile" title="Profile" />
<Tabs.Tab aria-controls="contact" title="Contact" disabled />
</Tabs.List>
<Tabs.Panel id="home">Tab content for Home</Tabs.Panel>
<Tabs.Panel id="profile">Tab content for Profile</Tabs.Panel>
<Tabs.Panel id="contact">Tab content for Contact</Tabs.Panel>
</Tabs.Container>
</StackVertical>
</StackHorizontal>
);

export const TabsWithIcon = () => (
<TabsProvider defaultActiveKey="profile">
<Tabs>
<Tab aria-controls="user" title="User" icon="user" />
<Tab aria-controls="calendar" title="Calendar" icon="calendar" />
<Tab aria-controls="favorite" title="Favorite" icon="star" disabled />
</Tabs>
<TabPanel id="user">Users tab content</TabPanel>
<TabPanel id="calendar">Calendar tab content</TabPanel>
<TabPanel id="favorite">Favorite tab content</TabPanel>
</TabsProvider>
<Tabs.Container defaultActiveKey="profile">
<Tabs.List>
<Tabs.Tab aria-controls="user" title="User" icon="user" />
<Tabs.Tab aria-controls="calendar" title="Calendar" icon="calendar" />
<Tabs.Tab aria-controls="favorite" title="Favorite" icon="star" disabled />
</Tabs.List>
<Tabs.Panel id="user">Users tab content</Tabs.Panel>
<Tabs.Panel id="calendar">Calendar tab content</Tabs.Panel>
<Tabs.Panel id="favorite">Favorite tab content</Tabs.Panel>
</Tabs.Container>
);

export const TabsWithTag = () => (
<TabsProvider defaultActiveKey="profile">
<Tabs>
<Tab aria-controls="user" title="User" icon="user" tag={13} />
<Tab aria-controls="calendar" title="Calendar" icon="calendar" tag={54} />
<Tab
<Tabs.Container defaultActiveKey="profile">
<Tabs.List>
<Tabs.Tab aria-controls="user" title="User" icon="user" tag={13} />
<Tabs.Tab aria-controls="calendar" title="Calendar" icon="calendar" tag={54} />
<Tabs.Tab
aria-controls="favorite"
title="Favorite"
icon="star"
tag="999+"
tooltip="1534 Favorite items"
/>
</Tabs>
<TabPanel id="user">Users tab content</TabPanel>
<TabPanel id="calendar">Calendar tab content</TabPanel>
<TabPanel id="favorite">Favorite tab content</TabPanel>
</TabsProvider>
</Tabs.List>
<Tabs.Panel id="user">Users tab content</Tabs.Panel>
<Tabs.Panel id="calendar">Calendar tab content</Tabs.Panel>
<Tabs.Panel id="favorite">Favorite tab content</Tabs.Panel>
</Tabs.Container>
);

export const TabsWithLongTitles = () => (
<TabsProvider defaultActiveKey="user">
<Tabs>
<Tab aria-controls="user" title="User" icon="user" tag={13} />
<Tab
<Tabs.Container defaultActiveKey="user">
<Tabs.List>
<Tabs.Tab aria-controls="user" title="User" icon="user" tag={13} />
<Tabs.Tab
aria-controls="notification"
title="A much too long title that will trigger the overflow limit"
icon="information-stroke"
tag="999+"
tooltip="1239 notifications - A much too long title that will trigger the overflow limit"
/>
</Tabs>
<TabPanel id="user">Users tab content</TabPanel>
<TabPanel id="notification">
</Tabs.List>
<Tabs.Panel id="user">Users tab content</Tabs.Panel>
<Tabs.Panel id="notification">
<h2>About tab content</h2>
</TabPanel>
</TabsProvider>
</Tabs.Panel>
</Tabs.Container>
);

export const TabStandaloneControlled = () => {
const [key, setKey] = useState<string>('home');
return (
<TabsProvider activeKey={key} onSelect={(e, k) => setKey(k)}>
<Tabs>
<Tab aria-controls="home" title="Home" />
<Tab aria-controls="profile" title="Profile" />
<Tab aria-controls="contact" title="Contact" disabled />
</Tabs>
<TabPanel id="home">Tab content for Home</TabPanel>
<TabPanel id="profile">Tab content for Profile</TabPanel>
<TabPanel id="contact">Tab content for Contact</TabPanel>
</TabsProvider>
<Tabs.Container activeKey={key} onSelect={(e, k) => setKey(k)}>
<Tabs.List>
<Tabs.Tab aria-controls="home" title="Home" />
<Tabs.Tab aria-controls="profile" title="Profile" />
<Tabs.Tab aria-controls="contact" title="Contact" disabled />
</Tabs.List>
<Tabs.Panel id="home">Tab content for Home</Tabs.Panel>
<Tabs.Panel id="profile">Tab content for Profile</Tabs.Panel>
<Tabs.Panel id="contact">Tab content for Contact</Tabs.Panel>
</Tabs.Container>
);
};

export const TabAPI = () => (
<Tabs
tabs={[
{
tabTitle: 'Tabs 1',
tabContent: <>Tab 1</>,
},
{
tabTitle: {
title: 'Tabs 2',
},
tabContent: <>Tab 2</>,
},
{
tabTitle: {
title: 'Tabs 3',
icon: 'user',
tag: '999+',
tooltip: '1534 Favorite',
},
tabContent: <>Tab 3</>,
},
]}
/>
);
Loading