first commit
This commit is contained in:
243
src/components/Tab/Tab.tsx
Normal file
243
src/components/Tab/Tab.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user