"use client"; import { ArrowLeftIcon, ArrowRightIcon, CopyIcon, GlobeIcon, ListXIcon, LockIcon, PinIcon, PinOffIcon, PlusIcon, RotateCwIcon, XIcon } from "lucide-react"; import { AnimatePresence, motion, Reorder, useReducedMotion } from "motion/react"; import { type KeyboardEvent, type ReactNode, useEffect, useLayoutEffect, useRef, useState } from "react"; import { ContextMenu, ContextMenuItem, ContextMenuPopup, ContextMenuSeparator, ContextMenuShortcut, ContextMenuTrigger, } from "@/components/ui/context-menu"; import { cn } from "@/lib/utils"; export type TabTone = "sky" | "violet" | "emerald" | "amber" | "rose" | "teal" | "neutral"; export interface BrowserTab { id: string; title: string; url: string; tone?: TabTone; mark?: string; pinned?: boolean; headline?: string; } export interface BrowserTabsProps { defaultTabs: BrowserTab[]; defaultActiveId?: string; renderPage?: (tab: BrowserTab) => ReactNode; newTab?: () => Omit; onChange?: (tabs: BrowserTab[], activeId: string | null) => void; className?: string; } const FAVICON: Record = { sky: "bg-sky-500 text-white", violet: "bg-violet-500 text-white", emerald: "bg-emerald-500 text-white", amber: "bg-amber-400 text-amber-950", rose: "bg-rose-500 text-white", teal: "bg-teal-500 text-white", neutral: "bg-neutral-400 text-white dark:bg-neutral-600", }; const PAGE_TINT: Record = { sky: "bg-sky-500/10 text-sky-700 dark:text-sky-300", violet: "bg-violet-500/10 text-violet-700 dark:text-violet-300", emerald: "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300", amber: "bg-amber-500/12 text-amber-700 dark:text-amber-300", rose: "bg-rose-500/10 text-rose-700 dark:text-rose-300", teal: "bg-teal-500/10 text-teal-700 dark:text-teal-300", neutral: "bg-muted text-muted-foreground", }; const PINNED_W = 40; const MIN_W = 104; const MAX_W = 196; const NEW_W = 36; const pinnedFirst = (list: BrowserTab[]) => [...list.filter((t) => t.pinned), ...list.filter((t) => !t.pinned)]; let counter = 0; export function BrowserTabs({ defaultTabs, defaultActiveId, renderPage, newTab, onChange, className }: BrowserTabsProps) { const reduce = useReducedMotion(); const [tabs, setTabs] = useState(() => pinnedFirst(defaultTabs)); const [active, setActive] = useState(defaultActiveId ?? defaultTabs[0]?.id ?? null); const [menuTab, setMenuTab] = useState(null); const [stripWidth, setStripWidth] = useState(0); // After closing with the mouse, widths hold still so the next close button lands under the pointer. const [frozen, setFrozen] = useState(null); const [edges, setEdges] = useState({ left: false, right: false }); const stripRef = useRef(null); const areaRef = useRef(null); const tabRefs = useRef(new Map()); useLayoutEffect(() => { const el = areaRef.current; if (!el) return; const ro = new ResizeObserver(() => setStripWidth(el.clientWidth)); ro.observe(el); setStripWidth(el.clientWidth); return () => ro.disconnect(); }, []); const checkEdges = () => { const el = stripRef.current; if (!el) return; setEdges({ left: el.scrollLeft > 2, right: el.scrollLeft + el.clientWidth < el.scrollWidth - 2 }); }; useEffect(() => { const t = window.setTimeout(checkEdges, 260); return () => clearTimeout(t); }, [tabs, stripWidth, frozen]); const commit = (next: BrowserTab[], nextActive: string | null = active) => { setTabs(next); setActive(nextActive); onChange?.(next, nextActive); }; const pinnedCount = tabs.filter((t) => t.pinned).length; const looseCount = tabs.length - pinnedCount; const fit = looseCount ? Math.floor((stripWidth - pinnedCount * PINNED_W - NEW_W - 12) / looseCount) : MAX_W; const width = frozen ?? Math.max(MIN_W, Math.min(MAX_W, fit)); const close = (id: string, byPointer = false) => { const i = tabs.findIndex((t) => t.id === id); if (i < 0) return; if (byPointer && !tabs[i].pinned) setFrozen(width); const next = tabs.filter((t) => t.id !== id); let nextActive = active; if (active === id) nextActive = next[Math.min(i, next.length - 1)]?.id ?? null; commit(next, nextActive); if (!byPointer && nextActive) requestAnimationFrame(() => tabRefs.current.get(nextActive)?.focus()); }; const open = () => { const base = newTab?.() ?? { title: "New Tab", url: "", tone: "neutral" as const, mark: "" }; const tab = { ...base, id: `tab-${Date.now()}-${counter++}` }; commit([...tabs, tab], tab.id); const el = stripRef.current; if (!el) return; const start = performance.now(); const follow = () => { const end = el.scrollWidth - el.clientWidth; const done = reduce || performance.now() - start > 450; el.scrollLeft = done ? end : el.scrollLeft + (end - el.scrollLeft) * 0.3; if (!done) requestAnimationFrame(follow); }; requestAnimationFrame(follow); }; const togglePin = (id: string) => commit(pinnedFirst(tabs.map((t) => (t.id === id ? { ...t, pinned: !t.pinned } : t)))); const duplicate = (id: string) => { const i = tabs.findIndex((t) => t.id === id); if (i < 0) return; const copy = { ...tabs[i], id: `tab-${Date.now()}-${counter++}`, pinned: false }; const next = [...tabs]; next.splice(i + 1, 0, copy); commit(pinnedFirst(next), copy.id); }; const closeOthers = (id: string) => commit(tabs.filter((t) => t.id === id || t.pinned), id); const move = (id: string, dir: -1 | 1) => { const i = tabs.findIndex((t) => t.id === id); const j = i + dir; if (j < 0 || j >= tabs.length || tabs[j].pinned !== tabs[i].pinned) return; const next = [...tabs]; [next[i], next[j]] = [next[j], next[i]]; commit(next); }; const onTabKey = (event: KeyboardEvent, tab: BrowserTab) => { const i = tabs.findIndex((t) => t.id === tab.id); if (event.altKey && event.shiftKey && (event.key === "ArrowLeft" || event.key === "ArrowRight")) { event.preventDefault(); move(tab.id, event.key === "ArrowLeft" ? -1 : 1); requestAnimationFrame(() => tabRefs.current.get(tab.id)?.focus()); return; } let next: BrowserTab | undefined; if (event.key === "ArrowRight") next = tabs[(i + 1) % tabs.length]; else if (event.key === "ArrowLeft") next = tabs[(i - 1 + tabs.length) % tabs.length]; else if (event.key === "Home") next = tabs[0]; else if (event.key === "End") next = tabs[tabs.length - 1]; else if (event.key === "Delete" || event.key === "Backspace") { event.preventDefault(); close(tab.id); return; } if (next) { event.preventDefault(); setActive(next.id); tabRefs.current.get(next.id)?.focus(); tabRefs.current.get(next.id)?.scrollIntoView({ block: "nearest", inline: "nearest" }); } }; const current = tabs.find((t) => t.id === active) ?? null; const menuTarget = tabs.find((t) => t.id === menuTab); const spring = reduce ? { duration: 0 } : { type: "spring" as const, stiffness: 520, damping: 42 }; return (
setFrozen(null)} onScroll={checkEdges} ref={stripRef} style={{ maskImage: `linear-gradient(to right, ${edges.left ? "transparent, black 20px" : "black, black"}, ${edges.right ? "black calc(100% - 20px), transparent" : "black, black"})`, }} > commit(pinnedFirst(next))} role="tablist" values={tabs} > {tabs.map((tab, i) => { const isActive = tab.id === active; const prevActive = tabs[i - 1]?.id === active; return ( 0} exit={{ width: 0, opacity: 0, transition: { duration: reduce ? 0 : 0.16, ease: "easeOut" } }} initial={reduce ? false : { width: 0, opacity: 0 }} key={tab.id} layout="position" onAuxClick={(event) => { if (event.button === 1) close(tab.id, true); }} onContextMenu={() => setMenuTab(tab.id)} onPointerDown={(event) => { if (event.button === 0) setActive(tab.id); }} role="presentation" // Reorder.Item adds tabIndex 0 unless it is overridden; focus belongs to the inner tab tabIndex={undefined} transition={spring} value={tab} whileDrag={{ zIndex: 20 }} >
onTabKey(event, tab)} ref={(el: HTMLDivElement | null) => { if (el) tabRefs.current.set(tab.id, el); else tabRefs.current.delete(tab.id); }} role="tab" tabIndex={isActive ? 0 : -1} title={tab.title} > {!isActive && ( )} {!tab.pinned && ( <> {tab.title} )}
{!tab.pinned && ( )}
); })}
{menuTarget && ( <> togglePin(menuTarget.id)}> {menuTarget.pinned ? : } {menuTarget.pinned ? "Unpin tab" : "Pin tab"} duplicate(menuTarget.id)}> Duplicate close(menuTarget.id)}> Close tab Del closeOthers(menuTarget.id)}> Close other tabs )}
{[ArrowLeftIcon, ArrowRightIcon, RotateCwIcon].map((Icon, i) => ( ))}
{current?.url ? : null} {current?.url ? ( <> {current.url.split("/")[0]} {current.url.slice(current.url.split("/")[0].length)} ) : ( Search or type a URL )}
{current ? ( (renderPage ?? DefaultPage)(current) ) : (

No tabs open

)}
); } function Favicon({ tab }: { tab: BrowserTab }) { const mark = tab.mark ?? tab.title.charAt(0); if (!mark) return ; return ( {mark} ); } function DefaultPage(tab: BrowserTab) { const tint = PAGE_TINT[tab.tone ?? "neutral"]; if (!tab.url) { return (
Search the web
{["S", "M", "D", "B"].map((l) => ( {l} ))}
); } return (
{tab.title.split(" ")[0]} Product Pricing Docs

{tab.headline ?? tab.title}

{[0, 1, 2].map((i) => (
))}
); }