119 lines
4.2 KiB
TypeScript
119 lines
4.2 KiB
TypeScript
"use client";
|
|
|
|
/* eslint-disable react-hooks/set-state-in-effect */
|
|
import { useState, useEffect, type FC, useId } from "react";
|
|
import { motion, LayoutGroup } from "framer-motion";
|
|
|
|
/* ---------- Types ---------- */
|
|
interface TabItem {
|
|
id: string;
|
|
label: string;
|
|
}
|
|
|
|
interface ContinuousTabsProps {
|
|
tabs?: TabItem[];
|
|
activeId?: string;
|
|
defaultActiveId?: string;
|
|
onChange?: (id: string) => void;
|
|
size?: "sm" | "md";
|
|
layoutId?: string;
|
|
}
|
|
|
|
/* ---------- Defaults ---------- */
|
|
const DEFAULT_TABS: TabItem[] = [
|
|
{ id: "home", label: "Home" },
|
|
{ id: "interactions", label: "Interactions" },
|
|
{ id: "resources", label: "Resources" },
|
|
{ id: "docs", label: "Docs" },
|
|
];
|
|
|
|
export const ContinuousTabs: FC<ContinuousTabsProps> = ({
|
|
tabs = DEFAULT_TABS,
|
|
activeId,
|
|
defaultActiveId = "home",
|
|
onChange,
|
|
size = "md",
|
|
layoutId,
|
|
}) => {
|
|
const [activeState, setActiveState] = useState<string>(defaultActiveId);
|
|
const [isMounted, setIsMounted] = useState<boolean>(false);
|
|
const uniqueId = useId();
|
|
|
|
useEffect(() => {
|
|
setIsMounted(true);
|
|
}, []);
|
|
|
|
const active = activeId !== undefined ? activeId : activeState;
|
|
const finalLayoutId = layoutId ?? `active-pill-${uniqueId}`;
|
|
|
|
const handleChange = (id: string) => {
|
|
setActiveState(id);
|
|
onChange?.(id);
|
|
};
|
|
|
|
if (!isMounted) return null;
|
|
|
|
const isSm = size === "sm";
|
|
|
|
return (
|
|
<LayoutGroup>
|
|
<nav
|
|
className={`
|
|
relative flex items-center bg-muted/40 dark:bg-charcoal/50
|
|
border border-border
|
|
rounded-full transition-all duration-300
|
|
shadow-[inset_0_-1px_2px_rgba(0,0,0,0.05)]
|
|
${isSm ? "gap-0.5 p-1" : "gap-0.5 sm:gap-1 p-1.5 sm:p-2"}
|
|
`}
|
|
>
|
|
{tabs.map((tab) => {
|
|
const isActive = active === tab.id;
|
|
|
|
return (
|
|
<button
|
|
key={tab.id}
|
|
onClick={() => handleChange(tab.id)}
|
|
className={`
|
|
relative flex items-center justify-center rounded-full outline-none transition-transform active:scale-[0.98]
|
|
${isSm ? "px-3 py-1.5" : "px-5 py-2 sm:px-6 sm:py-2.5"}
|
|
`}
|
|
>
|
|
{/* Active pill */}
|
|
{isActive && (
|
|
<motion.div
|
|
layoutId={finalLayoutId}
|
|
transition={{
|
|
type: "spring",
|
|
stiffness: 380,
|
|
damping: 30,
|
|
mass: 0.9,
|
|
}}
|
|
className="
|
|
absolute inset-0 rounded-full
|
|
bg-[#ff3b1f]
|
|
shadow-sm
|
|
"
|
|
/>
|
|
)}
|
|
|
|
{/* Text */}
|
|
<motion.span
|
|
layout="position"
|
|
className={`
|
|
relative z-10 font-semibold leading-none transition-colors duration-200
|
|
${isSm ? "text-[11px] sm:text-xs" : "text-sm sm:text-base"}
|
|
${isActive
|
|
? "text-white"
|
|
: "text-muted-foreground hover:text-foreground"
|
|
}
|
|
`}
|
|
>
|
|
{tab.label}
|
|
</motion.span>
|
|
</button>
|
|
);
|
|
})}
|
|
</nav>
|
|
</LayoutGroup>
|
|
);
|
|
}; |