Files
wehub-resource-sync e30e75b5d4
Code Quality / Oxlint + Oxfmt (push) Waiting to run
Code Quality / Template Sync (push) Waiting to run
Code Quality / Build Changed Packages (push) Waiting to run
Code Quality / Test Changed Packages (push) Waiting to run
Deploy Expo Example / Deploy Production (push) Waiting to run
Deploy Ink Example / Deploy Production (push) Waiting to run
Python Tests / pytest (assistant-stream, 3.10) (push) Waiting to run
Python Tests / pytest (assistant-stream, 3.12) (push) Waiting to run
Python Tests / pytest (assistant-ui-sync-server-api, 3.10) (push) Waiting to run
Python Tests / pytest (assistant-ui-sync-server-api, 3.12) (push) Waiting to run
Deploy Shadcn Registry / Deploy Production (push) Waiting to run
Template Metrics / LOC + Bundle Size (push) Waiting to run
Changesets / Create Version PR (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:40:13 +08:00

457 lines
13 KiB
TypeScript

"use client";
import { cn } from "@/lib/utils";
import { cva, type VariantProps } from "class-variance-authority";
import Link from "next/link";
import { usePathname } from "next/navigation";
import React from "react";
interface TabItem {
label: string;
value?: React.ReactNode; // If present, it's a content tab
href?: string; // If present, it's a navigation tab
icon?: React.ReactNode;
isActive?: (pathname: string) => boolean;
}
const tabSwitcherVariants = cva("flex flex-col", {
variants: {
orientation: {
horizontal: "h-full overflow-hidden",
navigation: "h-auto",
},
},
defaultVariants: {
orientation: "horizontal",
},
});
const tabListVariants = cva("flex-none", {
variants: {
orientation: {
horizontal: "",
navigation: "",
},
},
defaultVariants: {
orientation: "horizontal",
},
});
const tabIndicatorVariants = cva(
"absolute transition-all duration-300 ease-out",
{
variants: {
variant: {
ghost: "bg-foreground bottom-0 h-[2px]",
default: "bg-primary bottom-0 h-[2px]",
outline: "bg-foreground bottom-0 h-[2px]",
secondary: "bg-secondary-foreground bottom-0 h-[2px]",
link: "bg-primary bottom-0 h-[2px]",
},
orientation: {
horizontal: "bottom-0 h-[2px]",
navigation: "bottom-0 h-0.5",
},
},
defaultVariants: {
variant: "ghost",
orientation: "horizontal",
},
},
);
const tabItemVariants = cva(
"focus-visible:ring-ring/50 relative flex h-[30px] cursor-pointer items-center justify-center gap-2 rounded-md px-3 py-2 text-sm leading-5 whitespace-nowrap transition-all duration-300 outline-none focus-visible:ring-2 focus-visible:ring-inset data-[active=true]:font-medium",
{
variants: {
variant: {
ghost:
"data-[active=true]:bg-foreground/5 data-[active=true]:text-foreground border-transparent bg-transparent",
default:
"border-border/50 bg-background/50 text-muted-foreground hover:border-border hover:bg-background hover:text-foreground data-[active=true]:border-border data-[active=true]:bg-background data-[active=true]:text-foreground border data-[active=true]:shadow-sm",
outline:
"border-border/30 text-muted-foreground hover:border-border hover:bg-accent/50 hover:text-foreground data-[active=true]:border-border data-[active=true]:bg-accent/30 data-[active=true]:text-foreground border bg-transparent",
secondary:
"bg-secondary/30 text-secondary-foreground/80 hover:bg-secondary/50 hover:text-secondary-foreground data-[active=true]:bg-secondary/70 data-[active=true]:text-secondary-foreground border-transparent",
link: "text-muted-foreground hover:text-primary data-[active=true]:text-primary border-transparent bg-transparent",
},
},
defaultVariants: {
variant: "ghost",
},
},
);
function Tab({
tabs,
className,
defaultActiveIndex = 0,
variant = "ghost",
orientation,
onTabChange,
actions,
...props
}: React.ComponentProps<"div"> & {
tabs: TabItem[];
defaultActiveIndex?: number;
variant?: "ghost" | "default" | "outline" | "secondary" | "link";
onTabChange?: (label: string, index: number) => void;
actions?: React.ReactNode;
} & VariantProps<typeof tabSwitcherVariants>) {
const pathname = usePathname();
const [hoveredIndex, setHoveredIndex] = React.useState<number | null>(null);
const [contentActiveIndex, setContentActiveIndex] =
React.useState(defaultActiveIndex);
const [hoverStyle, setHoverStyle] = React.useState({});
const [activeStyle, setActiveStyle] = React.useState({
left: "0px",
width: "0px",
});
const tabRefs = React.useRef<(HTMLElement | null)[]>([]);
// Check if we have content tabs (tabs with value)
const hasContentTabs = tabs.some((tab) => tab.value !== undefined);
// Pure navigation mode: ALL tabs are navigation-only (href without value)
const isPureNavigationMode =
tabs.every((tab) => tab.href && !tab.value) && tabs.some((tab) => tab.href);
const resolvedOrientation =
orientation || (isPureNavigationMode ? "navigation" : "horizontal");
// Find active tab index for pure navigation mode
const navigationActiveIndex = isPureNavigationMode
? tabs.findIndex((tab) => {
if (tab.href) {
return tab.isActive ? tab.isActive(pathname) : pathname === tab.href;
}
return false;
})
: -1;
const activeIndex = isPureNavigationMode
? navigationActiveIndex
: contentActiveIndex;
React.useEffect(() => {
if (hoveredIndex !== null) {
const hoveredElement = tabRefs.current[hoveredIndex];
if (hoveredElement) {
const { offsetLeft, offsetWidth } = hoveredElement;
setHoverStyle({
left: `${offsetLeft}px`,
width: `${offsetWidth}px`,
});
}
}
}, [hoveredIndex]);
React.useEffect(() => {
if (activeIndex !== -1) {
const activeElement = tabRefs.current[activeIndex];
if (activeElement) {
const { offsetLeft, offsetWidth } = activeElement;
setActiveStyle({
left: `${offsetLeft}px`,
width: `${offsetWidth}px`,
});
}
}
}, [activeIndex]);
React.useEffect(() => {
requestAnimationFrame(() => {
const initialActiveIndex = isPureNavigationMode
? navigationActiveIndex
: contentActiveIndex;
if (initialActiveIndex !== -1) {
const activeElement = tabRefs.current[initialActiveIndex];
if (activeElement) {
const { offsetLeft, offsetWidth } = activeElement;
setActiveStyle({
left: `${offsetLeft}px`,
width: `${offsetWidth}px`,
});
}
}
});
}, [isPureNavigationMode, navigationActiveIndex, contentActiveIndex]);
const handleKeyDown = (index: number, e: React.KeyboardEvent) => {
const tab = tabs[index];
// Only handle key events for content tabs (tabs with value)
if (tab?.value !== undefined && (e.key === "Enter" || e.key === " ")) {
e.preventDefault();
if (index !== contentActiveIndex) {
onTabChange?.(tab.label, index);
}
setContentActiveIndex(index);
}
};
const handleContentTabClick = (index: number) => {
const tab = tabs[index];
// Only set active index for content tabs (tabs with value)
if (tab?.value !== undefined) {
if (index !== contentActiveIndex) {
onTabChange?.(tab.label, index);
}
setContentActiveIndex(index);
}
};
// Show indicator only for ghost, outline, and link variants
const showIndicator =
variant === "ghost" || variant === "outline" || variant === "link";
return (
<div
className={cn(
tabSwitcherVariants({ orientation: resolvedOrientation }),
className,
)}
data-orientation={resolvedOrientation}
data-slot="unified-tab-switcher"
data-variant={variant}
{...props}
>
<TabList orientation={resolvedOrientation}>
<div className="flex items-center gap-2">
<TabContainer className="min-w-0 flex-1">
{showIndicator && (
<TabIndicator
activeStyle={activeStyle}
hoveredIndex={hoveredIndex}
hoverStyle={hoverStyle}
orientation={resolvedOrientation}
variant={variant}
/>
)}
{tabs.map((tab, index) => (
<TabItem
index={index}
isActive={
isPureNavigationMode
? Boolean(
tab.href &&
(tab.isActive
? tab.isActive(pathname)
: pathname === tab.href),
)
: tab.value !== undefined && index === contentActiveIndex
}
key={tab.label}
onClick={() => handleContentTabClick(index)}
onKeyDown={(e) => handleKeyDown(index, e)}
onMouseEnter={() => setHoveredIndex(index)}
onMouseLeave={() => setHoveredIndex(null)}
tab={tab}
tabRefs={tabRefs}
variant={variant}
/>
))}
</TabContainer>
{actions && (
<div
className="flex flex-none items-center gap-1 pb-2"
data-slot="tab-actions"
>
{actions}
</div>
)}
</div>
</TabList>
{/* Content panel - show when there are content tabs */}
{hasContentTabs && (
<TabContent
activeIndex={contentActiveIndex}
activeTab={tabs[contentActiveIndex]}
/>
)}
</div>
);
}
function TabList({
orientation,
className,
children,
...props
}: React.ComponentProps<"div"> & {
orientation: "horizontal" | "navigation";
}) {
return (
<div
className={cn(tabListVariants({ orientation }), className)}
data-orientation={orientation}
data-slot="tab-list"
{...props}
>
{children}
</div>
);
}
function TabIndicator({
variant,
orientation,
hoverStyle,
activeStyle,
hoveredIndex,
className,
...props
}: React.ComponentProps<"div"> & {
variant: "ghost" | "default" | "outline" | "secondary" | "link";
orientation: "horizontal" | "navigation";
hoverStyle: React.CSSProperties;
activeStyle: React.CSSProperties;
hoveredIndex: number | null;
}) {
return (
<>
{variant === "ghost" &&
hoveredIndex !== null &&
hoverStyle.width &&
Number.parseFloat(String(hoverStyle.width)) > 0 && (
<div
className="bg-foreground/8 absolute flex h-[30px] items-center rounded-md transition-all duration-300 ease-out"
data-slot="tab-hover-indicator"
style={hoverStyle}
{...props}
/>
)}
<div
className={cn(
tabIndicatorVariants({ variant, orientation }),
className,
)}
data-orientation={orientation}
data-slot="tab-active-indicator"
data-variant={variant}
style={activeStyle}
/>
</>
);
}
function TabContainer({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn(
"relative flex scrollbar-none items-center space-x-[6px] overflow-x-auto pb-2",
className,
)}
data-slot="tab-container"
role="tablist"
{...props}
/>
);
}
function TabItem({
tab,
index,
isActive,
variant,
tabRefs,
onMouseEnter,
onMouseLeave,
onClick,
onKeyDown,
className,
}: {
tab: TabItem;
index: number;
isActive: boolean;
variant: "ghost" | "default" | "outline" | "secondary" | "link";
tabRefs: React.RefObject<(HTMLElement | null)[]>;
onMouseEnter: () => void;
onMouseLeave: () => void;
onClick: () => void;
onKeyDown: (e: React.KeyboardEvent) => void;
className?: string;
}) {
const tabContent = (
<>
{tab.icon}
{tab.label}
</>
);
const baseClasses = cn(tabItemVariants({ variant }), className);
// If tab has a path, render as navigation link
if (tab.href) {
return (
<Link
aria-controls={`panel-${tab.label}`}
aria-selected={isActive}
className={baseClasses}
data-active={isActive}
data-slot="tab-item"
data-type="navigation"
data-variant={variant}
href={tab.href}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
ref={(el) => {
tabRefs.current[index] = el;
}}
role="tab"
tabIndex={0}
>
{tabContent}
</Link>
);
}
// Otherwise render as content tab
return (
<div
aria-controls={`panel-${tab.label}`}
className={baseClasses}
data-active={isActive}
data-slot="tab-item"
data-type="content"
data-variant={variant}
onClick={onClick}
onKeyDown={onKeyDown}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
ref={(el) => {
tabRefs.current[index] = el;
}}
role="tab"
tabIndex={0}
>
{tabContent}
</div>
);
}
function TabContent({
activeTab,
activeIndex,
className,
...props
}: React.ComponentProps<"div"> & {
activeTab: TabItem | undefined;
activeIndex: number;
}) {
return (
<div
aria-labelledby={activeTab?.label}
className={cn("relative mt-4 flex-1 overflow-hidden", className)}
data-slot="tab-content-panel"
id={`panel-${activeTab?.label}`}
role="tabpanel"
{...props}
>
{activeTab?.value}
</div>
);
}
export { Tab, TabList, TabIndicator, TabContainer, TabItem, TabContent };