Skip to content

Commit

Permalink
feat: restore design system Tabs api (#4949)
Browse files Browse the repository at this point in the history
  • Loading branch information
jmfrancois authored Oct 23, 2023
1 parent 96d6884 commit 59cf99b
Show file tree
Hide file tree
Showing 7 changed files with 230 additions and 77 deletions.
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 @@ -8,6 +8,7 @@ export type TabsProviderPropTypes = {
activeKey?: string;
onSelect?: (event: any, key: string) => void;
size?: string;
id?: string;
};

type WithChildren = {
Expand All @@ -29,7 +30,7 @@ export function TabsProvider(props: TabsProviderPropTypes & WithChildren) {
},
});
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</>,
},
]}
/>
);

0 comments on commit 59cf99b

Please sign in to comment.