Skip to content
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
63 changes: 32 additions & 31 deletions src/components/navigations/Tabs/Tabs.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite';

import * as React from 'react';

import { Tabs, Tab } from './Tabs.tsx';
import { type TabProps, Tabs, Tab } from './Tabs.tsx';


type TabsArgs = React.ComponentProps<typeof Tabs>;
Expand All @@ -26,34 +26,25 @@ export default {
render: (args) => <Tabs {...args}/>,
} satisfies Meta<TabsArgs>;

type DefaultTabOption = {
index: number,
className?: string,
};
const defaultTabOptions: DefaultTabOption[] = [1,2,3,4].map(index => {
return { index };
});
const defaultTabOptions: Array<TabProps> = [1,2,3,4].map(index => ({ tabKey: `${index}`, title: `Tab ${index}` }));

type TabWithTriggerProps = React.PropsWithChildren<Partial<TabsArgs>> & {
options?: undefined | Array<DefaultTabOption>,
options?: undefined | Array<TabProps>,
defaultActiveTabKey?: undefined | string,
};
const TabWithTrigger = (props: TabWithTriggerProps) => {
const { options = defaultTabOptions, defaultActiveTabKey, ...tabContext } = props;

const [activeTabKey, setActiveTabKey] = React.useState<undefined | string>(defaultActiveTabKey);
const [activeTabKey, setActiveTabKey] = React.useState<undefined | string>(defaultActiveTabKey ?? '1');

return (
<Tabs onSwitch={setActiveTabKey} activeKey={activeTabKey} {...tabContext}>
{options.map(tab => {
{options.map(tabProps => {
return (
<Tab
key={tab.index}
data-label={`tab${tab.index}`}
tabKey={`tab${tab.index}`}
title={`Tab ${tab.index}`}
render={() => <>Tab {tab.index} contents</>}
className={tab.className}
key={tabProps.tabKey}
render={() => <>Tab {tabProps.tabKey} contents</>}
{...tabProps}
/>
)
})}
Expand All @@ -67,30 +58,25 @@ const BaseStory: StoryWithTrigger = {
render: (args) => <TabWithTrigger {...args} />,
};

export const Standard: StoryWithTrigger = {
export const TabsStandard: StoryWithTrigger = {
...BaseStory,
name: 'Standard',
args: { ...BaseStory.args },
};

// TODO: This seems to not work atm
// See https://github.com/fortanix/baklava/issues/261
export const StandardDefaultActive: StoryWithTrigger = {
export const TabsWithDefaultActive: StoryWithTrigger = {
...BaseStory,
name: 'Standard [default active]',
args: {
...BaseStory.args,
defaultActiveTabKey: '1',
defaultActiveTabKey: '2',
},
};

export const StandardHover: StoryWithTrigger = {
export const TabsWithHover: StoryWithTrigger = {
...BaseStory,
name: 'Standard [hover]',
args: {
...BaseStory.args,
options: defaultTabOptions.map(option => {
if (option.index === 1) {
if (option.tabKey === '1') {
return {
...option,
className: 'pseudo-hover',
Expand All @@ -101,20 +87,35 @@ export const StandardHover: StoryWithTrigger = {
},
};

export const StandardFocus: StoryWithTrigger = {
export const TabsWithFocus: StoryWithTrigger = {
...BaseStory,
name: 'Standard [focus]',
args: {
...BaseStory.args,
options: defaultTabOptions.map(option => {
if (option.index === 1) {
if (option.tabKey === '1') {
return {
...option,
className: 'pseudo-focus-visible',
};
}
return option;
}),
defaultActiveTabKey: 'tab1',
},
};

/**
* In the following story, each tab should have a `data-label` attribute on their respective tab panels and buttons.
*/
export const TabsWithCustomProps: StoryWithTrigger = {
...BaseStory,
args: {
...BaseStory.args,
options: defaultTabOptions.map(option => ({
...option,
'data-label': `tab-content-${option.tabKey}`,
tabTriggerProps: {
'data-label': `tab-trigger-${option.tabKey}`,
},
})),
},
};
42 changes: 28 additions & 14 deletions src/components/navigations/Tabs/Tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,20 +13,29 @@ export { cl as TabsClassNames };
export type TabKey = string;

export type TabProps = ComponentProps<'div'> & {
children?: undefined | React.ReactNode,
/** A unique identifier for this tab. */
tabKey: string,
/** The contents of the tab, when active. */
children?: undefined | React.ReactNode,
/** A render prop to render the tab. Overrides `children`, if set. */
render?: undefined | (() => React.ReactNode),
/** The title of this tab, to be shown in the tab trigger button. */
title: React.ReactNode,
hide?: boolean,
contentClassName?: ClassNameArgument,
render?: () => React.ReactNode,
/** Whether to hide this tab. If true, the tab trigger is not rendered at all. */
hide?: undefined | boolean,
/** A class name to set on the tab trigger button. */
className?: undefined | ClassNameArgument,
/** A class name to set on the tab panel content. */
contentClassName?: undefined | ClassNameArgument,
/** Any additional props to pass to the tab trigger button. */
tabTriggerProps?: undefined | ComponentProps<'li'> & { [key: `data-${string}`]: string },
};
export const Tab = ({ children }: TabProps): React.ReactElement => {
return children as React.ReactElement;
};
type TabElement = React.ReactElement<TabProps, React.FunctionComponent<typeof Tab>>;


export type TabsProps = React.PropsWithChildren<ComponentProps<'div'> & {
export type TabsProps = ComponentProps<'div'> & {
/** Whether this component should be unstyled. */
unstyled?: undefined | boolean,

Expand All @@ -35,7 +44,7 @@ export type TabsProps = React.PropsWithChildren<ComponentProps<'div'> & {

/** Callback executed when active tab is changed. */
onSwitch: (tabKey: TabKey) => void,
}>;
};
/**
* A tab component
*/
Expand All @@ -54,7 +63,7 @@ export const Tabs = (props: TabsProps) => {

type ActiveTabProps = Omit<TabProps, 'children' | 'tabKey' | 'title' | 'hide' | 'render'>;
const getActiveTabProps = (tab: TabElement): ActiveTabProps => {
const { children, tabKey, title, hide, render, ...tabProps } = tab.props;
const { children, tabKey, title, hide, render, tabTriggerProps, ...tabProps } = tab.props;
return tabProps;
};

Expand Down Expand Up @@ -91,23 +100,28 @@ export const Tabs = (props: TabsProps) => {
>
<ul className={cx(cl['bk-tabs__switcher'])} role="tablist">
{tabs.map(tab => {
if (tab.props.hide) return null;
const isActive = tab.props.tabKey === activeKey;
const { tabKey, hide, tabTriggerProps } = tab.props;

if (hide) { return null; }

const isActive = tabKey === activeKey;

return (
<li
key={tab.props.tabKey}
key={tabKey}
role="tab"
tabIndex={0}
aria-selected={isActive}
data-tab={tab.props.tabKey}
className={cx(cl['bk-tabs__switcher__tab'],tab.props.className)}
data-tab={tabKey}
{...tabTriggerProps}
className={cx(cl['bk-tabs__switcher__tab'], tabTriggerProps?.className)}
onClick={() => { onSwitch(tab.props.tabKey); }} // FIXME: add a Button and use that instead
>
<span>{tab.props.title}</span>
{/* Hidden duplicated title, used to prevent layout shifts on hover. */}
<span aria-hidden className={cx(cl['bk-tabs__switcher__tab__hover-placeholder'])}>{tab.props.title}</span>
</li>
)
);
})}
</ul>

Expand Down