import type { DragEndEvent, DragMoveEvent, DragStartEvent } from '@dnd-kit/core'; import { closestCenter, DndContext, DragOverlay, PointerSensor, useDraggable, useDroppable, useSensor, useSensors, } from '@dnd-kit/core'; import classNames from 'classnames'; import type { ReactNode, Ref } from 'react'; import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, } from 'react'; import { useKeyValue } from '../../../hooks/useKeyValue'; import { computeSideForDragMove } from '../../../lib/dnd'; import { DropMarker } from '../../DropMarker'; import { ErrorBoundary } from '../../ErrorBoundary'; import type { ButtonProps } from '../Button'; import { Button } from '../Button'; import { Icon } from '../Icon'; import type { RadioDropdownProps } from '../RadioDropdown'; import { RadioDropdown } from '../RadioDropdown'; export type TabItem = | { value: string; label: string; hidden?: boolean; leftSlot?: ReactNode; rightSlot?: ReactNode; } | { value: string; options: Omit; leftSlot?: ReactNode; rightSlot?: ReactNode; }; interface TabsStorage { order: string[]; activeTabs: Record; } export interface TabsRef { /** Programmatically set the active tab */ setActiveTab: (value: string) => void; } interface Props { label: string; /** Default tab value. If not provided, defaults to first tab. */ defaultValue?: string; /** Called when active tab changes */ onChangeValue?: (value: string) => void; tabs: TabItem[]; tabListClassName?: string; className?: string; children: ReactNode; addBorders?: boolean; layout?: 'horizontal' | 'vertical'; /** Storage key for persisting tab order and active tab. When provided, enables drag-to-reorder and active tab persistence. */ storageKey?: string | string[]; /** Key to identify which context this tab belongs to (e.g., request ID). Used for per-context active tab persistence. */ activeTabKey?: string; } export const Tabs = forwardRef(function Tabs( { defaultValue, onChangeValue: onChangeValueProp, label, children, tabs: originalTabs, className, tabListClassName, addBorders, layout = 'vertical', storageKey, activeTabKey, }: Props, forwardedRef: Ref, ) { const ref = useRef(null); const reorderable = !!storageKey; // Use key-value storage for persistence if storageKey is provided // Handle migration from old format (string[]) to new format (TabsStorage) const { value: rawStorage, set: setStorage } = useKeyValue({ namespace: 'no_sync', key: storageKey ?? ['tabs', 'default'], fallback: { order: [], activeTabs: {} }, }); // Migrate old format (string[]) to new format (TabsStorage) const storage: TabsStorage = Array.isArray(rawStorage) ? { order: rawStorage, activeTabs: {} } : (rawStorage ?? { order: [], activeTabs: {} }); const savedOrder = storage.order; // Get the active tab value - prefer storage (if activeTabKey), then defaultValue, then first tab const storedActiveTab = activeTabKey ? storage?.activeTabs?.[activeTabKey] : undefined; const [internalValue, setInternalValue] = useState(undefined); const value = storedActiveTab ?? internalValue ?? defaultValue ?? originalTabs[0]?.value; // Helper to normalize storage (handle migration from old format) const normalizeStorage = useCallback( (s: TabsStorage | string[]): TabsStorage => Array.isArray(s) ? { order: s, activeTabs: {} } : s, [], ); // Handle tab change - update internal state, storage if we have a key, and call prop callback const onChangeValue = useCallback( async (newValue: string) => { setInternalValue(newValue); if (storageKey && activeTabKey) { await setStorage((s) => { const normalized = normalizeStorage(s); return { ...normalized, activeTabs: { ...normalized.activeTabs, [activeTabKey]: newValue }, }; }); } onChangeValueProp?.(newValue); }, [storageKey, activeTabKey, setStorage, onChangeValueProp, normalizeStorage], ); // Expose imperative methods via ref useImperativeHandle( forwardedRef, () => ({ setActiveTab: (value: string) => { onChangeValue(value); }, }), [onChangeValue], ); // Helper to save order const setSavedOrder = useCallback( async (order: string[]) => { await setStorage((s) => { const normalized = normalizeStorage(s); return { ...normalized, order }; }); }, [setStorage, normalizeStorage], ); // State for ordered tabs const [orderedTabs, setOrderedTabs] = useState(originalTabs); const [isDragging, setIsDragging] = useState(null); const [hoveredIndex, setHoveredIndex] = useState(null); // Reorder tabs based on saved order when tabs or savedOrder changes useEffect(() => { if (!storageKey || savedOrder == null || savedOrder.length === 0) { setOrderedTabs(originalTabs); return; } // Create a map of tab values to tab items const tabMap = new Map(originalTabs.map((tab) => [tab.value, tab])); // Reorder based on saved order, adding any new tabs at the end const reordered: TabItem[] = []; const seenValues = new Set(); // Add tabs in saved order for (const value of savedOrder) { const tab = tabMap.get(value); if (tab) { reordered.push(tab); seenValues.add(value); } } // Add any new tabs that weren't in the saved order for (const tab of originalTabs) { if (!seenValues.has(tab.value)) { reordered.push(tab); } } setOrderedTabs(reordered); }, [originalTabs, savedOrder, storageKey]); const tabs = storageKey ? orderedTabs : originalTabs; // Update tabs when value changes useEffect(() => { const tabs = ref.current?.querySelectorAll('[data-tab]'); for (const tab of tabs ?? []) { const v = tab.getAttribute('data-tab'); const parent = tab.closest('.tabs-container'); if (parent !== ref.current) { // Tab is part of a nested tab container, so ignore it } else if (v === value) { tab.setAttribute('data-state', 'active'); tab.setAttribute('aria-hidden', 'false'); tab.style.display = 'block'; } else { tab.setAttribute('data-state', 'inactive'); tab.setAttribute('aria-hidden', 'true'); tab.style.display = 'none'; } } }, [value]); // Drag and drop handlers const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } })); const onDragStart = useCallback( (e: DragStartEvent) => { const tab = tabs.find((t) => t.value === e.active.id); setIsDragging(tab ?? null); }, [tabs], ); const onDragMove = useCallback( (e: DragMoveEvent) => { const overId = e.over?.id as string | undefined; if (!overId) return setHoveredIndex(null); const overTab = tabs.find((t) => t.value === overId); if (overTab == null) return setHoveredIndex(null); // For vertical layout, tabs are arranged horizontally (side-by-side) const orientation = layout === 'vertical' ? 'horizontal' : 'vertical'; const side = computeSideForDragMove(overTab.value, e, orientation); // If computeSideForDragMove returns null (shouldn't happen but be safe), default to null if (side === null) return setHoveredIndex(null); const overIndex = tabs.findIndex((t) => t.value === overId); const hoveredIndex = overIndex + (side === 'before' ? 0 : 1); setHoveredIndex(hoveredIndex); }, [tabs, layout], ); const onDragCancel = useCallback(() => { setIsDragging(null); setHoveredIndex(null); }, []); const onDragEnd = useCallback( (e: DragEndEvent) => { setIsDragging(null); setHoveredIndex(null); const activeId = e.active.id as string | undefined; const overId = e.over?.id as string | undefined; if (!activeId || !overId || activeId === overId) return; const from = tabs.findIndex((t) => t.value === activeId); const baseTo = tabs.findIndex((t) => t.value === overId); const to = hoveredIndex ?? (baseTo === -1 ? from : baseTo); if (from !== -1 && to !== -1 && from !== to) { const newTabs = [...tabs]; const [moved] = newTabs.splice(from, 1); if (moved === undefined) return; newTabs.splice(to > from ? to - 1 : to, 0, moved); setOrderedTabs(newTabs); // Save order to storage setSavedOrder(newTabs.map((t) => t.value)).catch(console.error); } }, [tabs, hoveredIndex, setSavedOrder], ); const tabButtons = useMemo(() => { const items: ReactNode[] = []; tabs.forEach((t, i) => { if ('hidden' in t && t.hidden) { return; } const isActive = t.value === value; const showDropMarkerBefore = hoveredIndex === i; if (showDropMarkerBefore) { items.push(
, ); } items.push( , ); }); return items; }, [tabs, value, addBorders, layout, reorderable, isDragging, onChangeValue, hoveredIndex]); const tabList = (
{tabButtons} {hoveredIndex === tabs.length && (
)}
); return (
{reorderable ? ( {tabList} {isDragging && ( )} ) : ( tabList )} {children}
); }); interface TabButtonProps { tab: TabItem; isActive: boolean; addBorders?: boolean; layout: 'horizontal' | 'vertical'; reorderable: boolean; isDragging: boolean; onChangeValue?: (value: string) => void; overlay?: boolean; } function TabButton({ tab, isActive, addBorders, layout, reorderable, isDragging, onChangeValue, overlay = false, }: TabButtonProps) { const { attributes, listeners, setNodeRef: setDraggableRef, } = useDraggable({ id: tab.value, disabled: !reorderable, // The button inside handles focus attributes: { tabIndex: -1 }, }); const { setNodeRef: setDroppableRef } = useDroppable({ id: tab.value, disabled: !reorderable, }); const handleSetWrapperRef = useCallback( (n: HTMLDivElement | null) => { if (reorderable) { setDraggableRef(n); setDroppableRef(n); } }, [reorderable, setDraggableRef, setDroppableRef], ); const btnProps: Partial = { color: 'custom', justify: layout === 'horizontal' ? 'start' : 'center', onClick: isActive ? undefined : (e: React.MouseEvent) => { e.preventDefault(); // Prevent dropdown from opening on first click onChangeValue?.(tab.value); }, className: classNames( 'flex items-center rounded whitespace-nowrap', '!px-2 ml-[1px]', 'outline-none', 'ring-none', 'focus-visible-or-class:outline-2', addBorders && 'border focus-visible:bg-surface-highlight', isActive ? 'text-text' : 'text-text-subtle', isActive && addBorders ? 'border-surface-active bg-surface-active' : layout === 'vertical' ? 'border-border-subtle' : 'border-transparent', layout === 'horizontal' && 'min-w-[10rem]', isDragging && 'opacity-50', overlay && 'opacity-80', ), }; const buttonContent = (() => { if ('options' in tab) { const option = tab.options.items.find((i) => 'value' in i && i.value === tab.options.value); return ( ); } return ( ); })(); // Apply drag handlers to wrapper, not button const wrapperProps = reorderable && !overlay ? { ...attributes, ...listeners } : {}; return (
{buttonContent}
); } interface TabContentProps { value: string; children: ReactNode; className?: string; } export const TabContent = memo(function TabContent({ value, children, className, }: TabContentProps) { return (
{children}
); }); /** * Programmatically set the active tab for a Tabs component that uses storageKey + activeTabKey. * This is useful when you need to change the tab from outside the component (e.g., in response to an event). */ export async function setActiveTab({ storageKey, activeTabKey, value, }: { storageKey: string; activeTabKey: string; value: string; }): Promise { const { getKeyValue, setKeyValue } = await import('../../../lib/keyValueStore'); const current = getKeyValue({ namespace: 'no_sync', key: storageKey, fallback: { order: [], activeTabs: {} }, }); await setKeyValue({ namespace: 'no_sync', key: storageKey, value: { ...current, activeTabs: { ...current.activeTabs, [activeTabKey]: value }, }, }); }