mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-26 01:06:23 +02:00
554 lines
15 KiB
TypeScript
554 lines
15 KiB
TypeScript
"use client";
|
|
|
|
import React, {
|
|
createContext,
|
|
forwardRef,
|
|
type ReactNode,
|
|
useCallback,
|
|
useContext,
|
|
useEffect,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
|
|
import { cn } from "@/lib/utils";
|
|
|
|
/* ───────────────────────────
|
|
Context (replaces cloneElement)
|
|
─────────────────────────── */
|
|
|
|
interface TabsContextValue {
|
|
activeValue: string;
|
|
onValueChange: (value: string) => void;
|
|
}
|
|
|
|
const TabsContext = createContext<TabsContextValue | null>(null);
|
|
|
|
function useTabsContext() {
|
|
const ctx = useContext(TabsContext);
|
|
if (!ctx) {
|
|
throw new Error("AnimatedTabs compound components must be rendered inside <Tabs>");
|
|
}
|
|
return ctx;
|
|
}
|
|
|
|
/* ───────────────────────────
|
|
Constants (hoisted out of render)
|
|
─────────────────────────── */
|
|
|
|
const SIZE_CLASSES = {
|
|
sm: "h-[32px] text-sm",
|
|
md: "h-[40px] text-base",
|
|
lg: "h-[48px] text-lg",
|
|
} as const;
|
|
|
|
const VARIANT_CLASSES = {
|
|
default: "",
|
|
pills: "rounded-full",
|
|
underlined: "",
|
|
} as const;
|
|
|
|
const ACTIVE_INDICATOR_CLASSES = {
|
|
default: "h-[2px] bg-primary dark:bg-primary",
|
|
pills: "hidden",
|
|
underlined: "h-[2px] bg-primary dark:bg-primary",
|
|
} as const;
|
|
|
|
const HOVER_INDICATOR_CLASSES = {
|
|
default: "bg-muted dark:bg-muted rounded-[6px]",
|
|
pills: "bg-muted dark:bg-muted rounded-full",
|
|
underlined: "bg-muted dark:bg-muted rounded-[6px]",
|
|
} as const;
|
|
|
|
/* ───────────────────────────
|
|
XScrollable (internal)
|
|
─────────────────────────── */
|
|
|
|
const XScrollable = forwardRef<
|
|
HTMLDivElement,
|
|
{
|
|
className?: string;
|
|
children?: ReactNode;
|
|
showScrollbar?: boolean;
|
|
contentClassName?: string;
|
|
} & React.HTMLAttributes<HTMLDivElement>
|
|
>(({ className, children, showScrollbar = true, contentClassName, ...props }, ref) => {
|
|
const scrollRef = useRef<HTMLDivElement | null>(null);
|
|
const dragging = useRef(false);
|
|
const startX = useRef(0);
|
|
const startScrollLeft = useRef(0);
|
|
const [scrollPos, setScrollPos] = useState<"start" | "middle" | "end" | "none">("none");
|
|
|
|
const updateScrollPos = useCallback(() => {
|
|
const el = scrollRef.current;
|
|
if (!el) return;
|
|
const canScroll = el.scrollWidth > el.clientWidth + 1;
|
|
if (!canScroll) {
|
|
setScrollPos("none");
|
|
return;
|
|
}
|
|
const atStart = el.scrollLeft <= 2;
|
|
const atEnd = el.scrollWidth - el.scrollLeft - el.clientWidth <= 2;
|
|
setScrollPos(atStart ? "start" : atEnd ? "end" : "middle");
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
updateScrollPos();
|
|
const el = scrollRef.current;
|
|
if (!el) return;
|
|
const ro = new ResizeObserver(updateScrollPos);
|
|
ro.observe(el);
|
|
return () => ro.disconnect();
|
|
}, [updateScrollPos]);
|
|
|
|
const onMouseDown = (e: React.MouseEvent) => {
|
|
if (!scrollRef.current) return;
|
|
dragging.current = true;
|
|
startX.current = e.clientX;
|
|
startScrollLeft.current = scrollRef.current.scrollLeft;
|
|
};
|
|
const endDrag = () => {
|
|
dragging.current = false;
|
|
};
|
|
const onMouseMove = (e: React.MouseEvent) => {
|
|
if (!dragging.current || !scrollRef.current) return;
|
|
e.preventDefault();
|
|
const dx = e.clientX - startX.current;
|
|
scrollRef.current.scrollLeft = startScrollLeft.current - dx;
|
|
};
|
|
|
|
const onWheel = (e: React.WheelEvent) => {
|
|
if (!scrollRef.current) return;
|
|
const delta = Math.abs(e.deltaY) > Math.abs(e.deltaX) ? e.deltaY : e.deltaX;
|
|
if (delta !== 0) {
|
|
e.preventDefault();
|
|
scrollRef.current.scrollLeft += delta;
|
|
}
|
|
};
|
|
|
|
const handleScroll = useCallback(() => {
|
|
updateScrollPos();
|
|
}, [updateScrollPos]);
|
|
|
|
const needsMask = scrollPos !== "none";
|
|
const maskStart = scrollPos === "start" || scrollPos === "none" ? "black" : "transparent";
|
|
const maskEnd = scrollPos === "end" || scrollPos === "none" ? "black" : "transparent";
|
|
const maskImage = needsMask
|
|
? `linear-gradient(to right, ${maskStart}, black 24px, black calc(100% - 24px), ${maskEnd})`
|
|
: undefined;
|
|
|
|
return (
|
|
// biome-ignore lint/a11y/noStaticElementInteractions: drag-scroll container needs mouse events
|
|
<div
|
|
ref={ref}
|
|
className={cn("relative", className)}
|
|
{...props}
|
|
onMouseLeave={endDrag}
|
|
onMouseUp={endDrag}
|
|
onMouseMove={onMouseMove}
|
|
>
|
|
{/* biome-ignore lint/a11y/noStaticElementInteractions: drag-scroll requires onMouseDown */}
|
|
<div
|
|
ref={scrollRef}
|
|
className={cn(
|
|
"overflow-x-auto overflow-y-hidden whitespace-nowrap [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden",
|
|
!showScrollbar && "scrollbar-none",
|
|
contentClassName
|
|
)}
|
|
style={{
|
|
maskImage,
|
|
WebkitMaskImage: maskImage,
|
|
}}
|
|
onWheel={onWheel}
|
|
onMouseDown={onMouseDown}
|
|
onScroll={handleScroll}
|
|
>
|
|
{children}
|
|
</div>
|
|
</div>
|
|
);
|
|
});
|
|
XScrollable.displayName = "XScrollable";
|
|
|
|
/* ───────────────────────────
|
|
Tabs (root)
|
|
─────────────────────────── */
|
|
|
|
const Tabs = forwardRef<
|
|
HTMLDivElement,
|
|
{
|
|
defaultValue?: string;
|
|
value?: string;
|
|
onValueChange?: (value: string) => void;
|
|
className?: string;
|
|
children?: ReactNode;
|
|
}
|
|
>(({ defaultValue, value, onValueChange, className, children, ...props }, ref) => {
|
|
const [activeValue, setActiveValue] = useState(value || defaultValue || "");
|
|
|
|
useEffect(() => {
|
|
if (value !== undefined) {
|
|
setActiveValue(value);
|
|
}
|
|
}, [value]);
|
|
|
|
const handleValueChange = useCallback(
|
|
(newValue: string) => {
|
|
if (value === undefined) {
|
|
setActiveValue(newValue);
|
|
}
|
|
onValueChange?.(newValue);
|
|
},
|
|
[onValueChange, value]
|
|
);
|
|
|
|
return (
|
|
<TabsContext.Provider value={{ activeValue, onValueChange: handleValueChange }}>
|
|
<div ref={ref} className={cn("tabs-container", className)} {...props}>
|
|
{children}
|
|
</div>
|
|
</TabsContext.Provider>
|
|
);
|
|
});
|
|
Tabs.displayName = "Tabs";
|
|
|
|
/* ───────────────────────────
|
|
TabsList
|
|
─────────────────────────── */
|
|
|
|
type TabsListVariant = "default" | "pills" | "underlined";
|
|
type TabsListSize = "sm" | "md" | "lg";
|
|
|
|
const TabsList = forwardRef<
|
|
HTMLDivElement,
|
|
{
|
|
className?: string;
|
|
children?: ReactNode;
|
|
showHoverEffect?: boolean;
|
|
showActiveIndicator?: boolean;
|
|
activeIndicatorPosition?: "top" | "bottom";
|
|
activeIndicatorOffset?: number;
|
|
size?: TabsListSize;
|
|
variant?: TabsListVariant;
|
|
stretch?: boolean;
|
|
ariaLabel?: string;
|
|
showBottomBorder?: boolean;
|
|
bottomBorderClassName?: string;
|
|
activeIndicatorClassName?: string;
|
|
hoverIndicatorClassName?: string;
|
|
}
|
|
>(
|
|
(
|
|
{
|
|
className,
|
|
children,
|
|
showHoverEffect = true,
|
|
showActiveIndicator = true,
|
|
activeIndicatorPosition = "bottom",
|
|
activeIndicatorOffset = 0,
|
|
size = "sm",
|
|
variant = "default",
|
|
stretch = false,
|
|
ariaLabel = "Tabs",
|
|
showBottomBorder = false,
|
|
bottomBorderClassName,
|
|
activeIndicatorClassName,
|
|
hoverIndicatorClassName,
|
|
...props
|
|
},
|
|
ref
|
|
) => {
|
|
const { activeValue, onValueChange } = useTabsContext();
|
|
|
|
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
|
const [hoverStyle, setHoverStyle] = useState({});
|
|
const [activeStyle, setActiveStyle] = useState({
|
|
left: "0px",
|
|
width: "0px",
|
|
});
|
|
const tabRefs = useRef<(HTMLDivElement | null)[]>([]);
|
|
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
const activeIndex = React.Children.toArray(children).findIndex(
|
|
(child) =>
|
|
React.isValidElement(child) &&
|
|
(child as React.ReactElement<{ value: string }>).props.value === activeValue
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (hoveredIndex !== null && showHoverEffect) {
|
|
const hoveredElement = tabRefs.current[hoveredIndex];
|
|
if (hoveredElement) {
|
|
const { offsetLeft, offsetWidth } = hoveredElement;
|
|
setHoverStyle({
|
|
left: `${offsetLeft}px`,
|
|
width: `${offsetWidth}px`,
|
|
});
|
|
}
|
|
}
|
|
}, [hoveredIndex, showHoverEffect]);
|
|
|
|
const updateActiveIndicator = useCallback(() => {
|
|
if (showActiveIndicator && activeIndex >= 0) {
|
|
const activeElement = tabRefs.current[activeIndex];
|
|
if (activeElement) {
|
|
const { offsetLeft, offsetWidth } = activeElement;
|
|
setActiveStyle({
|
|
left: `${offsetLeft}px`,
|
|
width: `${offsetWidth}px`,
|
|
});
|
|
}
|
|
}
|
|
}, [showActiveIndicator, activeIndex]);
|
|
|
|
useEffect(() => {
|
|
updateActiveIndicator();
|
|
}, [updateActiveIndicator]);
|
|
|
|
useEffect(() => {
|
|
requestAnimationFrame(updateActiveIndicator);
|
|
}, [updateActiveIndicator]);
|
|
|
|
const scrollTabToCenter = useCallback((index: number) => {
|
|
const tabElement = tabRefs.current[index];
|
|
const scrollContainer = scrollContainerRef.current;
|
|
|
|
if (tabElement && scrollContainer) {
|
|
const containerWidth = scrollContainer.offsetWidth;
|
|
const tabWidth = tabElement.offsetWidth;
|
|
const tabLeft = tabElement.offsetLeft;
|
|
const scrollTarget = tabLeft - containerWidth / 2 + tabWidth / 2;
|
|
scrollContainer.scrollTo({ left: scrollTarget, behavior: "smooth" });
|
|
}
|
|
}, []);
|
|
|
|
const setTabRef = useCallback((el: HTMLDivElement | null, index: number) => {
|
|
tabRefs.current[index] = el;
|
|
}, []);
|
|
|
|
const handleScrollableRef = useCallback((node: HTMLDivElement | null) => {
|
|
if (node) {
|
|
const scrollableDiv = node.querySelector('div[class*="overflow-x-auto"]');
|
|
if (scrollableDiv) {
|
|
scrollContainerRef.current = scrollableDiv as HTMLDivElement;
|
|
}
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (activeIndex >= 0) {
|
|
const timer = setTimeout(() => {
|
|
scrollTabToCenter(activeIndex);
|
|
}, 100);
|
|
return () => clearTimeout(timer);
|
|
}
|
|
}, [activeIndex, scrollTabToCenter]);
|
|
|
|
return (
|
|
<div
|
|
ref={handleScrollableRef}
|
|
className={cn("relative", className)}
|
|
role="tablist"
|
|
aria-label={ariaLabel}
|
|
{...props}
|
|
>
|
|
{showBottomBorder && (
|
|
<div
|
|
className={cn(
|
|
"absolute bottom-0 left-0 right-0 h-px bg-border dark:bg-border z-0",
|
|
bottomBorderClassName
|
|
)}
|
|
/>
|
|
)}
|
|
<XScrollable showScrollbar={false}>
|
|
<div className={cn("relative", showBottomBorder && "pb-px")}>
|
|
{showHoverEffect && (
|
|
<div
|
|
className={cn(
|
|
"absolute transition-all duration-300 ease-out flex items-center z-0",
|
|
SIZE_CLASSES[size],
|
|
HOVER_INDICATOR_CLASSES[variant],
|
|
hoverIndicatorClassName
|
|
)}
|
|
style={{
|
|
...hoverStyle,
|
|
opacity: hoveredIndex !== null ? 1 : 0,
|
|
transition: "all 300ms ease-out",
|
|
}}
|
|
aria-hidden="true"
|
|
/>
|
|
)}
|
|
|
|
<div
|
|
ref={ref}
|
|
className={cn(
|
|
"relative flex items-center",
|
|
stretch ? "w-full" : "",
|
|
variant === "default" ? "space-x-[6px]" : "space-x-[2px]"
|
|
)}
|
|
>
|
|
{React.Children.map(children, (child, index) => {
|
|
if (!React.isValidElement(child)) return child;
|
|
|
|
const childProps = (
|
|
child as React.ReactElement<{
|
|
value: string;
|
|
disabled?: boolean;
|
|
label?: string;
|
|
className?: string;
|
|
activeClassName?: string;
|
|
inactiveClassName?: string;
|
|
disabledClassName?: string;
|
|
}>
|
|
).props;
|
|
|
|
const { value, disabled } = childProps;
|
|
const isActive = value === activeValue;
|
|
|
|
return (
|
|
<div
|
|
key={value}
|
|
ref={(el) => setTabRef(el, index)}
|
|
className={cn(
|
|
"px-3 py-2 sm:mb-1.5 mb-2 cursor-pointer transition-colors duration-300",
|
|
SIZE_CLASSES[size],
|
|
variant === "pills" && isActive
|
|
? "bg-[#0e0f1114] dark:bg-[#ffffff1a] rounded-full"
|
|
: "",
|
|
disabled ? "opacity-50 cursor-not-allowed" : "",
|
|
stretch ? "flex-1 text-center" : "",
|
|
isActive
|
|
? childProps.activeClassName || "text-foreground dark:text-foreground"
|
|
: childProps.inactiveClassName ||
|
|
"text-muted-foreground dark:text-muted-foreground",
|
|
disabled && childProps.disabledClassName,
|
|
VARIANT_CLASSES[variant],
|
|
childProps.className
|
|
)}
|
|
onMouseEnter={() => !disabled && setHoveredIndex(index)}
|
|
onMouseLeave={() => setHoveredIndex(null)}
|
|
onClick={() => {
|
|
if (!disabled) {
|
|
onValueChange(value);
|
|
scrollTabToCenter(index);
|
|
}
|
|
}}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" || e.key === " ") {
|
|
e.preventDefault();
|
|
if (!disabled) {
|
|
onValueChange(value);
|
|
scrollTabToCenter(index);
|
|
}
|
|
}
|
|
}}
|
|
role="tab"
|
|
aria-selected={isActive}
|
|
aria-disabled={disabled}
|
|
aria-controls={`tabpanel-${value}`}
|
|
id={`tab-${value}`}
|
|
tabIndex={isActive ? 0 : -1}
|
|
>
|
|
<div className="whitespace-nowrap flex items-center justify-center h-full">
|
|
{child}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{showActiveIndicator && variant !== "pills" && activeIndex >= 0 && (
|
|
<div
|
|
className={cn(
|
|
"absolute transition-all duration-300 ease-out z-10",
|
|
ACTIVE_INDICATOR_CLASSES[variant],
|
|
activeIndicatorPosition === "top" ? "top-[-1px]" : "bottom-[-1px]",
|
|
activeIndicatorClassName
|
|
)}
|
|
style={{
|
|
...activeStyle,
|
|
transition: "all 300ms ease-out",
|
|
[activeIndicatorPosition]: `${activeIndicatorOffset}px`,
|
|
}}
|
|
aria-hidden="true"
|
|
/>
|
|
)}
|
|
</div>
|
|
</XScrollable>
|
|
</div>
|
|
);
|
|
}
|
|
);
|
|
TabsList.displayName = "TabsList";
|
|
|
|
/* ───────────────────────────
|
|
TabsTrigger
|
|
─────────────────────────── */
|
|
|
|
const TabsTrigger = forwardRef<
|
|
HTMLDivElement,
|
|
{
|
|
value: string;
|
|
disabled?: boolean;
|
|
label?: string;
|
|
className?: string;
|
|
activeClassName?: string;
|
|
inactiveClassName?: string;
|
|
disabledClassName?: string;
|
|
children?: ReactNode;
|
|
}
|
|
>(
|
|
(
|
|
{
|
|
value,
|
|
disabled = false,
|
|
label,
|
|
className,
|
|
activeClassName,
|
|
inactiveClassName,
|
|
disabledClassName,
|
|
children,
|
|
...props
|
|
},
|
|
ref
|
|
) => {
|
|
return (
|
|
<div ref={ref} className={cn("flex items-center", className)} {...props}>
|
|
{label || children}
|
|
</div>
|
|
);
|
|
}
|
|
);
|
|
TabsTrigger.displayName = "TabsTrigger";
|
|
|
|
/* ───────────────────────────
|
|
TabsContent
|
|
─────────────────────────── */
|
|
|
|
const TabsContent = forwardRef<
|
|
HTMLDivElement,
|
|
{
|
|
value: string;
|
|
className?: string;
|
|
children: ReactNode;
|
|
}
|
|
>(({ value, className, children, ...props }, ref) => {
|
|
const { activeValue } = useTabsContext();
|
|
|
|
if (value !== activeValue) return null;
|
|
return (
|
|
<div
|
|
ref={ref}
|
|
role="tabpanel"
|
|
id={`tabpanel-${value}`}
|
|
aria-labelledby={`tab-${value}`}
|
|
className={className}
|
|
{...props}
|
|
>
|
|
{children}
|
|
</div>
|
|
);
|
|
});
|
|
TabsContent.displayName = "TabsContent";
|
|
|
|
export { Tabs, TabsList, TabsTrigger, TabsContent };
|