diff --git a/packages/design-system/src/components/Tabs/Primitive/TabPanel.tsx b/packages/design-system/src/components/Tabs/Primitive/TabPanel.tsx index b9e8ff73d25..fda169b4cae 100644 --- a/packages/design-system/src/components/Tabs/Primitive/TabPanel.tsx +++ b/packages/design-system/src/components/Tabs/Primitive/TabPanel.tsx @@ -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; diff --git a/packages/design-system/src/components/Tabs/Primitive/TabsProvider.tsx b/packages/design-system/src/components/Tabs/Primitive/TabsProvider.tsx index ff899fc2a46..868f635515e 100644 --- a/packages/design-system/src/components/Tabs/Primitive/TabsProvider.tsx +++ b/packages/design-system/src/components/Tabs/Primitive/TabsProvider.tsx @@ -8,6 +8,7 @@ export type TabsProviderPropTypes = { activeKey?: string; onSelect?: (event: any, key: string) => void; size?: string; + id?: string; }; type WithChildren = { @@ -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} diff --git a/packages/design-system/src/components/Tabs/Tabs.test.tsx b/packages/design-system/src/components/Tabs/Tabs.test.tsx index 5b390ec67b3..4dc7bc4b17f 100644 --- a/packages/design-system/src/components/Tabs/Tabs.test.tsx +++ b/packages/design-system/src/components/Tabs/Tabs.test.tsx @@ -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(); + }); }); diff --git a/packages/design-system/src/components/Tabs/__snapshots__/Tabs.test.tsx.snap b/packages/design-system/src/components/Tabs/__snapshots__/Tabs.test.tsx.snap index e1d7fe398f7..2dfac657a9f 100644 --- a/packages/design-system/src/components/Tabs/__snapshots__/Tabs.test.tsx.snap +++ b/packages/design-system/src/components/Tabs/__snapshots__/Tabs.test.tsx.snap @@ -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" > diff --git a/packages/design-system/src/components/Tabs/index.ts b/packages/design-system/src/components/Tabs/index.ts index 8177fe1c79b..6a58005fd16 100644 --- a/packages/design-system/src/components/Tabs/index.ts +++ b/packages/design-system/src/components/Tabs/index.ts @@ -1,3 +1 @@ -export * from './Primitive/TabsProvider'; -export * from './Primitive/Tabs'; -export * from './Primitive/TabPanel'; +export * from './variants/Tabs'; diff --git a/packages/design-system/src/components/Tabs/variants/Tabs.tsx b/packages/design-system/src/components/Tabs/variants/Tabs.tsx new file mode 100644 index 00000000000..09ed87a02f0 --- /dev/null +++ b/packages/design-system/src/components/Tabs/variants/Tabs.tsx @@ -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; diff --git a/packages/design-system/src/stories/navigation/Tabs.stories.tsx b/packages/design-system/src/stories/navigation/Tabs.stories.tsx index db9cee1ee68..08b72c89e94 100644 --- a/packages/design-system/src/stories/navigation/Tabs.stories.tsx +++ b/packages/design-system/src/stories/navigation/Tabs.stories.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { StackHorizontal, StackVertical, Tabs, TabsProvider, Tab, TabPanel } from '../../'; +import { StackHorizontal, StackVertical, Tabs } from '../../'; export default { component: Tabs, title: 'Navigation/Tabs' }; @@ -7,96 +7,122 @@ 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</>, + }, + ]} + /> +);