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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:40:13 +08:00
commit e30e75b5d4
3893 changed files with 533074 additions and 0 deletions
@@ -0,0 +1,32 @@
import { CLOUD_URL } from "@/lib/constants";
import { cn } from "@/lib/utils";
export const cloudButtonVariants = {
marketing:
"border-border hover:bg-muted hidden rounded-md border px-3 py-1.5 text-sm font-medium transition-colors md:inline-flex",
docs: "border-border/50 bg-muted/50 hover:bg-muted flex h-8 items-center rounded-lg border px-3 text-sm font-medium transition-colors",
mobile:
"border-border hover:bg-muted inline-flex w-fit rounded-md border px-3 py-1.5 text-sm font-medium transition-colors",
};
export function CloudButton({
variant,
className,
onClick,
}: {
variant: keyof typeof cloudButtonVariants;
className?: string;
onClick?: () => void;
}) {
return (
<a
href={CLOUD_URL}
target="_blank"
rel="noopener noreferrer"
onClick={onClick}
className={cn(cloudButtonVariants[variant], className)}
>
Cloud
</a>
);
}
@@ -0,0 +1,119 @@
"use client";
import { useState, useEffect, useRef } from "react";
import type { ThemeColor } from "@/components/builder/types";
import { cn } from "@/lib/utils";
export type { ThemeColor };
export function ColorPicker({
value,
onChange,
}: {
value: string;
onChange: (value: string) => void;
}) {
const [localValue, setLocalValue] = useState(value);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
setLocalValue(value);
}, [value]);
const handleChange = (newValue: string) => {
setLocalValue(newValue);
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
onChange(newValue);
}, 50);
};
return (
<label className="relative cursor-pointer">
<div
className="size-5 rounded-md shadow-sm ring-1 ring-black/10 ring-inset"
style={{ backgroundColor: localValue }}
/>
<input
type="color"
value={localValue}
onChange={(e) => handleChange(e.target.value)}
className="absolute inset-0 size-full cursor-pointer opacity-0"
/>
</label>
);
}
export function ThemeColorPicker({
value,
onChange,
}: {
value: ThemeColor;
onChange: (value: ThemeColor) => void;
}) {
return (
<div className="flex gap-1">
<ColorPicker
value={value.light}
onChange={(light) => onChange({ ...value, light })}
/>
<ColorPicker
value={value.dark}
onChange={(dark) => onChange({ ...value, dark })}
/>
</div>
);
}
export function OptionalThemeColorPicker({
value,
defaultValue,
onChange,
}: {
value: ThemeColor | undefined;
defaultValue: ThemeColor;
onChange: (value: ThemeColor | undefined) => void;
}) {
const displayValue = value ?? defaultValue;
const isCustom = value !== undefined;
return (
<div className="flex gap-1">
<label className="relative cursor-pointer">
<div
className={cn(
"size-5 rounded-md shadow-sm ring-1 ring-inset",
isCustom ? "ring-black/10" : "opacity-50 ring-black/5",
)}
style={{ backgroundColor: displayValue.light }}
/>
<input
type="color"
value={displayValue.light}
onChange={(e) => onChange({ ...displayValue, light: e.target.value })}
className="absolute inset-0 size-full cursor-pointer opacity-0"
/>
</label>
<label className="relative cursor-pointer">
<div
className={cn(
"size-5 rounded-md shadow-sm ring-1 ring-inset",
isCustom ? "ring-black/10" : "opacity-50 ring-black/5",
)}
style={{ backgroundColor: displayValue.dark }}
/>
<input
type="color"
value={displayValue.dark}
onChange={(e) => onChange({ ...displayValue, dark: e.target.value })}
className="absolute inset-0 size-full cursor-pointer opacity-0"
/>
</label>
</div>
);
}
+178
View File
@@ -0,0 +1,178 @@
import type { FC, ReactNode } from "react";
import Image from "next/image";
import Link from "next/link";
import { ArrowUpRight } from "lucide-react";
import { GitHubIcon } from "@/components/icons/github";
import { DiscordIcon } from "@/components/icons/discord";
import { ThemeToggle } from "@/components/shared/theme-toggle";
import { CLOUD_URL, PRODUCTS } from "@/lib/constants";
type FooterLinkItem = {
label: string;
href: string;
external?: boolean;
};
const FOOTER_LINKS: Record<string, FooterLinkItem[]> = {
Products: [
{
label: "Cloud",
href: CLOUD_URL,
external: true,
},
{ label: "Playground", href: "/playground" },
...PRODUCTS.map((p) => ({
label: p.label,
href: p.href,
...(p.external && { external: true }),
})),
],
Resources: [
{ label: "Documentation", href: "/docs" },
{ label: "Examples", href: "/examples" },
{ label: "Showcase", href: "/showcase" },
{ label: "Traction", href: "/traction" },
{ label: "Packages", href: "/packages" },
{ label: "Blog", href: "/blog" },
],
Company: [
{ label: "Careers", href: "/careers" },
{
label: "Contact Sales",
href: "https://cal.com/simon-farshid/assistant-ui",
external: true,
},
{ label: "Pricing", href: "/pricing" },
{ label: "Brand", href: "/brand" },
],
Legal: [
{
label: "Terms of Service",
href: "/terms-of-service",
},
{
label: "Privacy Policy",
href: "/privacy-policy",
},
],
};
export function Footer(): React.ReactElement {
return (
<footer className="py-10 md:py-16">
<div className="mx-auto flex w-full max-w-7xl flex-col gap-10 px-4 md:flex-row md:justify-between">
<div className="grid grid-cols-2 gap-x-12 gap-y-8 sm:grid-cols-4 md:order-2 lg:gap-x-16">
{Object.entries(FOOTER_LINKS).map(([category, links]) => (
<div key={category} className="flex flex-col gap-3">
<p className="text-sm font-medium">{category}</p>
{links.map((link) => (
<FooterLink
key={link.href}
href={link.href}
{...(link.external && { external: true })}
>
{link.label}
</FooterLink>
))}
</div>
))}
</div>
<div className="flex flex-col gap-4 md:order-1">
<Link href="/" className="flex items-center gap-2">
<Image
src="/favicon/icon.svg"
alt="logo"
width={24}
height={24}
className="size-6 dark:hue-rotate-180 dark:invert"
/>
<span className="text-xl font-medium">assistant-ui</span>
</Link>
<div className="flex items-center gap-3">
<a
href="https://x.com/assistantui"
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground transition-colors"
aria-label="X (Twitter)"
>
<svg
aria-hidden="true"
className="size-5"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
</svg>
</a>
<a
href="https://github.com/assistant-ui"
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground transition-colors"
aria-label="GitHub"
>
<GitHubIcon className="size-5" />
</a>
<a
href="https://discord.gg/S9dwgCNEFs"
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground transition-colors"
aria-label="Discord"
>
<DiscordIcon className="size-5" />
</a>
</div>
<div className="mt-auto flex flex-col gap-3">
<div className="-ml-2">
<ThemeToggle />
</div>
<a
href="https://agentbase.dev"
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground text-sm transition-colors"
>
&copy; {new Date().getFullYear()} AgentbaseAI Inc.
</a>
</div>
</div>
</div>
</footer>
);
}
const FooterLink: FC<{
href: string;
external?: boolean;
children: ReactNode;
}> = ({ href, external, children }) => {
const isExternal = external ?? href.startsWith("http");
if (isExternal) {
return (
<a
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-sm transition-colors"
href={href}
target="_blank"
rel="noopener noreferrer"
>
{children}
<ArrowUpRight className="size-3 opacity-40" />
</a>
);
}
return (
<Link
className="text-muted-foreground hover:text-foreground text-sm transition-colors"
href={href}
>
{children}
</Link>
);
};
@@ -0,0 +1,112 @@
"use client";
import type { ReactElement } from "react";
import Image from "next/image";
import Link from "next/link";
import { DownloadIcon, SquareDashedIcon, TypeIcon } from "lucide-react";
import { toast } from "sonner";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { cn } from "@/lib/utils";
type BrandAssetsMenuProps = {
children: ReactElement;
};
type HeaderBrandLinkProps = {
className?: string;
labelClassName?: string;
};
export function HeaderBrandLink({
className,
labelClassName,
}: HeaderBrandLinkProps) {
return (
<BrandAssetsMenu>
<Link
href="/"
className={cn("flex shrink-0 items-center gap-2", className)}
>
<Image
src="/favicon/icon.svg"
alt="assistant-ui logo"
width={18}
height={18}
className="dark:hue-rotate-180 dark:invert"
/>
<span className={cn("font-medium tracking-tight", labelClassName)}>
assistant-ui
</span>
</Link>
</BrandAssetsMenu>
);
}
function BrandAssetsMenu({ children }: BrandAssetsMenuProps) {
return (
<ContextMenu modal={false}>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent className="w-56">
<ContextMenuItem
onSelect={() => void copySvg("Logomark as SVG", "/favicon/icon.svg")}
>
<Image
src="/favicon/icon.svg"
alt=""
width={16}
height={16}
className="size-4 dark:hue-rotate-180 dark:invert"
/>
Copy Logomark as SVG
</ContextMenuItem>
<ContextMenuItem
onSelect={() =>
void copySvg("Logotype as SVG", "/brand/logotype.svg")
}
>
<TypeIcon className="size-4" />
Copy Logotype as SVG
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem asChild>
<Link href="/brand">
<SquareDashedIcon className="size-4" />
Brand Guidelines
</Link>
</ContextMenuItem>
<ContextMenuItem asChild>
<a href="/assistant-ui-brand.zip" download>
<DownloadIcon className="size-4" />
Download Brand Assets
</a>
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}
// ClipboardItem wraps the pending fetch so the clipboard write starts inside
// the user activation window; Safari rejects a write that awaits the fetch.
const copySvg = async (label: string, path: string) => {
try {
const svg = fetch(path).then(async (response) => {
if (!response.ok) throw new Error("Failed to load SVG");
return new Blob([await response.text()], { type: "text/plain" });
});
await navigator.clipboard.write([new ClipboardItem({ "text/plain": svg })]);
toast.success(`${label} copied`);
} catch {
toast.error(`Failed to copy ${label.toLowerCase()}`);
}
};
+291
View File
@@ -0,0 +1,291 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { Menu, X, ArrowUpRight, ArrowRight, Search } from "lucide-react";
import { usePersistentBoolean } from "@/hooks/use-persistent-boolean";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
import { formatCompact } from "@/lib/format";
import { SearchDialog } from "./search-dialog";
import { GitHubIcon } from "@/components/icons/github";
import { DiscordIcon } from "@/components/icons/discord";
import { NAV_ITEMS } from "@/lib/constants";
import {
CloudButton,
cloudButtonVariants,
} from "@/components/shared/cloud-button";
import { useAssistantPanel } from "@/components/docs/assistant/context";
import { NavItems } from "@/components/shared/nav-items";
import { HeaderBrandLink } from "@/components/shared/header-brand-link";
function SearchButton({ onToggle }: { onToggle: () => void }) {
useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
e.stopPropagation();
onToggle();
}
};
document.addEventListener("keydown", down, true);
return () => document.removeEventListener("keydown", down, true);
}, [onToggle]);
return (
<button
type="button"
onClick={onToggle}
className="text-muted-foreground hover:text-foreground flex size-8 cursor-pointer items-center justify-center transition-colors"
aria-label="Search (⌘K)"
>
<Search className="size-4" />
</button>
);
}
function HiringBanner({ onDismiss }: { onDismiss: () => void }) {
return (
<div className="relative flex justify-center">
<div className="border-border/50 bg-background/60 relative flex items-center gap-3 rounded-full border px-4 py-1.5 backdrop-blur-md">
<Link
href="/careers"
className="group inline-flex items-center gap-1.5 text-xs"
>
<span className="shimmer text-muted-foreground group-hover:text-foreground transition-colors">
We&apos;re hiring. Build the future of agentic UI.
</span>
<ArrowRight className="text-muted-foreground group-hover:text-foreground size-3 transition-all group-hover:translate-x-0.5" />
</Link>
<button
type="button"
aria-label="Dismiss"
onClick={onDismiss}
className="text-muted-foreground hover:bg-muted hover:text-foreground flex size-5 items-center justify-center rounded-full transition-colors"
>
<X className="size-3" />
</button>
</div>
</div>
);
}
export function Header() {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [searchOpen, setSearchOpen] = useState(false);
const [mounted, setMounted] = useState(false);
const [stars, setStars] = useState<number | null>(null);
const pathname = usePathname();
const { toggle } = useAssistantPanel();
const [dismissed, setDismissed] = usePersistentBoolean(
"homepage-hiring-banner-dismissed",
);
const [visited, setVisited] = usePersistentBoolean("homepage-visited");
const [returningVisitor, setReturningVisitor] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
if (pathname !== "/") return;
// Show the banner only from the second homepage visit onward.
setReturningVisitor(visited);
if (!visited) setVisited(true);
// oxlint-disable-next-line react/exhaustive-deps
}, [pathname]);
useEffect(() => {
fetch("/api/github/repo")
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
if (data && typeof data.stars === "number") {
setStars(data.stars);
}
})
.catch(console.error);
}, []);
const isHome = pathname === "/";
const showBanner = mounted && isHome && returningVisitor && !dismissed;
return (
<header className="sticky top-0 z-50 w-full">
<div className="from-background pointer-events-none absolute inset-x-0 top-0 h-14 bg-linear-to-b to-transparent mask-[linear-gradient(to_bottom,black_75%,transparent)] backdrop-blur-xl" />
<div className="relative mx-auto flex h-12 w-full max-w-7xl items-center justify-between px-4">
<div className="flex items-center gap-4">
<HeaderBrandLink />
<nav className="hidden items-center md:flex">
<NavItems items={NAV_ITEMS} />
</nav>
</div>
<div className="flex items-center gap-1">
{!isHome && (
<>
<SearchButton onToggle={() => setSearchOpen((prev) => !prev)} />
<SearchDialog open={searchOpen} onOpenChange={setSearchOpen} />
</>
)}
{isHome && (
<button
type="button"
onClick={toggle}
className={cloudButtonVariants.marketing}
aria-label="Ask AI (⌘I)"
>
Ask AI
</button>
)}
<CloudButton variant="marketing" />
<a
href="https://github.com/assistant-ui/assistant-ui"
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground ml-2 hidden items-center gap-2.5 transition-colors sm:flex"
aria-label="GitHub"
>
<GitHubIcon className="size-4" />
{stars !== null && (
<span className="text-sm tabular-nums">
{formatCompact(stars)}
</span>
)}
</a>
{!isHome && (
<a
href="https://discord.gg/S9dwgCNEFs"
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground hidden size-8 items-center justify-center transition-colors sm:flex"
aria-label="Discord"
>
<DiscordIcon className="size-4" />
</a>
)}
<button
type="button"
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
className="text-muted-foreground hover:text-foreground flex size-8 items-center justify-center transition-colors md:hidden"
aria-label="Toggle menu"
>
{mobileMenuOpen ? (
<X className="size-5" />
) : (
<Menu className="size-5" />
)}
</button>
</div>
</div>
<div
className={cn(
"bg-background fixed inset-x-0 top-12 bottom-0 z-40 transition-opacity duration-200 md:hidden",
mobileMenuOpen ? "opacity-100" : "pointer-events-none opacity-0",
)}
>
<nav className="flex h-full flex-col gap-1 overflow-y-auto px-4 pt-4">
{NAV_ITEMS.map((item) => {
if (item.type === "link") {
return item.href.startsWith("http") ? (
<a
key={item.href}
href={item.href}
target="_blank"
rel="noopener noreferrer"
onClick={() => setMobileMenuOpen(false)}
className="text-foreground py-3 text-lg transition-colors"
>
{item.label}
</a>
) : (
<Link
key={item.href}
href={item.href}
onClick={() => setMobileMenuOpen(false)}
className="text-foreground py-3 text-lg transition-colors"
>
{item.label}
</Link>
);
}
const groups = item.groups;
return (
<div key={item.label} className="flex flex-col">
{groups.map((group) => (
<div key={group.label} className="flex flex-col">
<span className="text-muted-foreground py-3 text-sm">
{group.label}
</span>
{group.items.map((link) =>
link.external ? (
<a
key={link.href}
href={link.href}
target="_blank"
rel="noopener noreferrer"
onClick={() => setMobileMenuOpen(false)}
className="text-foreground flex items-center gap-1.5 py-2 pl-4 text-lg transition-colors"
>
{link.label}
<ArrowUpRight className="size-3.5 opacity-40" />
</a>
) : (
<Link
key={link.href}
href={link.href}
onClick={() => setMobileMenuOpen(false)}
className="text-foreground py-2 pl-4 text-lg transition-colors"
>
{link.label}
</Link>
),
)}
</div>
))}
</div>
);
})}
<div className="mt-auto flex flex-col gap-4 border-t py-6">
<CloudButton
variant="mobile"
onClick={() => setMobileMenuOpen(false)}
/>
<div className="flex gap-4">
<a
href="https://github.com/assistant-ui/assistant-ui"
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground flex items-center gap-2 transition-colors"
>
<GitHubIcon className="size-5" />
</a>
<a
href="https://discord.gg/S9dwgCNEFs"
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground flex items-center gap-2 transition-colors"
>
<DiscordIcon className="size-5" />
</a>
</div>
</div>
</nav>
</div>
{showBanner && (
<div className="absolute top-full right-0 left-0">
<HiringBanner onDismiss={() => setDismissed(true)} />
</div>
)}
</header>
);
}
@@ -0,0 +1,55 @@
import { ArrowUpRight, ChevronDown } from "lucide-react";
import Link from "next/link";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card";
interface MoreDropdownItem {
label: string;
href: string;
external?: boolean;
}
export function MoreDropdown({ items }: { items: MoreDropdownItem[] }) {
return (
<HoverCard openDelay={100} closeDelay={100}>
<HoverCardTrigger asChild>
<button
type="button"
className="text-muted-foreground hover:text-foreground flex items-center gap-1 px-3 py-1.5 text-sm transition-colors"
>
More
<ChevronDown className="size-3" />
</button>
</HoverCardTrigger>
<HoverCardContent className="w-40 rounded-xl p-1 shadow-xs" align="end">
<div className="flex flex-col">
{items.map((item) =>
item.external ? (
<a
key={item.href}
href={item.href}
target="_blank"
rel="noopener noreferrer"
className="hover:bg-muted flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-sm transition-colors"
>
{item.label}
<ArrowUpRight className="size-3 opacity-40" />
</a>
) : (
<Link
key={item.href}
href={item.href}
className="hover:bg-muted rounded-md px-2.5 py-1.5 text-sm transition-colors"
>
{item.label}
</Link>
),
)}
</div>
</HoverCardContent>
</HoverCard>
);
}
+96
View File
@@ -0,0 +1,96 @@
import Link from "next/link";
import { ArrowUpRight } from "lucide-react";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card";
import type { DropdownItem, NavItem } from "@/lib/constants";
function DropdownLink({ link }: { link: DropdownItem }) {
const className =
"hover:bg-muted flex flex-col rounded-md px-2 py-1.5 transition-colors";
if (link.external) {
return (
<a
href={link.href}
target="_blank"
rel="noopener noreferrer"
className={className}
>
<span className="flex items-center gap-1.5 text-sm">
{link.label}
<ArrowUpRight className="size-3 opacity-40" />
</span>
<span className="text-muted-foreground text-xs">
{link.description}
</span>
</a>
);
}
return (
<Link href={link.href} className={className}>
<span className="text-sm">{link.label}</span>
<span className="text-muted-foreground text-xs">{link.description}</span>
</Link>
);
}
export function NavItems({
items,
megaAlign = "start",
}: {
items: NavItem[];
megaAlign?: "start" | "end";
}) {
return items.map((item) => {
if (item.type === "link") {
return (
<Link
key={item.href}
href={item.href}
className="text-muted-foreground hover:text-foreground px-3 py-1.5 text-sm transition-colors"
>
{item.label}
</Link>
);
}
const trigger = (
<HoverCardTrigger asChild>
<button
type="button"
className="text-muted-foreground hover:text-foreground px-3 py-1.5 text-sm transition-colors"
>
{item.label}
</button>
</HoverCardTrigger>
);
return (
<HoverCard key={item.label} openDelay={100} closeDelay={100}>
{trigger}
<HoverCardContent
align={megaAlign}
alignOffset={megaAlign === "end" ? -130 : -52}
className="w-[44rem] rounded-xl p-3 shadow-xs"
>
<div className="grid grid-cols-3 gap-2">
{item.groups.map((group) => (
<div key={group.label} className="flex flex-col gap-1">
<span className="text-muted-foreground px-2 pb-1 text-xs font-medium">
{group.label}
</span>
{group.items.map((link) => (
<DropdownLink key={link.href} link={link} />
))}
</div>
))}
</div>
</HoverCardContent>
</HoverCard>
);
});
}
@@ -0,0 +1,446 @@
"use client";
import { useEffect, useState, useCallback, useMemo, useRef } from "react";
import { useRouter } from "next/navigation";
import {
Search,
CornerDownLeft,
ArrowUp,
ArrowDown,
FileText,
Hash,
Text,
Sparkles,
} from "lucide-react";
import { useDocsSearch } from "fumadocs-core/search/client";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import { analytics } from "@/lib/analytics";
import { useGlobalAskAI } from "@/components/docs/assistant/context";
interface HighlightSegment {
type: "text";
content: string;
styles?: {
highlight?: boolean;
};
}
interface SearchResult {
id: string;
url: string;
content: string;
type: string;
contentWithHighlights?: HighlightSegment[];
}
interface SearchDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
function HighlightedText({
segments,
fallback,
}: {
segments: HighlightSegment[] | undefined;
fallback: string;
}) {
if (!segments || segments.length === 0) {
return <>{fallback}</>;
}
return (
<>
{segments.map((segment, i) => (
<span
key={i}
className={cn(
segment.styles?.highlight &&
"bg-primary/15 text-primary rounded px-0.5",
)}
>
{segment.content}
</span>
))}
</>
);
}
function formatBreadcrumb(url: string): string {
const path = url.split("#")[0] ?? "";
const segments = path.split("/").filter(Boolean);
if (segments.length === 0) return "Home";
return segments
.map((segment) =>
segment
.split("-")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" "),
)
.join(" / ");
}
function getResultTitle(item: SearchResult): string {
if (item.type === "page") {
const path = item.url.split("#")[0] ?? "";
const segments = path.split("/").filter(Boolean);
const lastSegment = segments[segments.length - 1] ?? "Home";
return lastSegment
.split("-")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ");
}
return item.content.replace(/<\/?mark>/g, "");
}
function parseHighlightedContent(content: string): HighlightSegment[] {
const segments: HighlightSegment[] = [];
const regex = /<mark>([\s\S]*?)<\/mark>/g;
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = regex.exec(content)) !== null) {
if (match.index > lastIndex) {
segments.push({
type: "text",
content: content.slice(lastIndex, match.index),
});
}
segments.push({
type: "text",
content: match[1] ?? "",
styles: { highlight: true },
});
lastIndex = regex.lastIndex;
}
if (lastIndex < content.length) {
segments.push({ type: "text", content: content.slice(lastIndex) });
}
return segments;
}
function getResultSegments(item: SearchResult): HighlightSegment[] | undefined {
if (item.contentWithHighlights && item.contentWithHighlights.length > 0) {
return item.contentWithHighlights;
}
if (item.type === "page") return undefined;
return parseHighlightedContent(item.content);
}
function ResultIcon({ type }: { type: string }) {
switch (type) {
case "page":
return <FileText className="size-4" />;
case "heading":
return <Hash className="size-4" />;
default:
return <Text className="size-4" />;
}
}
function getPageUrl(url: string): string {
return url.split("#")[0] ?? "";
}
interface GroupedResult {
pageUrl: string;
items: SearchResult[];
}
export function SearchDialog({ open, onOpenChange }: SearchDialogProps) {
const router = useRouter();
const { setSearch, query } = useDocsSearch({ type: "fetch" });
const [inputValue, setInputValue] = useState("");
const [selectedIndex, setSelectedIndex] = useState(0);
const listRef = useRef<HTMLDivElement>(null);
const askAIFn = useGlobalAskAI();
const latestResults = useMemo((): SearchResult[] => {
if (!query.data || query.data === "empty") return [];
return query.data as SearchResult[];
}, [query.data]);
const staleResultsRef = useRef<SearchResult[]>([]);
useEffect(() => {
if (latestResults.length > 0) {
staleResultsRef.current = latestResults;
}
}, [latestResults]);
const results =
latestResults.length > 0 ? latestResults : staleResultsRef.current;
const groupedResults = useMemo((): GroupedResult[] => {
const groups: GroupedResult[] = [];
let currentGroup: GroupedResult | null = null;
for (const item of results) {
const pageUrl = getPageUrl(item.url);
if (!currentGroup || currentGroup.pageUrl !== pageUrl) {
currentGroup = { pageUrl, items: [item] };
groups.push(currentGroup);
} else {
currentGroup.items.push(item);
}
}
return groups;
}, [results]);
const showAskAI = !!askAIFn && inputValue.length > 0;
const lastTrackedQuery = useRef("");
const searchTrackingTimeout = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
const resultsLengthRef = useRef(0);
useEffect(() => {
resultsLengthRef.current = results.length;
}, [results.length]);
useEffect(() => {
if (open) {
setInputValue("");
setSelectedIndex(0);
lastTrackedQuery.current = "";
staleResultsRef.current = [];
}
}, [open]);
useEffect(() => {
if (inputValue.length === 0) {
setSearch("");
staleResultsRef.current = [];
return;
}
const timer = setTimeout(() => {
setSearch(inputValue);
}, 150);
return () => clearTimeout(timer);
}, [inputValue, setSearch]);
useEffect(() => {
void latestResults;
setSelectedIndex(0);
}, [latestResults]);
useEffect(() => {
if (listRef.current && results.length > 0) {
const selectedElement = listRef.current.querySelector(
`[data-index="${selectedIndex}"]`,
);
selectedElement?.scrollIntoView({ block: "nearest" });
}
}, [selectedIndex, results.length]);
const handleAskAI = useCallback(() => {
if (!askAIFn) return;
analytics.search.askAITriggered(inputValue);
onOpenChange(false);
askAIFn(inputValue);
}, [askAIFn, inputValue, onOpenChange]);
const handleSelect = useCallback(
(url: string) => {
if (searchTrackingTimeout.current) {
clearTimeout(searchTrackingTimeout.current);
if (inputValue.length >= 2 && inputValue !== lastTrackedQuery.current) {
lastTrackedQuery.current = inputValue;
const resultCount = resultsLengthRef.current;
if (resultCount === 0) analytics.search.noResults(inputValue);
else analytics.search.querySubmitted(inputValue, resultCount);
}
}
const position = results.findIndex((r) => r.url === url);
analytics.search.resultClicked(inputValue, url, position);
onOpenChange(false);
router.push(url);
},
[onOpenChange, router, results, inputValue],
);
useEffect(() => {
if (searchTrackingTimeout.current) {
clearTimeout(searchTrackingTimeout.current);
}
if (
!inputValue ||
inputValue.length < 2 ||
query.isLoading ||
inputValue === lastTrackedQuery.current
)
return;
searchTrackingTimeout.current = setTimeout(() => {
lastTrackedQuery.current = inputValue;
const resultCount = resultsLengthRef.current;
if (resultCount === 0) analytics.search.noResults(inputValue);
else analytics.search.querySubmitted(inputValue, resultCount);
}, 500);
return () => {
if (searchTrackingTimeout.current) {
clearTimeout(searchTrackingTimeout.current);
}
};
}, [inputValue, query.isLoading]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "ArrowDown") {
e.preventDefault();
setSelectedIndex((i) => Math.min(i + 1, results.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setSelectedIndex((i) => Math.max(i - 1, 0));
} else if (e.key === "Enter" && results[selectedIndex]) {
e.preventDefault();
handleSelect(results[selectedIndex].url);
}
},
[results, selectedIndex, handleSelect],
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="gap-0 overflow-hidden rounded-3xl border-none p-2 sm:max-w-xl"
showCloseButton={false}
>
<DialogHeader className="sr-only">
<DialogTitle>Search</DialogTitle>
<DialogDescription>Search documentation</DialogDescription>
</DialogHeader>
<div className="overflow-hidden rounded-2xl border">
<div className="border-border/50 flex items-center gap-2.5 border-b px-3">
<Search className="text-muted-foreground size-4" />
<input
type="text"
placeholder="Search documentation..."
value={inputValue}
onChange={(e) => {
setInputValue(e.target.value);
}}
onKeyDown={handleKeyDown}
className="placeholder:text-muted-foreground/60 h-11 flex-1 bg-transparent text-sm outline-none"
autoFocus
/>
{showAskAI && (
<button
type="button"
onClick={handleAskAI}
className="hover:bg-accent hidden shrink-0 items-center gap-1.5 rounded-lg px-2 py-1 text-pink-500 transition-colors sm:flex"
>
<Sparkles className="size-3.5" />
<span className="text-xs font-medium">Ask AI</span>
</button>
)}
</div>
<div
ref={listRef}
className="h-[min(400px,90vh)] overflow-x-hidden overflow-y-auto overscroll-contain"
>
{inputValue.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center gap-3 px-4">
<div className="text-muted-foreground/60 flex items-center gap-6">
<span className="flex items-center gap-1.5 text-sm">
<ArrowUp className="size-3" />
<ArrowDown className="size-3" />
<span>navigate</span>
</span>
<span className="flex items-center gap-1.5 text-sm">
<CornerDownLeft className="size-3" />
<span>select</span>
</span>
</div>
</div>
) : query.isLoading && results.length === 0 ? (
<div className="flex h-full items-center justify-center">
<div className="text-muted-foreground/60 flex items-center gap-2">
<div className="size-1 animate-pulse rounded-full bg-current" />
<div className="size-1 animate-pulse rounded-full bg-current [animation-delay:150ms]" />
<div className="size-1 animate-pulse rounded-full bg-current [animation-delay:300ms]" />
</div>
</div>
) : results.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center gap-1 px-4">
<p className="text-muted-foreground/60 text-sm">
No results for &ldquo;{inputValue}&rdquo;
</p>
</div>
) : (
<div className="p-2">
{(() => {
let flatIndex = 0;
return groupedResults.map((group) => (
<div key={group.pageUrl} className="mb-1 last:mb-0">
{group.items.map((item, itemIndex) => {
const currentIndex = flatIndex++;
const isSelected = currentIndex === selectedIndex;
const title = getResultTitle(item);
const isNested = itemIndex > 0 || item.type !== "page";
const showBreadcrumb = itemIndex === 0;
return (
<button
type="button"
key={item.id}
data-index={currentIndex}
onClick={() => handleSelect(item.url)}
onMouseEnter={() => setSelectedIndex(currentIndex)}
className={cn(
"group flex w-full cursor-pointer items-center gap-3 rounded-lg py-2 pr-3 text-left transition-colors",
isSelected ? "bg-accent" : "hover:bg-accent/50",
isNested ? "pl-9" : "pl-3",
)}
>
<div className="text-muted-foreground shrink-0">
<ResultIcon type={item.type} />
</div>
<div className="flex min-w-0 flex-1 flex-col">
<span className="text-foreground truncate text-sm font-medium">
<HighlightedText
segments={getResultSegments(item)}
fallback={title}
/>
</span>
{showBreadcrumb && (
<span className="text-muted-foreground truncate text-xs">
{formatBreadcrumb(item.url)}
</span>
)}
</div>
<CornerDownLeft
className={cn(
"text-muted-foreground size-3.5 shrink-0 transition-opacity",
isSelected ? "opacity-60" : "opacity-0",
)}
/>
</button>
);
})}
</div>
));
})()}
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,166 @@
"use client";
import { type ReactNode, useMemo } from "react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { cn } from "@/lib/utils";
import { GitHubIcon } from "@/components/icons/github";
import { Select } from "@/components/assistant-ui/select";
import { SUB_PROJECTS } from "@/lib/constants";
import { ThemeToggle } from "./theme-toggle";
import { HeaderBrandLink } from "./header-brand-link";
interface BreadcrumbItem {
label: string;
href: string;
shimmer?: boolean;
}
interface SubProjectLayoutProps {
name: string;
githubPath: string;
breadcrumbs?: BreadcrumbItem[];
children: ReactNode;
hideFooter?: boolean;
fullHeight?: boolean;
}
export function SubProjectLayout({
name,
githubPath,
breadcrumbs: breadcrumbsOverride,
children,
hideFooter = false,
fullHeight = false,
}: SubProjectLayoutProps): React.ReactElement {
const pathname = usePathname();
const router = useRouter();
const breadcrumbs = useMemo(() => {
if (breadcrumbsOverride) {
return breadcrumbsOverride;
}
const basePath = `/${name}`;
if (!pathname.startsWith(basePath) || pathname === basePath) {
return [];
}
const subPath = pathname.slice(basePath.length);
const segments = subPath.split("/").filter(Boolean);
return segments.map((segment, index) => ({
label: segment,
href: `${basePath}/${segments.slice(0, index + 1).join("/")}`,
shimmer: false,
}));
}, [pathname, name, breadcrumbsOverride]);
return (
<div
className={cn(
"flex flex-col",
fullHeight ? "h-svh overflow-hidden" : "min-h-screen",
)}
>
<header
className={cn("z-50 w-full shrink-0", !fullHeight && "sticky top-0")}
>
{!fullHeight && (
<div className="from-background pointer-events-none absolute inset-x-0 top-0 h-14 bg-linear-to-b to-transparent mask-[linear-gradient(to_bottom,black_75%,transparent)] backdrop-blur-xl" />
)}
<div
className={cn(
"relative flex h-12 w-full items-center justify-between px-4",
!fullHeight && "mx-auto max-w-7xl",
)}
>
<div className="flex min-w-0 items-center">
<HeaderBrandLink labelClassName="hidden sm:inline" />
<span className="text-muted-foreground/40 ml-3">/</span>
<Select
value={name}
onValueChange={(value) => router.push(`/${value}`)}
options={SUB_PROJECTS.toSorted((a, b) =>
a.slug.localeCompare(b.slug),
).map((p) => ({
value: p.slug,
label:
p.slug === "tw-shimmer" ? (
<span className="shimmer">{p.label}</span>
) : (
p.label
),
textValue: p.slug,
}))}
/>
<span className="hidden sm:contents">
{breadcrumbs?.map((item, index) => (
<span key={item.href} className="contents">
<span className="text-muted-foreground/40 mr-3 ml-1">/</span>
<Link
href={item.href}
className={cn(
"hover:text-foreground mr-2 text-sm transition-colors",
index === breadcrumbs.length - 1
? "text-foreground"
: "text-muted-foreground",
item.shimmer && "shimmer",
)}
>
{item.label}
</Link>
</span>
))}
</span>
</div>
<div className="flex items-center gap-2">
<div
data-sub-project-header-portal
className="flex items-center gap-1"
/>
<a
href={githubPath}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground flex size-8 items-center justify-center transition-colors"
aria-label="View on GitHub"
>
<GitHubIcon className="size-4" />
</a>
<ThemeToggle />
</div>
</div>
</header>
<main className={cn("flex-1", fullHeight && "min-h-0 overflow-hidden")}>
{children}
</main>
{!hideFooter && (
<footer className="relative px-4 py-8">
<div className="text-muted-foreground mx-auto flex max-w-7xl items-center justify-between text-sm">
<p>
By{" "}
<Link
href="/"
className="hover:text-foreground transition-colors"
>
assistant-ui
</Link>
</p>
<a
href="https://agentbase.dev"
target="_blank"
rel="noopener noreferrer"
className="text-foreground/30 hover:text-foreground text-xs transition-colors"
>
&copy; {new Date().getFullYear()} AgentbaseAI Inc.
</a>
</div>
</footer>
)}
</div>
);
}
+456
View File
@@ -0,0 +1,456 @@
"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 };
@@ -0,0 +1,35 @@
"use client";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
import { Moon, Sun } from "lucide-react";
export function ThemeToggle() {
const { resolvedTheme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const toggleTheme = () => {
setTheme(resolvedTheme === "dark" ? "light" : "dark");
};
return (
<button
type="button"
onClick={toggleTheme}
className="text-muted-foreground hover:text-foreground flex size-8 items-center justify-center transition-colors"
aria-label="Toggle theme"
>
{mounted ? (
resolvedTheme === "dark" ? (
<Moon className="size-4" />
) : (
<Sun className="size-4" />
)
) : (
<div className="size-4" />
)}
</button>
);
}