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 } from 'react'; import { memo, useCallback, useEffect, 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 Props { label: string; value?: string; onChangeValue: (value: string) => void; tabs: TabItem[]; tabListClassName?: string; className?: string; children: ReactNode; addBorders?: boolean; layout?: 'horizontal' | 'vertical'; storageKey?: string | string[]; } export function Tabs({ value, onChangeValue, label, children, tabs: originalTabs, className, tabListClassName, addBorders, layout = 'vertical', storageKey, }: Props) { const ref = useRef(null); const reorderable = !!storageKey; // Use key-value storage for persistence if storageKey is provided const { value: savedOrder, set: setSavedOrder } = useKeyValue({ namespace: 'global', key: storageKey ?? ['tabs_order', 'default'], fallback: [], }); // 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; value = value ?? tabs[0]?.value; // 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, }); 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 : () => 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}
); });