@medalsocial/meda
Version:
Shared Meda UI shell and runtime package.
198 lines (197 loc) • 13.6 kB
JavaScript
'use client';
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
import { DndContext, DragOverlay, KeyboardSensor, MeasuringStrategy, PointerSensor, useDndMonitor, useSensor, useSensors, } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { ChevronRight, EyeOff, MoreHorizontal } from 'lucide-react';
import { useCallback, useDeferredValue, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from '../components/ui/collapsible.js';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '../components/ui/dropdown-menu.js';
import { cn } from '../lib/utils.js';
import { KanbanCardWrapper } from './kanban-card-wrapper.js';
import { kanbanCollisionDetection } from './kanban-collision.js';
import { KanbanColumn } from './kanban-column.js';
import { handleKanbanColumnDrop } from './kanban-drop-handler.js';
import { defaultKanbanLabels, } from './types.js';
const interpolate = (template, vars) => Object.entries(vars).reduce((s, [k, v]) => s.replace(new RegExp(`\\{\\{${k}\\}\\}`, 'g'), v), template);
function resolveOverColumnStatus(overId, columns, items) {
const targetColumn = columns.find((col) => col.id === overId);
if (targetColumn) {
return targetColumn.id;
}
const targetItem = items.find((item) => item.id === overId);
return targetItem?.status ?? null;
}
/**
* Inner component that always renders inside a DndContext — either the kanban's
* own (non-headless) or the consumer's outer context (headless). This means
* `useDndMonitor` is always safe to call here, enabling the DragOverlay to
* track external drags when `headless={true}`.
*/
function KanbanBoardInner({ columns, items, renderCard, onReorder, onCardMove, canDropCard, onAddItem, showEmptyColumns = false, hiddenColumnIds = [], onHiddenColumnIdsChange, emptyColumnContent, labels, headless = false, className, }) {
const resolvedLabels = { ...defaultKanbanLabels, ...(labels ?? {}) };
const [activeId, setActiveId] = useState(null);
const [overColumnId, setOverColumnId] = useState(null);
// Defer item updates during drag to keep the overlay responsive
const deferredItems = useDeferredValue(items);
// Group items by status
const itemsByStatus = useMemo(() => {
const grouped = new Map();
// Initialize all columns
for (const column of columns) {
grouped.set(column.id, []);
}
// Group items
for (const item of deferredItems) {
const status = item.status;
const columnItems = grouped.get(status);
if (columnItems) {
columnItems.push(item);
}
}
// Sort each column by position
for (const [status, columnItems] of grouped) {
columnItems.sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
grouped.set(status, columnItems);
}
return grouped;
}, [columns, deferredItems]);
// Get the active item being dragged
const activeItem = useMemo(() => {
if (!activeId)
return null;
return items.find((item) => item.id === activeId) ?? null;
}, [activeId, items]);
const hiddenColumnIdSet = useMemo(() => new Set(hiddenColumnIds.map((columnId) => String(columnId))), [hiddenColumnIds]);
const hiddenColumns = useMemo(() => columns.filter((column) => hiddenColumnIdSet.has(String(column.id))), [columns, hiddenColumnIdSet]);
const explicitlyVisibleColumns = useMemo(() => columns.filter((column) => !hiddenColumnIdSet.has(String(column.id))), [columns, hiddenColumnIdSet]);
// Filter columns if not showing empty ones
const visibleColumns = useMemo(() => {
if (showEmptyColumns)
return explicitlyVisibleColumns;
return explicitlyVisibleColumns.filter((column) => {
const columnItems = itemsByStatus.get(column.id);
return columnItems && columnItems.length > 0;
});
}, [explicitlyVisibleColumns, itemsByStatus, showEmptyColumns]);
// Handle drag over (for preview) — used both by own DndContext and via useDndMonitor
const handleDragOver = useCallback((event) => {
const { over } = event;
if (!over) {
setOverColumnId(null);
return;
}
setOverColumnId(resolveOverColumnStatus(String(over.id), visibleColumns, items));
}, [items, visibleColumns]);
// Handle drag start
const handleDragStart = useCallback((event) => {
setActiveId(event.active.id);
}, []);
// Handle drag end
const handleDragEnd = useCallback((event) => {
setActiveId(null);
setOverColumnId(null);
handleKanbanColumnDrop({
event,
items,
columns: visibleColumns,
onCardMove,
onReorder,
canDropCard,
});
}, [items, visibleColumns, canDropCard, onReorder, onCardMove]);
// Always monitor the active DndContext (either the kanban's own in non-headless
// mode, or the consumer's outer context in headless mode). This is safe because
// KanbanBoardInner is always rendered inside a DndContext.
//
// In headless mode the consumer's outer onDragEnd is the single owner of drop
// routing — only update local UI state here to avoid double-firing handleKanbanColumnDrop.
useDndMonitor({
onDragStart: handleDragStart,
onDragOver: handleDragOver,
onDragEnd: headless
? () => {
setActiveId(null);
setOverColumnId(null);
}
: handleDragEnd,
onDragCancel: () => {
setActiveId(null);
setOverColumnId(null);
},
});
const handleHideColumn = useCallback((columnId) => {
if (!onHiddenColumnIdsChange)
return;
const nextHiddenColumnIds = [...hiddenColumnIds];
if (!nextHiddenColumnIds.includes(columnId)) {
nextHiddenColumnIds.push(columnId);
onHiddenColumnIdsChange(nextHiddenColumnIds);
}
}, [hiddenColumnIds, onHiddenColumnIdsChange]);
const handleShowColumn = useCallback((columnId) => {
if (!onHiddenColumnIdsChange)
return;
onHiddenColumnIdsChange(hiddenColumnIds.filter((hiddenColumnId) => hiddenColumnId !== columnId));
}, [hiddenColumnIds, onHiddenColumnIdsChange]);
return (_jsxs(_Fragment, { children: [_jsxs("div", { "data-slot": "kanban-board", className: cn('flex h-full gap-4 pb-4', className), children: [visibleColumns.map((column) => {
const columnItems = itemsByStatus.get(column.id) ?? [];
const canDropInColumn = !activeId ||
!canDropCard ||
activeItem?.status === column.id ||
canDropCard(activeId, column.id);
const isOver = overColumnId === column.id && canDropInColumn;
return (_jsx(SortableContext, { items: columnItems.map((item) => item.id), strategy: verticalListSortingStrategy, children: _jsx(KanbanColumn, { column: column, items: columnItems, count: columnItems.length, isOver: isOver, onAddItem: onAddItem && column.canAdd !== false
? () => onAddItem(column.id)
: undefined, onHideColumn: onHiddenColumnIdsChange ? () => handleHideColumn(column.id) : undefined, labels: resolvedLabels, children: columnItems.length === 0 ? (
// If onAddItem is provided, show nothing (add button appears on hover)
// Otherwise show custom empty content or default "No items" message
(() => {
if (onAddItem && column.canAdd !== false)
return null;
return (emptyColumnContent || (_jsx("div", { className: "py-8 text-center text-muted-foreground text-sm", children: resolvedLabels.noItems })));
})()) : (_jsx("div", { className: "space-y-2", children: columnItems.map((item) => (_jsx(KanbanCardWrapper, { id: item.id, isDragging: activeId === item.id, children: renderCard(item) }, item.id))) })) }) }, column.id));
}), hiddenColumns.length > 0 && (_jsx(HiddenColumnsRail, { hiddenColumns: hiddenColumns, itemsByStatus: itemsByStatus, onShowColumn: handleShowColumn, labels: resolvedLabels }))] }), typeof document !== 'undefined'
? createPortal(_jsx(DragOverlay, { dropAnimation: null, children: activeItem ? (_jsx("div", { className: "cursor-grabbing opacity-95 shadow-xl", children: renderCard(activeItem) })) : null }), document.body)
: null] }));
}
/**
* Generic Kanban Board Component
*
* A reusable drag-and-drop kanban board that can be used across
* different features (Deals, Ideas, etc.)
*/
export function KanbanBoard(props) {
const { columns, onReorder, onCardMove, hiddenColumnIds = [], isLoading = false, headless = false, } = props;
// Setup sensors for drag detection (only when drag is enabled)
const dragSensors = useSensors(useSensor(PointerSensor, {
activationConstraint: {
distance: 5, // Keep drag responsive while avoiding accidental drags
},
}), useSensor(KeyboardSensor));
// Only use sensors if onReorder or onCardMove is provided
const sensors = onReorder || onCardMove ? dragSensors : [];
const hiddenColumnIdSet = useMemo(() => new Set(hiddenColumnIds.map((columnId) => String(columnId))), [hiddenColumnIds]);
const explicitlyVisibleColumns = useMemo(() => columns.filter((column) => !hiddenColumnIdSet.has(String(column.id))), [columns, hiddenColumnIdSet]);
// Loading skeleton
if (isLoading) {
const loadingColumns = explicitlyVisibleColumns.length > 0 ? explicitlyVisibleColumns : columns;
return (_jsx("div", { className: "flex gap-4 pb-4", children: loadingColumns.map((column) => (_jsxs("div", { className: "w-72 flex-shrink-0 animate-pulse rounded-lg bg-muted/50 p-4", children: [_jsx("div", { className: "mb-4 h-6 w-24 rounded bg-muted" }), _jsxs("div", { className: "space-y-3", children: [_jsx("div", { className: "h-24 rounded bg-muted" }), _jsx("div", { className: "h-24 rounded bg-muted" })] })] }, column.id))) }));
}
// In headless mode the consumer supplies the DndContext — KanbanBoardInner
// uses useDndMonitor to listen to it from inside the same tree.
if (headless) {
return _jsx(KanbanBoardInner, { ...props, headless: true });
}
return (_jsx(DndContext, { sensors: sensors, collisionDetection: kanbanCollisionDetection, measuring: {
droppable: {
strategy: MeasuringStrategy.Always,
},
}, children: _jsx(KanbanBoardInner, { ...props, headless: false }) }));
}
function HiddenColumnsRail({ hiddenColumns, itemsByStatus, onShowColumn, labels, }) {
return (_jsx("aside", { "data-slot": "kanban-hidden-columns-rail", className: "flex w-64 flex-shrink-0 flex-col gap-3 self-start rounded-lg border border-border/60 bg-background/95 p-3 shadow-sm backdrop-blur", children: _jsxs(Collapsible, { defaultOpen: true, children: [_jsxs(CollapsibleTrigger, { "data-slot": "kanban-hidden-columns-trigger", className: "flex w-full items-center gap-2 rounded-md p-1 text-left transition-colors hover:bg-accent/50", children: [_jsx(ChevronRight, { className: "h-4 w-4 text-muted-foreground transition-transform duration-200 [[data-open]>&]:rotate-90" }), _jsx(EyeOff, { className: "h-4 w-4 text-muted-foreground" }), _jsx("h3", { className: "font-medium text-sm", children: labels.hiddenColumns }), _jsx("span", { className: "ml-auto rounded-full bg-muted px-1.5 py-0.5 text-muted-foreground text-xs", children: hiddenColumns.length })] }), _jsx(CollapsibleContent, { "data-slot": "kanban-hidden-columns-content", className: "pt-3", children: _jsx("div", { className: "space-y-2", children: hiddenColumns.map((column) => {
const columnItems = itemsByStatus.get(column.id) ?? [];
return (_jsxs("div", { "data-slot": "kanban-hidden-column", "data-column-id": column.id, className: "flex items-center gap-3 rounded-md border border-border/60 bg-background p-3 shadow-sm", children: [column.icon ? (_jsx("span", { className: "flex-shrink-0", children: column.icon })) : (_jsx("div", { className: cn('h-2.5 w-2.5 rounded-full', column.accentClass ?? 'bg-muted-foreground') })), _jsx("div", { className: "min-w-0 flex-1", children: _jsx("div", { className: "truncate font-medium text-sm", children: column.label }) }), _jsx("span", { className: "text-muted-foreground text-sm tabular-nums", children: columnItems.length }), _jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { render: _jsx("button", { type: "button", className: "rounded-md border border-border/70 p-2 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground", "aria-label": "Actions" }), children: _jsx(MoreHorizontal, { className: "h-4 w-4" }) }), _jsx(DropdownMenuContent, { align: "end", children: _jsx(DropdownMenuItem, { onSelect: () => onShowColumn(column.id), children: interpolate(labels.showColumn, { column: column.label }) }) })] })] }, column.id));
}) }) })] }) }));
}