[T-4867: feat] Horizontal and vertical tabs component (#84)

* ️ feat: create tabs component

* ♻️ refactor: avoid big conflict on the example page

* 🔧 chore: upgrade tailwindcss and install `@radix-ui/react-tabs`

* ️ feat: create globe icon component

* 🎨 style: adjust vertical tab theme

* 📝 docs: add vertical tabs to the example page
This commit is contained in:
Wahyu Kurniawan
2024-02-21 16:13:16 +07:00
committed by GitHub
parent b5eef95d15
commit ea44efa0f2
20 changed files with 630 additions and 183 deletions
@@ -0,0 +1,20 @@
import React from 'react';
import { CustomIcon, CustomIconProps } from './CustomIcon';
export const GlobeIcon = (props: CustomIconProps) => {
return (
<CustomIcon
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
{...props}
>
<path
d="M10 17.9167C14.3723 17.9167 17.9167 14.3723 17.9167 10C17.9167 5.62776 14.3723 2.08334 10 2.08334M10 17.9167C5.62776 17.9167 2.08334 14.3723 2.08334 10C2.08334 5.62776 5.62776 2.08334 10 2.08334M10 17.9167C8.15906 17.9167 6.66668 14.3723 6.66668 10C6.66668 5.62776 8.15906 2.08334 10 2.08334M10 17.9167C11.841 17.9167 13.3333 14.3723 13.3333 10C13.3333 5.62776 11.841 2.08334 10 2.08334M17.5 10H2.50001"
stroke="currentColor"
strokeLinecap="square"
/>
</CustomIcon>
);
};
@@ -4,3 +4,4 @@ export * from './CheckIcon';
export * from './ChevronGrabberHorizontal';
export * from './ChevronLeft';
export * from './ChevronRight';
export * from './GlobeIcon';
@@ -0,0 +1,80 @@
import { tv, type VariantProps } from 'tailwind-variants';
export type TabsVariants = VariantProps<typeof tabsTheme>;
export const tabsTheme = tv({
slots: {
root: ['flex', 'data-[orientation=horizontal]:w-full'],
triggerWrapper: [
// Horizontal default
'px-1',
'pb-5',
'text-elements-low-em',
'border-b-2',
'border-transparent',
'hover:border-border-interactive/10',
'hover:text-elements-mid-em',
'focus-within:border-border-interactive/10',
'data-[state=active]:font-medium',
'data-[state=active]:text-elements-high-em',
'data-[state=active]:border-elements-high-em',
// Vertical
'data-[orientation=vertical]:px-3',
'data-[orientation=vertical]:py-3',
'data-[orientation=vertical]:min-w-[240px]',
'data-[orientation=vertical]:focus-ring',
'data-[orientation=vertical]:rounded-xl',
'data-[orientation=vertical]:border-transparent',
'data-[orientation=vertical]:hover:bg-base-bg-emphasized',
'data-[orientation=vertical]:hover:text-elements-mid-em',
'data-[orientation=vertical]:hover:border-transparent',
'data-[orientation=vertical]:focus-visible:border-transparent',
'data-[orientation=vertical]:focus-visible:bg-base-bg-emphasized',
'data-[orientation=vertical]:focus-visible:text-elements-mid-em',
'data-[orientation=vertical]:data-[state=active]:font-normal',
'data-[orientation=vertical]:data-[state=active]:bg-base-bg-emphasized',
'data-[orientation=vertical]:data-[state=active]:border-transparent',
'data-[orientation=vertical]:data-[state=active]:hover:text-elements-high-em',
'data-[orientation=vertical]:data-[state=active]:focus-visible:text-elements-high-em',
],
trigger: [
'flex',
'gap-1.5',
'cursor-default',
'select-none',
'items-center',
'justify-center',
'outline-none',
'leading-none',
'tracking-[-0.006em]',
'rounded-md',
// Horizontal default
'data-[orientation=horizontal]:focus-ring',
// Vertical
'data-[orientation=vertical]:gap-2',
],
triggerList: [
'flex',
'shrink-0',
'gap-5',
'border-b',
'border-transparent',
// Horizontal default
'data-[orientation=horizontal]:border-border-interactive/10',
// Vertical
'data-[orientation=vertical]:flex-col',
'data-[orientation=vertical]:gap-0.5',
],
content: ['text-elements-high-em', 'grow', 'outline-none', 'tab-content'],
},
variants: {
fillWidth: {
true: {
trigger: ['flex-1'],
},
},
},
defaultVariants: {
fillWidth: false,
},
});
@@ -0,0 +1,51 @@
import React, { type ComponentPropsWithoutRef } from 'react';
import { Root as TabsRoot } from '@radix-ui/react-tabs';
import { tabsTheme } from './Tabs.theme';
import TabsContent from './TabsContent';
import TabsList from './TabsList';
import TabsTrigger from './TabsTrigger';
import TabsProvider, { TabsProviderProps } from './TabsProvider';
export interface TabsProps extends ComponentPropsWithoutRef<typeof TabsRoot> {
/**
* The configuration for the tabs component.
*/
config?: TabsProviderProps;
}
/**
* A component that allows users to switch between different tabs.
* @returns JSX element representing the tabs component.
*/
export const Tabs = ({
config,
className,
orientation = 'horizontal',
...props
}: TabsProps) => {
const { root } = tabsTheme(config);
return (
<TabsProvider {...config} orientation={orientation}>
<TabsRoot
{...props}
orientation={orientation}
className={root({ className })}
/>
</TabsProvider>
);
};
/**
* Assigns the TabsTrigger class to the Trigger property of the Tabs object.
*/
Tabs.Trigger = TabsTrigger;
/**
* Assigns the TabsList object to the List property of the Tabs object.
*/
Tabs.List = TabsList;
/**
* Assigns the TabsContent component to the Content property of the Tabs component.
*/
Tabs.Content = TabsContent;
@@ -0,0 +1,26 @@
import React, {
forwardRef,
type ComponentPropsWithoutRef,
type ElementRef,
} from 'react';
import { Content } from '@radix-ui/react-tabs';
import { tabsTheme } from '../Tabs.theme';
export interface TabsContentProps
extends ComponentPropsWithoutRef<typeof Content> {}
/**
* A component that represents the content of the tabs component.
*/
const TabsContent = forwardRef<ElementRef<typeof Content>, TabsContentProps>(
({ className, ...props }, ref) => {
const { content } = tabsTheme();
return <Content ref={ref} className={content({ className })} {...props} />;
},
);
// Assigns the display name to the TabsContent component.
TabsContent.displayName = 'TabsContent';
export { TabsContent };
@@ -0,0 +1,3 @@
import { TabsContent } from './TabsContent';
export default TabsContent;
@@ -0,0 +1,25 @@
import React, {
forwardRef,
type ComponentPropsWithoutRef,
type ElementRef,
} from 'react';
import { List } from '@radix-ui/react-tabs';
import { tabsTheme } from 'components/shared/Tabs/Tabs.theme';
export interface TabsListProps extends ComponentPropsWithoutRef<typeof List> {}
/**
* A component that represents the list of tabs.
*/
const TabsList = forwardRef<ElementRef<typeof List>, TabsListProps>(
({ className, ...props }, ref) => {
const { triggerList } = tabsTheme({ className });
return <List ref={ref} className={triggerList({ className })} {...props} />;
},
);
// Assigns the display name to the TabsList component.
TabsList.displayName = 'TabsList';
export { TabsList };
@@ -0,0 +1,3 @@
import { TabsList } from './TabsList';
export default TabsList;
@@ -0,0 +1,47 @@
import React, {
createContext,
useContext,
type PropsWithChildren,
ComponentPropsWithoutRef,
} from 'react';
import { TabsVariants } from './Tabs.theme';
import { Root as TabsRoot } from '@radix-ui/react-tabs';
export interface TabsProviderProps
extends Partial<TabsVariants>,
ComponentPropsWithoutRef<typeof TabsRoot> {}
type TabsProviderContext = ReturnType<typeof useTabsValues>;
const TabsContext = createContext<Partial<TabsProviderContext>>({});
// For inferring return type
const useTabsValues = (props: TabsProviderProps) => {
return props;
};
/**
* A provider component that allows users to switch between different tabs.
* @returns JSX element representing the tabs provider component.
*/
export const TabsProvider = ({
children,
...props
}: PropsWithChildren<TabsProviderProps>): JSX.Element => {
const values = useTabsValues(props);
return <TabsContext.Provider value={values}>{children}</TabsContext.Provider>;
};
/**
* A hook that returns the context of the tabs provider.
* @returns The context of the tabs provider.
*/
export const useTabs = () => {
const context = useContext(TabsContext);
if (context === undefined) {
throw new Error('useTabs was used outside of its Provider');
}
return context;
};
export default TabsProvider;
@@ -0,0 +1,59 @@
import React, {
forwardRef,
type ComponentPropsWithoutRef,
type ElementRef,
type PropsWithChildren,
type ReactNode,
} from 'react';
import { Trigger } from '@radix-ui/react-tabs';
import { tabsTheme } from 'components/shared/Tabs/Tabs.theme';
import { useTabs } from 'components/shared/Tabs/TabsProvider';
export interface TabsTriggerProps
extends ComponentPropsWithoutRef<typeof Trigger> {
/**
* The icon to display in the trigger.
*/
icon?: ReactNode;
}
/**
* A component that represents the trigger for the tabs component.
*/
const TabsTrigger = forwardRef<
ElementRef<typeof Trigger>,
PropsWithChildren<TabsTriggerProps>
>(({ className, icon, children, ...props }, ref) => {
const config = useTabs();
const { triggerWrapper, trigger } = tabsTheme(config);
const orientation = config.orientation;
return (
<Trigger
ref={ref}
// Disabled focus state for horizontal tabs
tabIndex={orientation === 'horizontal' ? -1 : undefined}
className={triggerWrapper({ className })}
{...props}
>
{/* Need to add button in the trigger children because there's focus state inside the children */}
<button
data-orientation={orientation}
// Disabled focus state for vertical tabs
tabIndex={orientation === 'vertical' ? -1 : undefined}
className={trigger()}
>
{/* Put the icon on the left of the text for veritcal tab */}
{orientation === 'vertical' && icon}
{children}
{/* Put the icon on the right of the text for horizontal tab */}
{orientation === 'horizontal' && icon}
</button>
</Trigger>
);
});
TabsTrigger.displayName = 'TabsTrigger';
export { TabsTrigger };
@@ -0,0 +1,3 @@
import { TabsTrigger } from './TabsTrigger';
export default TabsTrigger;
@@ -0,0 +1 @@
export * from './Tabs';