244 lines
6.8 KiB
TypeScript
244 lines
6.8 KiB
TypeScript
import React, { useEffect, useMemo } from "react";
|
|
import { motion } from "framer-motion";
|
|
import { checkIsMobile } from "../../utils/checkIsMobile";
|
|
import { useTabStore } from "../../context/zustand-store/appStore";
|
|
|
|
type Tab = {
|
|
label: string;
|
|
visible?: boolean;
|
|
disabled?: boolean;
|
|
path?: string;
|
|
};
|
|
|
|
type TabsProps = {
|
|
tabs: Tab[];
|
|
onChange: (index: number) => void;
|
|
size?: "small" | "medium" | "large";
|
|
tabKey?: string;
|
|
};
|
|
|
|
const Tabs: React.FC<TabsProps> = ({
|
|
tabs,
|
|
onChange,
|
|
size = "medium",
|
|
tabKey = "default",
|
|
}) => {
|
|
const isMobile = checkIsMobile();
|
|
const { tabState, setActiveTab } = useTabStore();
|
|
|
|
const isTabVisible = (tab: Tab): boolean => {
|
|
if (tab.visible === false) return false;
|
|
return tab.visible === undefined || tab.visible === true;
|
|
};
|
|
|
|
const visibleTabs = useMemo(() => tabs.filter(isTabVisible), [tabs]);
|
|
const hasSingleVisibleTab = visibleTabs.length === 1;
|
|
|
|
const getFirstVisibleTabIndex = () => {
|
|
return tabs.findIndex(isTabVisible);
|
|
};
|
|
|
|
const isValidTabIndex = (index: number) => {
|
|
return index >= 0 && index < tabs.length && isTabVisible(tabs[index]);
|
|
};
|
|
|
|
const storedTab = tabState.activeTabs?.[tabKey];
|
|
const storedIndex = storedTab?.index ?? 0;
|
|
const firstVisibleIndex = getFirstVisibleTabIndex();
|
|
const selectedIndex = isValidTabIndex(storedIndex)
|
|
? storedIndex
|
|
: firstVisibleIndex !== -1
|
|
? firstVisibleIndex
|
|
: 0;
|
|
|
|
useEffect(() => {
|
|
if (hasSingleVisibleTab) {
|
|
const singleTabIndex = tabs.findIndex(isTabVisible);
|
|
if (singleTabIndex !== -1 && singleTabIndex !== selectedIndex) {
|
|
setActiveTab({
|
|
index: singleTabIndex,
|
|
path: tabs[singleTabIndex]?.path || window.location.pathname,
|
|
label: tabs[singleTabIndex].label,
|
|
tabKey,
|
|
});
|
|
onChange(singleTabIndex);
|
|
}
|
|
}
|
|
}, [hasSingleVisibleTab, tabs, tabKey]);
|
|
|
|
useEffect(() => {
|
|
if (!isValidTabIndex(selectedIndex)) {
|
|
const firstVisible = getFirstVisibleTabIndex();
|
|
if (firstVisible !== -1) {
|
|
setActiveTab({
|
|
index: firstVisible,
|
|
path: tabs[firstVisible]?.path || window.location.pathname,
|
|
label: tabs[firstVisible].label,
|
|
tabKey,
|
|
});
|
|
onChange(firstVisible);
|
|
}
|
|
} else {
|
|
onChange(selectedIndex);
|
|
}
|
|
}, [tabs, selectedIndex, tabKey]);
|
|
|
|
useEffect(() => {
|
|
if (typeof window === "undefined") return;
|
|
|
|
const currentPath = window.location.pathname;
|
|
|
|
if (storedTab) {
|
|
const storedTabStillExists = tabs.some(
|
|
(tab) =>
|
|
tab.label === storedTab.label &&
|
|
(tab.path === storedTab.path || !tab.path) &&
|
|
isTabVisible(tab)
|
|
);
|
|
|
|
if (storedTabStillExists) {
|
|
const storedIndex = tabs.findIndex(
|
|
(tab) =>
|
|
tab.label === storedTab.label &&
|
|
(tab.path === storedTab.path || !tab.path) &&
|
|
isTabVisible(tab)
|
|
);
|
|
|
|
if (storedIndex !== -1 && storedIndex !== selectedIndex) {
|
|
setActiveTab({
|
|
index: storedIndex,
|
|
path: tabs[storedIndex]?.path || currentPath,
|
|
label: tabs[storedIndex].label,
|
|
tabKey,
|
|
});
|
|
onChange(storedIndex);
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
const pathMatchIndex = tabs.findIndex(
|
|
(tab) => tab.path === currentPath && isTabVisible(tab)
|
|
);
|
|
|
|
if (pathMatchIndex !== -1) {
|
|
setActiveTab({
|
|
index: pathMatchIndex,
|
|
path: currentPath,
|
|
label: tabs[pathMatchIndex].label,
|
|
tabKey,
|
|
});
|
|
onChange(pathMatchIndex);
|
|
return;
|
|
}
|
|
|
|
if (!isValidTabIndex(selectedIndex)) {
|
|
const firstVisible = getFirstVisibleTabIndex();
|
|
if (firstVisible !== -1) {
|
|
setActiveTab({
|
|
index: firstVisible,
|
|
path: tabs[firstVisible]?.path || currentPath,
|
|
label: tabs[firstVisible].label,
|
|
tabKey,
|
|
});
|
|
onChange(firstVisible);
|
|
}
|
|
}
|
|
}, [tabs, tabKey, storedTab, selectedIndex]);
|
|
|
|
const handleTabClick = (index: number) => {
|
|
if (!tabs[index].disabled) {
|
|
setActiveTab({
|
|
index,
|
|
path: tabs[index]?.path || window.location.pathname,
|
|
label: tabs[index].label,
|
|
tabKey,
|
|
});
|
|
onChange(index);
|
|
}
|
|
};
|
|
|
|
const sizeClasses: Record<NonNullable<TabsProps["size"]>, string> = {
|
|
small: "text-xs py-1 px-2",
|
|
medium: "text-sm py-1.5 px-3",
|
|
large: "text-base py-2 px-4",
|
|
};
|
|
|
|
if (hasSingleVisibleTab) {
|
|
return null;
|
|
}
|
|
|
|
if (isMobile) {
|
|
return (
|
|
<div className="flex flex-wrap justify-center gap-0.5 w-full">
|
|
{tabs.map((tab, index) =>
|
|
!isTabVisible(tab) ? null : (
|
|
<button
|
|
key={index}
|
|
onClick={() => handleTabClick(index)}
|
|
disabled={tab.disabled}
|
|
className={`text-center rounded-lg px-2 py-1 transition-colors duration-200 text-[11px] font-medium
|
|
${
|
|
tab.disabled
|
|
? "text-red-300 dark:text-red-400 bg-gray-200 opacity-60 dark:bg-dark-600 cursor-not-allowed"
|
|
: selectedIndex === index
|
|
? "bg-primary-100 text-primary-700"
|
|
: "bg-white text-gray-700 dark:bg-gray-200"
|
|
}`}
|
|
>
|
|
{tab.label}
|
|
</button>
|
|
)
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="w-full flex justify-center select-none">
|
|
<div className="relative flex gap-1 border-b border-gray-200">
|
|
{tabs.map((tab, index) =>
|
|
!isTabVisible(tab) ? null : (
|
|
<button
|
|
key={index}
|
|
onClick={() => handleTabClick(index)}
|
|
disabled={tab.disabled}
|
|
className={`relative transition-colors duration-200 rounded-t-md focus:outline-none
|
|
${
|
|
tab.disabled
|
|
? "text-gray-400 cursor-not-allowed"
|
|
: "cursor-pointer"
|
|
}
|
|
${
|
|
window.location.pathname === "/"
|
|
? "text-xs py-1 px-2"
|
|
: sizeClasses[size]
|
|
}`}
|
|
>
|
|
<span
|
|
className={`${
|
|
selectedIndex === index
|
|
? "text-primary-600 font-semibold"
|
|
: "text-gray-600 dark:text-gray-300 hover:text-primary-800"
|
|
}`}
|
|
>
|
|
{tab.label}
|
|
</span>
|
|
|
|
{selectedIndex === index && !tab.disabled && (
|
|
<motion.div
|
|
layoutId={`tab-underline-${tabKey}`}
|
|
className="absolute -bottom-px left-0 right-0 h-[2px] bg-primary-600 rounded-t"
|
|
transition={{ type: "spring", stiffness: 400, damping: 30 }}
|
|
/>
|
|
)}
|
|
</button>
|
|
)
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default Tabs;
|