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
457 lines
13 KiB
TypeScript
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 };
|