mirror of https://github.com/buster-so/buster.git
260 lines
7.3 KiB
TypeScript
260 lines
7.3 KiB
TypeScript
'use client';
|
|
|
|
import {
|
|
closestCenter,
|
|
DndContext,
|
|
type DragEndEvent,
|
|
DragOverlay,
|
|
type DragStartEvent,
|
|
KeyboardSensor,
|
|
PointerSensor,
|
|
useSensor,
|
|
useSensors,
|
|
} from '@dnd-kit/core';
|
|
import { restrictToVerticalAxis } from '@dnd-kit/modifiers';
|
|
import {
|
|
arrayMove,
|
|
SortableContext,
|
|
sortableKeyboardCoordinates,
|
|
useSortable,
|
|
verticalListSortingStrategy,
|
|
} from '@dnd-kit/sortable';
|
|
import { CSS } from '@dnd-kit/utilities';
|
|
import React, { useEffect } from 'react';
|
|
import { useMemoizedFn } from '@/hooks/useMemoizedFn';
|
|
import { cn } from '@/lib/classMerge';
|
|
import {
|
|
Collapsible,
|
|
CollapsibleContent,
|
|
CollapsibleTrigger,
|
|
} from '../collapsible/CollapsibleBase';
|
|
import { CaretDown } from '../icons/NucleoIconFilled';
|
|
import { COLLAPSED_HIDDEN } from './config';
|
|
import type { ISidebarGroup } from './interfaces';
|
|
import { SidebarItem } from './SidebarItem';
|
|
|
|
const modifiers = [restrictToVerticalAxis];
|
|
|
|
interface SidebarTriggerProps {
|
|
label: string;
|
|
isOpen: boolean;
|
|
className?: string;
|
|
}
|
|
|
|
const SidebarTrigger: React.FC<SidebarTriggerProps> = React.memo(({ label, isOpen, className }) => {
|
|
return (
|
|
<div
|
|
className={cn(
|
|
'flex items-center gap-1 rounded px-1.5 py-1 text-base transition-colors',
|
|
'text-text-secondary hover:bg-nav-item-hover',
|
|
'group min-h-6 cursor-pointer',
|
|
className
|
|
)}
|
|
>
|
|
<span className="">{label}</span>
|
|
|
|
<div
|
|
className={cn(
|
|
'text-icon-color text-3xs -rotate-90 transition-transform duration-200',
|
|
isOpen && 'rotate-0'
|
|
)}
|
|
>
|
|
<CaretDown />
|
|
</div>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
SidebarTrigger.displayName = 'SidebarTrigger';
|
|
|
|
interface SortableSidebarItemProps {
|
|
item: ISidebarGroup['items'][0];
|
|
active?: boolean;
|
|
}
|
|
|
|
const SortableSidebarItem: React.FC<SortableSidebarItemProps> = React.memo(({ item, active }) => {
|
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
|
id: item.id,
|
|
disabled: item.disabled,
|
|
});
|
|
|
|
const style = {
|
|
transform: CSS.Transform.toString(transform),
|
|
transition,
|
|
opacity: isDragging ? 0 : 1,
|
|
};
|
|
|
|
const handleClick = useMemoizedFn((e: React.MouseEvent) => {
|
|
if (isDragging) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}
|
|
});
|
|
|
|
return (
|
|
<div
|
|
ref={setNodeRef}
|
|
style={style}
|
|
{...attributes}
|
|
{...listeners}
|
|
onClick={handleClick}
|
|
className={cn(isDragging && 'pointer-events-none')}
|
|
>
|
|
<div onClick={isDragging ? (e) => e.stopPropagation() : undefined}>
|
|
<SidebarItem {...item} active={active} />
|
|
</div>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
SortableSidebarItem.displayName = 'SortableSidebarItem';
|
|
|
|
export const SidebarCollapsible: React.FC<
|
|
ISidebarGroup & {
|
|
useCollapsible?: boolean;
|
|
activeItem?: string;
|
|
onItemsReorder?: (ids: string[]) => void;
|
|
}
|
|
> = React.memo(
|
|
({
|
|
label,
|
|
items,
|
|
isSortable = false,
|
|
activeItem,
|
|
onItemsReorder,
|
|
variant = 'collapsible',
|
|
icon,
|
|
defaultOpen = true,
|
|
useCollapsible,
|
|
triggerClassName,
|
|
className,
|
|
}) => {
|
|
// Track client mount to avoid SSR/CSR hydration mismatches for dnd-kit generated attributes
|
|
const [isMounted, setIsMounted] = React.useState(false);
|
|
React.useEffect(() => {
|
|
setIsMounted(true);
|
|
}, []);
|
|
|
|
const [isOpen, setIsOpen] = React.useState(defaultOpen);
|
|
const [sortedItems, setSortedItems] = React.useState(items);
|
|
const [draggingId, setDraggingId] = React.useState<string | null>(null);
|
|
|
|
const sensors = useSensors(
|
|
useSensor(PointerSensor, {
|
|
activationConstraint: {
|
|
distance: 2,
|
|
},
|
|
}),
|
|
useSensor(KeyboardSensor, {
|
|
coordinateGetter: sortableKeyboardCoordinates,
|
|
})
|
|
);
|
|
|
|
const handleDragStart = useMemoizedFn((event: DragStartEvent) => {
|
|
setDraggingId(event.active.id as string);
|
|
});
|
|
|
|
const handleDragEnd = useMemoizedFn((event: DragEndEvent) => {
|
|
const { active, over } = event;
|
|
setDraggingId(null);
|
|
|
|
if (active.id !== over?.id) {
|
|
setSortedItems((items) => {
|
|
const oldIndex = items.findIndex((item) => item.id === active.id);
|
|
const newIndex = items.findIndex((item) => item.id === over?.id);
|
|
const moveddArray = arrayMove(items, oldIndex, newIndex);
|
|
onItemsReorder?.(moveddArray.map((item) => item.id));
|
|
return moveddArray;
|
|
});
|
|
}
|
|
});
|
|
|
|
const draggingItem = draggingId ? sortedItems.find((item) => item.id === draggingId) : null;
|
|
|
|
useEffect(() => {
|
|
setSortedItems(items);
|
|
}, [items]);
|
|
|
|
return (
|
|
<Collapsible open={isOpen} onOpenChange={setIsOpen} className={cn('space-y-0.5', className)}>
|
|
{variant === 'collapsible' && (
|
|
<CollapsibleTrigger
|
|
asChild
|
|
className={cn(useCollapsible && COLLAPSED_HIDDEN, 'w-full', triggerClassName)}
|
|
>
|
|
<button type="button" className="w-full cursor-pointer text-left">
|
|
<SidebarTrigger label={label} isOpen={isOpen} />
|
|
</button>
|
|
</CollapsibleTrigger>
|
|
)}
|
|
|
|
{variant === 'icon' && (
|
|
<div
|
|
className={cn(
|
|
'flex items-center space-x-2.5 px-1.5 py-1 text-base',
|
|
'text-text-secondary'
|
|
)}
|
|
>
|
|
{icon && <span className="text-icon-color text-icon-size">{icon}</span>}
|
|
<span className="">{label}</span>
|
|
</div>
|
|
)}
|
|
|
|
<CollapsibleContent
|
|
className={cn(
|
|
isMounted &&
|
|
'data-[state=open]:animate-collapsible-down data-[state=closed]:animate-collapsible-up',
|
|
'h-[var(--radix-collapsible-content-height)]'
|
|
)}
|
|
style={{
|
|
minHeight:
|
|
!isMounted && isOpen ? `${items.length * 28 + items.length * 2 - 2}px` : undefined,
|
|
}}
|
|
>
|
|
<div className="gap-y-0.5 flex flex-col">
|
|
{isMounted && isSortable ? (
|
|
<DndContext
|
|
sensors={sensors}
|
|
collisionDetection={closestCenter}
|
|
onDragStart={handleDragStart}
|
|
onDragEnd={handleDragEnd}
|
|
modifiers={modifiers}
|
|
>
|
|
<SortableContext
|
|
items={sortedItems.map((item) => item.id)}
|
|
strategy={verticalListSortingStrategy}
|
|
>
|
|
{sortedItems.map((item) => (
|
|
<SortableSidebarItem
|
|
key={item.id}
|
|
item={item}
|
|
active={activeItem === item.id || item.active}
|
|
/>
|
|
))}
|
|
</SortableContext>
|
|
<DragOverlay>
|
|
{draggingId && draggingItem ? (
|
|
<div className="opacity-70 shadow">
|
|
<SidebarItem {...draggingItem} active={draggingItem.active} />
|
|
</div>
|
|
) : null}
|
|
</DragOverlay>
|
|
</DndContext>
|
|
) : (
|
|
items.map((item) => (
|
|
<SidebarItem
|
|
key={item.id}
|
|
{...item}
|
|
active={activeItem === item.id || item.active}
|
|
/>
|
|
))
|
|
)}
|
|
</div>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
);
|
|
}
|
|
);
|
|
|
|
SidebarCollapsible.displayName = 'SidebarCollapsible';
|