165 lines
4.5 KiB
TypeScript
165 lines
4.5 KiB
TypeScript
import clsx from "clsx";
|
|
import {
|
|
FC,
|
|
HTMLAttributes,
|
|
ReactNode,
|
|
useEffect,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
|
|
import ChevronDown from "@dndbeyond/fontawesome-cache/svgs/solid/chevron-down.svg";
|
|
|
|
import { Popover } from "../Popover";
|
|
import styles from "./styles.module.css";
|
|
|
|
/**
|
|
* This component renders a set of tabs with associated content panels.
|
|
* It supports different variants for tab behavior, including default,
|
|
* collapse, and toggle modes. Tabs can be limited in number of visible
|
|
* items, with overflow handled via a "More" dropdown menu.
|
|
*
|
|
** Variants:
|
|
* - default: Standard tab behavior where clicking a tab shows its content.
|
|
* - collapse: Always has a selected tab, but clicking it again collapses the content.
|
|
* - fullwidth: Tabs stretch to fill the container width.
|
|
*/
|
|
|
|
interface TabItem {
|
|
label: ReactNode;
|
|
content: ReactNode;
|
|
className?: string;
|
|
id: string;
|
|
}
|
|
|
|
interface TabListProps extends HTMLAttributes<HTMLElement> {
|
|
tabs: Array<TabItem | null>;
|
|
variant?: "default" | "collapse" | "fullwidth";
|
|
defaultActiveId?: string;
|
|
maxNavItemsShow?: number;
|
|
onChangeTab?: (id: string) => void;
|
|
themed?: boolean;
|
|
forceShow?: boolean;
|
|
}
|
|
|
|
export const TabList: FC<TabListProps> = ({
|
|
className,
|
|
tabs,
|
|
defaultActiveId,
|
|
maxNavItemsShow = 7,
|
|
variant = "default",
|
|
onChangeTab,
|
|
themed,
|
|
forceShow,
|
|
...props
|
|
}) => {
|
|
const tabListRef = useRef<HTMLDivElement>(null!);
|
|
const [activeTab, setActiveTab] = useState(defaultActiveId || tabs[0]?.id);
|
|
const [isTabVisible, setIsTabVisible] = useState(
|
|
forceShow ?? activeTab !== ""
|
|
);
|
|
|
|
const tabsWithContent = tabs.filter((tab) => tab);
|
|
const visibleTabs = tabsWithContent.slice(0, maxNavItemsShow);
|
|
const hiddenTabs = tabsWithContent.slice(maxNavItemsShow);
|
|
const selectedTab = tabs.find((tab) => tab?.id === activeTab);
|
|
const isToggleable = variant === "collapse";
|
|
|
|
const handleTabClick = (id: string) => {
|
|
if (id === activeTab && variant === "collapse") {
|
|
setIsTabVisible(!isTabVisible);
|
|
}
|
|
|
|
if (id !== activeTab) {
|
|
setActiveTab(id);
|
|
setIsTabVisible(true);
|
|
|
|
if (onChangeTab) {
|
|
onChangeTab(id);
|
|
}
|
|
}
|
|
};
|
|
|
|
useEffect(
|
|
() => {
|
|
// Update CSS variable for number of tabs
|
|
const tabListStyles = tabListRef.current?.style;
|
|
if (tabListStyles) {
|
|
tabListStyles.setProperty("--tab-count", tabs.length.toString());
|
|
tabListStyles.setProperty(
|
|
"--tab-active-position",
|
|
tabs.indexOf(selectedTab as TabItem).toString()
|
|
);
|
|
}
|
|
},
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
[tabs.length, selectedTab]
|
|
);
|
|
|
|
return (
|
|
<div
|
|
className={clsx([
|
|
styles.tabList,
|
|
styles[variant],
|
|
themed && styles.themed,
|
|
className,
|
|
])}
|
|
ref={tabListRef}
|
|
{...props}
|
|
>
|
|
<menu className={styles.tabs}>
|
|
{/* List of visible tabs */}
|
|
{visibleTabs.map((tab: TabItem) => (
|
|
<li key={tab.id}>
|
|
<button
|
|
className={styles.tabButton}
|
|
role="radio"
|
|
aria-checked={activeTab === tab.id}
|
|
onClick={() => handleTabClick(tab.id)}
|
|
data-testid={tab.id}
|
|
>
|
|
{tab.label}
|
|
{isToggleable && (
|
|
<ChevronDown
|
|
className={clsx([!isTabVisible && styles.closed])}
|
|
/>
|
|
)}
|
|
</button>
|
|
</li>
|
|
))}
|
|
|
|
{/* Toggle and menu for hidden tabs */}
|
|
{hiddenTabs.length > 0 && (
|
|
<li>
|
|
<Popover
|
|
className={styles.morePopover}
|
|
variant="menu"
|
|
trigger={<button className={styles.tabButton}>More</button>}
|
|
>
|
|
<menu className={styles.moreMenu}>
|
|
{hiddenTabs.map((tab: TabItem) => (
|
|
<li key={tab.id}>
|
|
<button
|
|
className={styles.tabButton}
|
|
role="radio"
|
|
aria-checked={activeTab === tab.id}
|
|
onClick={() => handleTabClick(tab.id)}
|
|
>
|
|
{tab.label}
|
|
</button>
|
|
</li>
|
|
))}
|
|
</menu>
|
|
</Popover>
|
|
</li>
|
|
)}
|
|
</menu>
|
|
|
|
{/* Content of the active tab */}
|
|
<div className={clsx([styles.tabContent, selectedTab?.className])}>
|
|
{isTabVisible && activeTab !== "" && selectedTab?.content}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|