import { useEffect, useLayoutEffect, useMemo, useRef, useState, type ButtonHTMLAttributes, type CSSProperties, type KeyboardEvent, type MouseEvent, type ReactNode, } from "react"; import { createPortal } from "react-dom"; import { DndContext, DragOverlay, KeyboardSensor, PointerSensor, closestCenter, useSensor, useSensors, type DragEndEvent, type DragStartEvent, } from "@dnd-kit/core"; import { SortableContext, horizontalListSortingStrategy, sortableKeyboardCoordinates, useSortable, } from "@dnd-kit/sortable"; import { CSS as DndCSS } from "@dnd-kit/utilities"; import { Plus, X } from "lucide-react"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import { SidePanelTab } from "./SidePanelTab"; import type { SidePanelTabItem } from "./types"; interface SortableSidePanelTabProps { tab: SidePanelTabItem; active: boolean; dragging: boolean; appearance: "default" | "streamlined-task"; showLeadingSeparator: boolean; onSelect: () => void; onClose: () => void; onAuxClick: (event: MouseEvent) => void; onKeyDown: (event: KeyboardEvent) => void; } function SortableSidePanelTab({ tab, active, dragging, appearance, showLeadingSeparator, onSelect, onClose, onAuxClick, onKeyDown, }: SortableSidePanelTabProps) { const sortable = useSortable({ id: tab.id, disabled: tab.disabled }); return (
{showLeadingSeparator ? ( ) : null} } onSelect={onSelect} onClose={onClose} onAuxClick={onAuxClick} onKeyDown={onKeyDown} />
); } function StreamlinedTabDragPreview({ tab, active, width, labelIsTruncated, }: { tab: SidePanelTabItem; active: boolean; width: number; labelIsTruncated: boolean; }) { return (
{tab.label} {tab.closable === false ? null : ( )}
); } export interface SidePanelTabsProps { tabs: SidePanelTabItem[]; activeTabId: string | null; onActiveTabChange: (tabId: string) => void; onCloseTab: (tabId: string) => void; onReorderTabs?: (orderedTabIds: string[]) => void; onAddTab?: () => void; addControl?: ReactNode; addLabel?: string; appearance?: "default" | "streamlined-task"; className?: string; } export function SidePanelTabs({ tabs, activeTabId, onActiveTabChange, onCloseTab, onReorderTabs, onAddTab, addControl, addLabel = "Open a new tab", appearance = "default", className, }: SidePanelTabsProps) { const [announcement, setAnnouncement] = useState(""); const [showEndFade, setShowEndFade] = useState(false); const [useWideTabs, setUseWideTabs] = useState(false); const [draggedTab, setDraggedTab] = useState<{ id: string; width: number; labelIsTruncated: boolean } | null>(null); const scrollRef = useRef(null); const addButtonRef = useRef(null); const addControlRef = useRef(null); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 4 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), ); const tabIds = useMemo(() => tabs.map((tab) => tab.id), [tabs]); const draggedTabItem = draggedTab ? tabs.find((tab) => tab.id === draggedTab.id) : null; useLayoutEffect(() => { const element = scrollRef.current; if (!element || appearance !== "streamlined-task") return; const labels = Array.from(element.querySelectorAll("[data-side-panel-tab-label-content]")); const updateSharedWidth = () => { const compactWidth = parseFloat(getComputedStyle(element).getPropertyValue("--side-panel-streamlined-tab-min-width")); // Measure intrinsic text against the compact width, regardless of the current // row width. Using current truncation would oscillate between the two sizes. setUseWideTabs(labels.some((content) => { const label = content.parentElement!; const wrapper = content.closest("[data-side-panel-tab-wrapper]")!; const chromeWidth = wrapper.getBoundingClientRect().width - label.getBoundingClientRect().width; return content.getBoundingClientRect().width + chromeWidth > compactWidth; })); }; updateSharedWidth(); const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(updateSharedWidth); observer?.observe(element); labels.forEach((label) => observer?.observe(label)); return () => observer?.disconnect(); }, [appearance, tabs]); function findTabElement(tabId: string, selector: "wrapper" | "target") { const attribute = selector === "wrapper" ? "data-side-panel-tab-wrapper" : "data-side-panel-tab-target"; return Array.from( scrollRef.current?.querySelectorAll(`[${attribute}]`) ?? [], ).find((element) => element.getAttribute(attribute) === tabId) ?? null; } useEffect(() => { if (!activeTabId) return; const element = findTabElement(activeTabId, "wrapper"); const reducedMotion = typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches; element?.scrollIntoView({ behavior: reducedMotion ? "auto" : "smooth", block: "nearest", inline: "nearest" }); // `findTabElement` only reads the committed tab DOM for this active id. // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeTabId]); useEffect(() => { const element = scrollRef.current; if (!element || appearance !== "streamlined-task") { setShowEndFade(false); return; } const updateEndFade = () => { const remainingScroll = element.scrollWidth - element.clientWidth - element.scrollLeft; setShowEndFade(remainingScroll > 1); }; updateEndFade(); element.addEventListener("scroll", updateEndFade, { passive: true }); const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(updateEndFade); observer?.observe(element); if (element.firstElementChild) observer?.observe(element.firstElementChild); return () => { element.removeEventListener("scroll", updateEndFade); observer?.disconnect(); }; }, [appearance, tabIds]); function focusTab(tabId: string | null) { window.requestAnimationFrame(() => { if (!tabId) { addButtonRef.current?.focus(); addControlRef.current?.querySelector("button, [href], input, [tabindex]:not([tabindex='-1'])")?.focus(); return; } const tab = findTabElement(tabId, "target") as HTMLButtonElement | null; tab?.focus(); }); } function closeTab(tabId: string) { const index = tabIds.indexOf(tabId); const nextFocus = tabIds[index + 1] ?? tabIds[index - 1] ?? null; onCloseTab(tabId); setAnnouncement(nextFocus ? "Tab closed." : "Last tab closed. Choose something to open."); focusTab(nextFocus); } function handleKeyDown(event: KeyboardEvent, tabId: string) { const index = tabIds.indexOf(tabId); if (index < 0) return; if (event.altKey && event.shiftKey && (event.key === "ArrowLeft" || event.key === "ArrowRight")) { if (!onReorderTabs) return; event.preventDefault(); const target = event.key === "ArrowLeft" ? index - 1 : index + 1; if (target < 0 || target >= tabIds.length) return; const ordered = [...tabIds]; const [moved] = ordered.splice(index, 1); ordered.splice(target, 0, moved!); onReorderTabs(ordered); setAnnouncement(`Moved ${tabs[index]?.label ?? "tab"} to position ${target + 1} of ${tabs.length}.`); focusTab(tabId); return; } const targetIndex = event.key === "ArrowLeft" ? Math.max(0, index - 1) : event.key === "ArrowRight" ? Math.min(tabIds.length - 1, index + 1) : event.key === "Home" ? 0 : event.key === "End" ? tabIds.length - 1 : -1; if (targetIndex < 0 || targetIndex === index) return; event.preventDefault(); const targetId = tabIds[targetIndex]!; onActiveTabChange(targetId); focusTab(targetId); } function handleDragEnd(event: DragEndEvent) { if (!onReorderTabs || !event.over || event.active.id === event.over.id) return; const from = tabIds.indexOf(String(event.active.id)); const to = tabIds.indexOf(String(event.over.id)); if (from < 0 || to < 0) return; const ordered = [...tabIds]; const [moved] = ordered.splice(from, 1); ordered.splice(to, 0, moved!); onReorderTabs(ordered); setAnnouncement(`Moved ${tabs[from]?.label ?? "tab"} to position ${to + 1} of ${tabs.length}.`); } function handleDragStart(event: DragStartEvent) { if (appearance !== "streamlined-task") return; const id = String(event.active.id); const wrapper = findTabElement(id, "wrapper"); if (!wrapper) return; setDraggedTab({ id, width: wrapper.getBoundingClientRect().width, labelIsTruncated: wrapper.querySelector('[data-truncated="true"]') !== null, }); } return (
{ handleDragEnd(event); setDraggedTab(null); }} onDragCancel={() => setDraggedTab(null)} >
{tabs.map((tab, index) => ( 0 && tab.id !== activeTabId && tabs[index - 1]?.id !== activeTabId } onSelect={() => onActiveTabChange(tab.id)} onClose={() => closeTab(tab.id)} onAuxClick={(event) => { if (event.button !== 1 || tab.closable === false) return; event.preventDefault(); closeTab(tab.id); }} onKeyDown={(event) => handleKeyDown(event, tab.id)} /> ))}
{appearance === "streamlined-task" && typeof document !== "undefined" ? createPortal( {draggedTab && draggedTabItem ? ( ) : null} , document.body, ) : null}
{addControl ?
{addControl}
: (onAddTab ? ( ) : null)}
{announcement}
); }