@medalsocial/meda 1.1.1 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/ui/checkbox.d.ts +8 -0
- package/dist/components/ui/checkbox.js +9 -0
- package/dist/components/ui/collapsible.d.ts +5 -0
- package/dist/components/ui/collapsible.js +13 -0
- package/dist/kanban/index.d.ts +5 -0
- package/dist/kanban/index.js +4 -0
- package/dist/kanban/kanban-board.d.ts +8 -0
- package/dist/kanban/kanban-board.js +197 -0
- package/dist/kanban/kanban-card-wrapper.d.ts +16 -0
- package/dist/kanban/kanban-card-wrapper.js +20 -0
- package/dist/kanban/kanban-collision.d.ts +16 -0
- package/dist/kanban/kanban-collision.js +18 -0
- package/dist/kanban/kanban-column.d.ts +19 -0
- package/dist/kanban/kanban-column.js +19 -0
- package/dist/kanban/kanban-drop-handler.d.ts +16 -0
- package/dist/kanban/kanban-drop-handler.js +84 -0
- package/dist/kanban/types.d.ts +47 -0
- package/dist/kanban/types.js +7 -0
- package/dist/list/index.d.ts +2 -0
- package/dist/list/index.js +1 -0
- package/dist/list/list-row.d.ts +52 -0
- package/dist/list/list-row.js +37 -0
- package/dist/shell/drag-mode-banner.d.ts +31 -0
- package/dist/shell/drag-mode-banner.js +19 -0
- package/dist/shell/index.d.ts +6 -0
- package/dist/shell/index.js +4 -0
- package/dist/shell/rail-drop-slot.d.ts +41 -0
- package/dist/shell/rail-drop-slot.js +49 -0
- package/dist/shell/rail-drop-zones.d.ts +28 -0
- package/dist/shell/rail-drop-zones.js +17 -0
- package/dist/shell/shell-main.js +1 -1
- package/dist/timeline/index.d.ts +3 -0
- package/dist/timeline/index.js +2 -0
- package/dist/timeline/lane-timeline-types.d.ts +61 -0
- package/dist/timeline/lane-timeline-types.js +6 -0
- package/dist/timeline/lane-timeline.d.ts +2 -0
- package/dist/timeline/lane-timeline.js +85 -0
- package/dist/timeline/lane.d.ts +10 -0
- package/dist/timeline/lane.js +39 -0
- package/dist/timeline/public.d.ts +1 -0
- package/dist/timeline/time-axis.d.ts +18 -0
- package/dist/timeline/time-axis.js +22 -0
- package/package.json +24 -1
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { Checkbox as CheckboxPrimitive } from '@base-ui/react/checkbox';
|
|
2
|
+
interface CheckboxProps extends CheckboxPrimitive.Root.Props {
|
|
3
|
+
/** Show indeterminate (mixed) state - a dash instead of checkmark */
|
|
4
|
+
indeterminate?: boolean;
|
|
5
|
+
}
|
|
6
|
+
declare function Checkbox({ className, indeterminate, ...props }: CheckboxProps): import("react/jsx-runtime").JSX.Element;
|
|
7
|
+
export type { CheckboxProps };
|
|
8
|
+
export { Checkbox };
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { Checkbox as CheckboxPrimitive } from '@base-ui/react/checkbox';
|
|
4
|
+
import { CheckIcon, MinusIcon } from 'lucide-react';
|
|
5
|
+
import { cn } from '../../lib/utils.js';
|
|
6
|
+
function Checkbox({ className, indeterminate, ...props }) {
|
|
7
|
+
return (_jsx(CheckboxPrimitive.Root, { "data-slot": "checkbox", indeterminate: indeterminate, className: cn('peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input outline-none transition-colors after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 group-has-disabled/field:opacity-50 aria-invalid:border-destructive aria-invalid:ring-[3px] aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:bg-input/30 dark:data-checked:bg-primary dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40', indeterminate && 'border-primary bg-primary text-primary-foreground', className), ...props, children: indeterminate ? (_jsx("span", { className: "grid place-content-center text-current [&>svg]:size-3.5", children: _jsx(MinusIcon, {}) })) : (_jsx(CheckboxPrimitive.Indicator, { "data-slot": "checkbox-indicator", className: "grid place-content-center text-current transition-none [&>svg]:size-3.5", children: _jsx(CheckIcon, {}) })) }));
|
|
8
|
+
}
|
|
9
|
+
export { Checkbox };
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { Collapsible as CollapsiblePrimitive } from '@base-ui/react/collapsible';
|
|
2
|
+
declare function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props): import("react/jsx-runtime").JSX.Element;
|
|
3
|
+
declare function CollapsibleTrigger({ ...props }: CollapsiblePrimitive.Trigger.Props): import("react/jsx-runtime").JSX.Element;
|
|
4
|
+
declare function CollapsibleContent({ ...props }: CollapsiblePrimitive.Panel.Props): import("react/jsx-runtime").JSX.Element;
|
|
5
|
+
export { Collapsible, CollapsibleContent, CollapsibleTrigger };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { Collapsible as CollapsiblePrimitive } from '@base-ui/react/collapsible';
|
|
4
|
+
function Collapsible({ ...props }) {
|
|
5
|
+
return _jsx(CollapsiblePrimitive.Root, { "data-slot": "collapsible", ...props });
|
|
6
|
+
}
|
|
7
|
+
function CollapsibleTrigger({ ...props }) {
|
|
8
|
+
return _jsx(CollapsiblePrimitive.Trigger, { "data-slot": "collapsible-trigger", ...props });
|
|
9
|
+
}
|
|
10
|
+
function CollapsibleContent({ ...props }) {
|
|
11
|
+
return _jsx(CollapsiblePrimitive.Panel, { "data-slot": "collapsible-content", ...props });
|
|
12
|
+
}
|
|
13
|
+
export { Collapsible, CollapsibleContent, CollapsibleTrigger };
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { KanbanBoard } from './kanban-board.js';
|
|
2
|
+
export type { KanbanColumnDropHandlerArgs } from './kanban-drop-handler.js';
|
|
3
|
+
export { handleKanbanColumnDrop } from './kanban-drop-handler.js';
|
|
4
|
+
export type { KanbanBoardProps, KanbanColumn, KanbanItem, KanbanLabels, } from './types.js';
|
|
5
|
+
export { defaultKanbanLabels } from './types.js';
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type KanbanBoardProps, type KanbanItem } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Generic Kanban Board Component
|
|
4
|
+
*
|
|
5
|
+
* A reusable drag-and-drop kanban board that can be used across
|
|
6
|
+
* different features (Deals, Ideas, etc.)
|
|
7
|
+
*/
|
|
8
|
+
export declare function KanbanBoard<TItem extends KanbanItem, TStatus extends string = string>(props: KanbanBoardProps<TItem, TStatus>): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { DndContext, DragOverlay, KeyboardSensor, MeasuringStrategy, PointerSensor, useDndMonitor, useSensor, useSensors, } from '@dnd-kit/core';
|
|
4
|
+
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
|
5
|
+
import { ChevronRight, EyeOff, MoreHorizontal } from 'lucide-react';
|
|
6
|
+
import { useCallback, useDeferredValue, useMemo, useState } from 'react';
|
|
7
|
+
import { createPortal } from 'react-dom';
|
|
8
|
+
import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from '../components/ui/collapsible.js';
|
|
9
|
+
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '../components/ui/dropdown-menu.js';
|
|
10
|
+
import { cn } from '../lib/utils.js';
|
|
11
|
+
import { KanbanCardWrapper } from './kanban-card-wrapper.js';
|
|
12
|
+
import { kanbanCollisionDetection } from './kanban-collision.js';
|
|
13
|
+
import { KanbanColumn } from './kanban-column.js';
|
|
14
|
+
import { handleKanbanColumnDrop } from './kanban-drop-handler.js';
|
|
15
|
+
import { defaultKanbanLabels, } from './types.js';
|
|
16
|
+
const interpolate = (template, vars) => Object.entries(vars).reduce((s, [k, v]) => s.replace(new RegExp(`\\{\\{${k}\\}\\}`, 'g'), v), template);
|
|
17
|
+
function resolveOverColumnStatus(overId, columns, items) {
|
|
18
|
+
const targetColumn = columns.find((col) => col.id === overId);
|
|
19
|
+
if (targetColumn) {
|
|
20
|
+
return targetColumn.id;
|
|
21
|
+
}
|
|
22
|
+
const targetItem = items.find((item) => item.id === overId);
|
|
23
|
+
return targetItem?.status ?? null;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Inner component that always renders inside a DndContext — either the kanban's
|
|
27
|
+
* own (non-headless) or the consumer's outer context (headless). This means
|
|
28
|
+
* `useDndMonitor` is always safe to call here, enabling the DragOverlay to
|
|
29
|
+
* track external drags when `headless={true}`.
|
|
30
|
+
*/
|
|
31
|
+
function KanbanBoardInner({ columns, items, renderCard, onReorder, onCardMove, canDropCard, onAddItem, showEmptyColumns = false, hiddenColumnIds = [], onHiddenColumnIdsChange, emptyColumnContent, labels, headless = false, className, }) {
|
|
32
|
+
const resolvedLabels = { ...defaultKanbanLabels, ...(labels ?? {}) };
|
|
33
|
+
const [activeId, setActiveId] = useState(null);
|
|
34
|
+
const [overColumnId, setOverColumnId] = useState(null);
|
|
35
|
+
// Defer item updates during drag to keep the overlay responsive
|
|
36
|
+
const deferredItems = useDeferredValue(items);
|
|
37
|
+
// Group items by status
|
|
38
|
+
const itemsByStatus = useMemo(() => {
|
|
39
|
+
const grouped = new Map();
|
|
40
|
+
// Initialize all columns
|
|
41
|
+
for (const column of columns) {
|
|
42
|
+
grouped.set(column.id, []);
|
|
43
|
+
}
|
|
44
|
+
// Group items
|
|
45
|
+
for (const item of deferredItems) {
|
|
46
|
+
const status = item.status;
|
|
47
|
+
const columnItems = grouped.get(status);
|
|
48
|
+
if (columnItems) {
|
|
49
|
+
columnItems.push(item);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
// Sort each column by position
|
|
53
|
+
for (const [status, columnItems] of grouped) {
|
|
54
|
+
columnItems.sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
|
|
55
|
+
grouped.set(status, columnItems);
|
|
56
|
+
}
|
|
57
|
+
return grouped;
|
|
58
|
+
}, [columns, deferredItems]);
|
|
59
|
+
// Get the active item being dragged
|
|
60
|
+
const activeItem = useMemo(() => {
|
|
61
|
+
if (!activeId)
|
|
62
|
+
return null;
|
|
63
|
+
return items.find((item) => item.id === activeId) ?? null;
|
|
64
|
+
}, [activeId, items]);
|
|
65
|
+
const hiddenColumnIdSet = useMemo(() => new Set(hiddenColumnIds.map((columnId) => String(columnId))), [hiddenColumnIds]);
|
|
66
|
+
const hiddenColumns = useMemo(() => columns.filter((column) => hiddenColumnIdSet.has(String(column.id))), [columns, hiddenColumnIdSet]);
|
|
67
|
+
const explicitlyVisibleColumns = useMemo(() => columns.filter((column) => !hiddenColumnIdSet.has(String(column.id))), [columns, hiddenColumnIdSet]);
|
|
68
|
+
// Filter columns if not showing empty ones
|
|
69
|
+
const visibleColumns = useMemo(() => {
|
|
70
|
+
if (showEmptyColumns)
|
|
71
|
+
return explicitlyVisibleColumns;
|
|
72
|
+
return explicitlyVisibleColumns.filter((column) => {
|
|
73
|
+
const columnItems = itemsByStatus.get(column.id);
|
|
74
|
+
return columnItems && columnItems.length > 0;
|
|
75
|
+
});
|
|
76
|
+
}, [explicitlyVisibleColumns, itemsByStatus, showEmptyColumns]);
|
|
77
|
+
// Handle drag over (for preview) — used both by own DndContext and via useDndMonitor
|
|
78
|
+
const handleDragOver = useCallback((event) => {
|
|
79
|
+
const { over } = event;
|
|
80
|
+
if (!over) {
|
|
81
|
+
setOverColumnId(null);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
setOverColumnId(resolveOverColumnStatus(String(over.id), visibleColumns, items));
|
|
85
|
+
}, [items, visibleColumns]);
|
|
86
|
+
// Handle drag start
|
|
87
|
+
const handleDragStart = useCallback((event) => {
|
|
88
|
+
setActiveId(event.active.id);
|
|
89
|
+
}, []);
|
|
90
|
+
// Handle drag end
|
|
91
|
+
const handleDragEnd = useCallback((event) => {
|
|
92
|
+
setActiveId(null);
|
|
93
|
+
setOverColumnId(null);
|
|
94
|
+
handleKanbanColumnDrop({
|
|
95
|
+
event,
|
|
96
|
+
items,
|
|
97
|
+
columns: visibleColumns,
|
|
98
|
+
onCardMove,
|
|
99
|
+
onReorder,
|
|
100
|
+
canDropCard,
|
|
101
|
+
});
|
|
102
|
+
}, [items, visibleColumns, canDropCard, onReorder, onCardMove]);
|
|
103
|
+
// Always monitor the active DndContext (either the kanban's own in non-headless
|
|
104
|
+
// mode, or the consumer's outer context in headless mode). This is safe because
|
|
105
|
+
// KanbanBoardInner is always rendered inside a DndContext.
|
|
106
|
+
//
|
|
107
|
+
// In headless mode the consumer's outer onDragEnd is the single owner of drop
|
|
108
|
+
// routing — only update local UI state here to avoid double-firing handleKanbanColumnDrop.
|
|
109
|
+
useDndMonitor({
|
|
110
|
+
onDragStart: handleDragStart,
|
|
111
|
+
onDragOver: handleDragOver,
|
|
112
|
+
onDragEnd: headless
|
|
113
|
+
? () => {
|
|
114
|
+
setActiveId(null);
|
|
115
|
+
setOverColumnId(null);
|
|
116
|
+
}
|
|
117
|
+
: handleDragEnd,
|
|
118
|
+
onDragCancel: () => {
|
|
119
|
+
setActiveId(null);
|
|
120
|
+
setOverColumnId(null);
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
const handleHideColumn = useCallback((columnId) => {
|
|
124
|
+
if (!onHiddenColumnIdsChange)
|
|
125
|
+
return;
|
|
126
|
+
const nextHiddenColumnIds = [...hiddenColumnIds];
|
|
127
|
+
if (!nextHiddenColumnIds.includes(columnId)) {
|
|
128
|
+
nextHiddenColumnIds.push(columnId);
|
|
129
|
+
onHiddenColumnIdsChange(nextHiddenColumnIds);
|
|
130
|
+
}
|
|
131
|
+
}, [hiddenColumnIds, onHiddenColumnIdsChange]);
|
|
132
|
+
const handleShowColumn = useCallback((columnId) => {
|
|
133
|
+
if (!onHiddenColumnIdsChange)
|
|
134
|
+
return;
|
|
135
|
+
onHiddenColumnIdsChange(hiddenColumnIds.filter((hiddenColumnId) => hiddenColumnId !== columnId));
|
|
136
|
+
}, [hiddenColumnIds, onHiddenColumnIdsChange]);
|
|
137
|
+
return (_jsxs(_Fragment, { children: [_jsxs("div", { "data-slot": "kanban-board", className: cn('flex h-full gap-4 pb-4', className), children: [visibleColumns.map((column) => {
|
|
138
|
+
const columnItems = itemsByStatus.get(column.id) ?? [];
|
|
139
|
+
const canDropInColumn = !activeId ||
|
|
140
|
+
!canDropCard ||
|
|
141
|
+
activeItem?.status === column.id ||
|
|
142
|
+
canDropCard(activeId, column.id);
|
|
143
|
+
const isOver = overColumnId === column.id && canDropInColumn;
|
|
144
|
+
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
|
|
145
|
+
? () => onAddItem(column.id)
|
|
146
|
+
: undefined, onHideColumn: onHiddenColumnIdsChange ? () => handleHideColumn(column.id) : undefined, labels: resolvedLabels, children: columnItems.length === 0 ? (
|
|
147
|
+
// If onAddItem is provided, show nothing (add button appears on hover)
|
|
148
|
+
// Otherwise show custom empty content or default "No items" message
|
|
149
|
+
(() => {
|
|
150
|
+
if (onAddItem && column.canAdd !== false)
|
|
151
|
+
return null;
|
|
152
|
+
return (emptyColumnContent || (_jsx("div", { className: "py-8 text-center text-muted-foreground text-sm", children: resolvedLabels.noItems })));
|
|
153
|
+
})()) : (_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));
|
|
154
|
+
}), hiddenColumns.length > 0 && (_jsx(HiddenColumnsRail, { hiddenColumns: hiddenColumns, itemsByStatus: itemsByStatus, onShowColumn: handleShowColumn, labels: resolvedLabels }))] }), typeof document !== 'undefined'
|
|
155
|
+
? createPortal(_jsx(DragOverlay, { dropAnimation: null, children: activeItem ? (_jsx("div", { className: "cursor-grabbing opacity-95 shadow-xl", children: renderCard(activeItem) })) : null }), document.body)
|
|
156
|
+
: null] }));
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Generic Kanban Board Component
|
|
160
|
+
*
|
|
161
|
+
* A reusable drag-and-drop kanban board that can be used across
|
|
162
|
+
* different features (Deals, Ideas, etc.)
|
|
163
|
+
*/
|
|
164
|
+
export function KanbanBoard(props) {
|
|
165
|
+
const { columns, onReorder, onCardMove, hiddenColumnIds = [], isLoading = false, headless = false, } = props;
|
|
166
|
+
// Setup sensors for drag detection (only when drag is enabled)
|
|
167
|
+
const dragSensors = useSensors(useSensor(PointerSensor, {
|
|
168
|
+
activationConstraint: {
|
|
169
|
+
distance: 5, // Keep drag responsive while avoiding accidental drags
|
|
170
|
+
},
|
|
171
|
+
}), useSensor(KeyboardSensor));
|
|
172
|
+
// Only use sensors if onReorder or onCardMove is provided
|
|
173
|
+
const sensors = onReorder || onCardMove ? dragSensors : [];
|
|
174
|
+
const hiddenColumnIdSet = useMemo(() => new Set(hiddenColumnIds.map((columnId) => String(columnId))), [hiddenColumnIds]);
|
|
175
|
+
const explicitlyVisibleColumns = useMemo(() => columns.filter((column) => !hiddenColumnIdSet.has(String(column.id))), [columns, hiddenColumnIdSet]);
|
|
176
|
+
// Loading skeleton
|
|
177
|
+
if (isLoading) {
|
|
178
|
+
const loadingColumns = explicitlyVisibleColumns.length > 0 ? explicitlyVisibleColumns : columns;
|
|
179
|
+
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))) }));
|
|
180
|
+
}
|
|
181
|
+
// In headless mode the consumer supplies the DndContext — KanbanBoardInner
|
|
182
|
+
// uses useDndMonitor to listen to it from inside the same tree.
|
|
183
|
+
if (headless) {
|
|
184
|
+
return _jsx(KanbanBoardInner, { ...props, headless: true });
|
|
185
|
+
}
|
|
186
|
+
return (_jsx(DndContext, { sensors: sensors, collisionDetection: kanbanCollisionDetection, measuring: {
|
|
187
|
+
droppable: {
|
|
188
|
+
strategy: MeasuringStrategy.Always,
|
|
189
|
+
},
|
|
190
|
+
}, children: _jsx(KanbanBoardInner, { ...props, headless: false }) }));
|
|
191
|
+
}
|
|
192
|
+
function HiddenColumnsRail({ hiddenColumns, itemsByStatus, onShowColumn, labels, }) {
|
|
193
|
+
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) => {
|
|
194
|
+
const columnItems = itemsByStatus.get(column.id) ?? [];
|
|
195
|
+
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));
|
|
196
|
+
}) }) })] }) }));
|
|
197
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
interface KanbanCardWrapperProps {
|
|
3
|
+
/** Unique ID for the draggable item */
|
|
4
|
+
id: string;
|
|
5
|
+
/** Whether the card is currently being dragged */
|
|
6
|
+
isDragging?: boolean;
|
|
7
|
+
/** The card content to render */
|
|
8
|
+
children: ReactNode;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* KanbanCardWrapper Component
|
|
12
|
+
*
|
|
13
|
+
* Wraps card content to make it draggable within the kanban board.
|
|
14
|
+
*/
|
|
15
|
+
export declare function KanbanCardWrapper({ id, isDragging, children }: KanbanCardWrapperProps): import("react/jsx-runtime").JSX.Element;
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { useSortable } from '@dnd-kit/sortable';
|
|
4
|
+
import { CSS } from '@dnd-kit/utilities';
|
|
5
|
+
import { cn } from '../lib/utils.js';
|
|
6
|
+
/**
|
|
7
|
+
* KanbanCardWrapper Component
|
|
8
|
+
*
|
|
9
|
+
* Wraps card content to make it draggable within the kanban board.
|
|
10
|
+
*/
|
|
11
|
+
export function KanbanCardWrapper({ id, isDragging, children }) {
|
|
12
|
+
const { attributes, listeners, setNodeRef, transform, transition, isDragging: isSortableDragging, } = useSortable({ id });
|
|
13
|
+
const style = {
|
|
14
|
+
transform: CSS.Transform.toString(transform),
|
|
15
|
+
transition,
|
|
16
|
+
};
|
|
17
|
+
const isCurrentlyDragging = isDragging || isSortableDragging;
|
|
18
|
+
return (_jsx("div", { ref: setNodeRef, style: style, ...attributes, ...listeners, "data-slot": "kanban-card", className: cn('cursor-grab active:cursor-grabbing', 'touch-manipulation', // Better touch handling
|
|
19
|
+
'transition-shadow duration-200', isCurrentlyDragging && 'z-50 opacity-50 shadow-lg'), children: children }));
|
|
20
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type CollisionDetection } from '@dnd-kit/core';
|
|
2
|
+
type CollisionArgs = Parameters<CollisionDetection>[0];
|
|
3
|
+
type CollisionResult = ReturnType<CollisionDetection>;
|
|
4
|
+
interface CollisionDetectionFns {
|
|
5
|
+
pointerWithinFn?: CollisionDetection;
|
|
6
|
+
closestCenterFn?: CollisionDetection;
|
|
7
|
+
}
|
|
8
|
+
export declare function runKanbanCollisionDetection(args: CollisionArgs, fns?: CollisionDetectionFns): CollisionResult;
|
|
9
|
+
export declare const kanbanCollisionDetection: CollisionDetection;
|
|
10
|
+
export declare function isKanbanCardDropAllowed<TStatus extends string>({ itemId, sourceStatus, targetStatus, canDropCard, }: {
|
|
11
|
+
itemId: string;
|
|
12
|
+
sourceStatus: TStatus;
|
|
13
|
+
targetStatus: TStatus;
|
|
14
|
+
canDropCard?: (itemId: string, targetStatus: TStatus) => boolean;
|
|
15
|
+
}): boolean;
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { closestCenter, pointerWithin } from '@dnd-kit/core';
|
|
2
|
+
export function runKanbanCollisionDetection(args, fns = {}) {
|
|
3
|
+
const pointerWithinFn = fns.pointerWithinFn ?? pointerWithin;
|
|
4
|
+
const closestCenterFn = fns.closestCenterFn ?? closestCenter;
|
|
5
|
+
const activeId = String(args.active.id);
|
|
6
|
+
const pointerCollisions = pointerWithinFn(args).filter((collision) => String(collision.id) !== activeId);
|
|
7
|
+
if (pointerCollisions.length > 0) {
|
|
8
|
+
return pointerCollisions;
|
|
9
|
+
}
|
|
10
|
+
return closestCenterFn(args).filter((collision) => String(collision.id) !== activeId);
|
|
11
|
+
}
|
|
12
|
+
export const kanbanCollisionDetection = (args) => runKanbanCollisionDetection(args);
|
|
13
|
+
export function isKanbanCardDropAllowed({ itemId, sourceStatus, targetStatus, canDropCard, }) {
|
|
14
|
+
if (sourceStatus === targetStatus) {
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
return canDropCard ? canDropCard(itemId, targetStatus) : true;
|
|
18
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import type { KanbanColumn as KanbanColumnDef, KanbanItem, KanbanLabels } from './types.js';
|
|
3
|
+
interface KanbanColumnProps<TItem extends KanbanItem> {
|
|
4
|
+
column: KanbanColumnDef;
|
|
5
|
+
items: TItem[];
|
|
6
|
+
count: number;
|
|
7
|
+
isOver?: boolean;
|
|
8
|
+
onAddItem?: () => void;
|
|
9
|
+
onHideColumn?: () => void;
|
|
10
|
+
labels: KanbanLabels;
|
|
11
|
+
children: ReactNode;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* KanbanColumn Component
|
|
15
|
+
*
|
|
16
|
+
* A single column in the kanban board that can receive dropped items.
|
|
17
|
+
*/
|
|
18
|
+
export declare function KanbanColumn<TItem extends KanbanItem>({ column, count, isOver, onAddItem, onHideColumn, labels, children, }: KanbanColumnProps<TItem>): import("react/jsx-runtime").JSX.Element;
|
|
19
|
+
export {};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useDroppable } from '@dnd-kit/core';
|
|
4
|
+
import { MoreHorizontal, Plus } from 'lucide-react';
|
|
5
|
+
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '../components/ui/dropdown-menu.js';
|
|
6
|
+
import { cn } from '../lib/utils.js';
|
|
7
|
+
const interpolate = (template, vars) => Object.entries(vars).reduce((s, [k, v]) => s.replace(new RegExp(`\\{\\{${k}\\}\\}`, 'g'), v), template);
|
|
8
|
+
/**
|
|
9
|
+
* KanbanColumn Component
|
|
10
|
+
*
|
|
11
|
+
* A single column in the kanban board that can receive dropped items.
|
|
12
|
+
*/
|
|
13
|
+
export function KanbanColumn({ column, count, isOver, onAddItem, onHideColumn, labels, children, }) {
|
|
14
|
+
const { setNodeRef, isOver: isDroppableOver } = useDroppable({
|
|
15
|
+
id: column.id,
|
|
16
|
+
});
|
|
17
|
+
const showDropIndicator = isOver || isDroppableOver;
|
|
18
|
+
return (_jsxs("div", { ref: setNodeRef, "data-slot": "kanban-column", "data-column-id": column.id, className: cn('group/column flex w-72 flex-shrink-0 flex-col rounded-lg bg-muted/30', 'transition-colors duration-200', showDropIndicator && 'bg-primary/5 ring-2 ring-primary/20'), children: [_jsxs("div", { "data-slot": "kanban-column-header", className: "flex items-center gap-2 p-3 pb-2", children: [column.icon ? (_jsx("span", { className: "flex-shrink-0", children: column.icon })) : (_jsx("div", { className: cn('h-2 w-2 rounded-full', column.accentClass ?? 'bg-muted-foreground') })), _jsx("h3", { "data-slot": "kanban-column-title", className: "font-medium text-sm", children: column.label }), _jsx("span", { className: "rounded-full bg-muted px-1.5 py-0.5 text-muted-foreground text-xs", children: count }), (onAddItem || onHideColumn) && (_jsxs("div", { "data-slot": "kanban-column-actions", className: "ml-auto flex items-center gap-1", children: [onAddItem && (_jsx("button", { type: "button", onClick: onAddItem, className: "rounded p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground", "aria-label": interpolate(labels.addItemTo, { column: column.label }), children: _jsx(Plus, { className: "h-4 w-4" }) })), onHideColumn && (_jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { render: _jsx("button", { type: "button", className: "rounded p-1 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: onHideColumn, children: labels.hideColumn }) })] }))] }))] }), _jsxs("div", { "data-slot": "kanban-column-content", className: cn('min-h-[100px] flex-1 overflow-y-auto p-2 pt-0 [contain:content]', 'transition-colors duration-200'), children: [children, onAddItem && (_jsxs("button", { type: "button", onClick: onAddItem, className: cn('flex w-full items-center justify-center gap-1.5 rounded-md border border-transparent border-dashed p-2 text-muted-foreground text-sm opacity-0 transition-[color,opacity,background-color,border-color] hover:border-muted-foreground/30 hover:bg-accent/50 hover:text-foreground focus-visible:opacity-100 group-hover/column:opacity-100', count > 0 && 'mt-2'), "aria-label": interpolate(labels.addItemTo, { column: column.label }), children: [_jsx(Plus, { className: "h-4 w-4" }), _jsx("span", { children: "Add" })] }))] }), showDropIndicator && count === 0 && (_jsx("div", { className: "mx-2 mb-2 h-1 rounded-full bg-primary/30" }))] }));
|
|
19
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { DragEndEvent } from '@dnd-kit/core';
|
|
2
|
+
import type { KanbanItem } from './types.js';
|
|
3
|
+
export interface KanbanColumnDropHandlerArgs<TItem extends KanbanItem, TStatus extends string> {
|
|
4
|
+
event: DragEndEvent;
|
|
5
|
+
items: TItem[];
|
|
6
|
+
columns: {
|
|
7
|
+
id: string;
|
|
8
|
+
}[];
|
|
9
|
+
onCardMove?: (itemId: string, newStatus: TStatus) => void;
|
|
10
|
+
onReorder?: (itemId: string, newStatus: TStatus, newPosition: number) => void;
|
|
11
|
+
canDropCard?: (itemId: string, targetStatus: TStatus) => boolean;
|
|
12
|
+
}
|
|
13
|
+
/** Returns true if the drag was handled as a kanban column drop (column-to-column
|
|
14
|
+
* move or reorder); false if `over` was null or did not resolve to a kanban column/
|
|
15
|
+
* item, so the consumer can handle it (e.g. an external drop slot). */
|
|
16
|
+
export declare function handleKanbanColumnDrop<TItem extends KanbanItem, TStatus extends string>(args: KanbanColumnDropHandlerArgs<TItem, TStatus>): boolean;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { isKanbanCardDropAllowed } from './kanban-collision.js';
|
|
2
|
+
/** Returns true if the drag was handled as a kanban column drop (column-to-column
|
|
3
|
+
* move or reorder); false if `over` was null or did not resolve to a kanban column/
|
|
4
|
+
* item, so the consumer can handle it (e.g. an external drop slot). */
|
|
5
|
+
export function handleKanbanColumnDrop(args) {
|
|
6
|
+
const { event, items, columns, onCardMove, onReorder, canDropCard } = args;
|
|
7
|
+
const { active, over } = event;
|
|
8
|
+
if (!over)
|
|
9
|
+
return false;
|
|
10
|
+
const activeItemId = active.id;
|
|
11
|
+
const overId = over.id;
|
|
12
|
+
const draggedItem = items.find((item) => item.id === activeItemId);
|
|
13
|
+
if (!draggedItem)
|
|
14
|
+
return false;
|
|
15
|
+
// Build itemsByStatus map for position resolution
|
|
16
|
+
const itemsByStatus = new Map();
|
|
17
|
+
for (const column of columns) {
|
|
18
|
+
itemsByStatus.set(column.id, []);
|
|
19
|
+
}
|
|
20
|
+
for (const item of items) {
|
|
21
|
+
const status = item.status;
|
|
22
|
+
const columnItems = itemsByStatus.get(status);
|
|
23
|
+
if (columnItems) {
|
|
24
|
+
columnItems.push(item);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
for (const [status, columnItems] of itemsByStatus) {
|
|
28
|
+
columnItems.sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
|
|
29
|
+
itemsByStatus.set(status, columnItems);
|
|
30
|
+
}
|
|
31
|
+
// Resolve drop target: check if over.id is a column id
|
|
32
|
+
const targetColumn = columns.find((col) => col.id === overId);
|
|
33
|
+
if (targetColumn) {
|
|
34
|
+
const targetStatus = targetColumn.id;
|
|
35
|
+
const columnItems = itemsByStatus.get(targetStatus) ?? [];
|
|
36
|
+
if (!isKanbanCardDropAllowed({
|
|
37
|
+
itemId: activeItemId,
|
|
38
|
+
sourceStatus: draggedItem.status,
|
|
39
|
+
targetStatus,
|
|
40
|
+
canDropCard,
|
|
41
|
+
})) {
|
|
42
|
+
return true; // handled (rejected) — don't fall through to external handler
|
|
43
|
+
}
|
|
44
|
+
if (draggedItem.status !== targetStatus || draggedItem.position !== columnItems.length) {
|
|
45
|
+
if (onReorder) {
|
|
46
|
+
onReorder(activeItemId, targetStatus, columnItems.length);
|
|
47
|
+
}
|
|
48
|
+
if (onCardMove && draggedItem.status !== targetStatus) {
|
|
49
|
+
void Promise.resolve(onCardMove(activeItemId, targetStatus)).catch(() => {
|
|
50
|
+
// Mutation handlers handle toast + rollback on failure.
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
// Check if over.id is an item id in one of the kanban columns
|
|
57
|
+
const targetItem = items.find((item) => item.id === overId);
|
|
58
|
+
if (!targetItem)
|
|
59
|
+
return false;
|
|
60
|
+
const targetStatus = targetItem.status;
|
|
61
|
+
const columnItems = itemsByStatus.get(targetStatus) ?? [];
|
|
62
|
+
const targetIndex = columnItems.findIndex((item) => item.id === overId);
|
|
63
|
+
if (targetIndex === -1)
|
|
64
|
+
return false;
|
|
65
|
+
if (!isKanbanCardDropAllowed({
|
|
66
|
+
itemId: activeItemId,
|
|
67
|
+
sourceStatus: draggedItem.status,
|
|
68
|
+
targetStatus,
|
|
69
|
+
canDropCard,
|
|
70
|
+
})) {
|
|
71
|
+
return true; // handled (rejected)
|
|
72
|
+
}
|
|
73
|
+
if (draggedItem.status !== targetStatus || draggedItem.position !== targetIndex) {
|
|
74
|
+
if (onReorder) {
|
|
75
|
+
onReorder(activeItemId, targetStatus, targetIndex);
|
|
76
|
+
}
|
|
77
|
+
if (onCardMove && draggedItem.status !== targetStatus) {
|
|
78
|
+
void Promise.resolve(onCardMove(activeItemId, targetStatus)).catch(() => {
|
|
79
|
+
// Mutation handlers handle toast + rollback on failure.
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
export interface KanbanItem {
|
|
3
|
+
id: string;
|
|
4
|
+
status: string;
|
|
5
|
+
position: number;
|
|
6
|
+
}
|
|
7
|
+
export interface KanbanColumn {
|
|
8
|
+
id: string;
|
|
9
|
+
label: string;
|
|
10
|
+
/** Tailwind class string for the column accent — e.g. 'bg-success-500' or a token-driven class. */
|
|
11
|
+
accentClass?: string;
|
|
12
|
+
/** Optional icon node rendered next to the column label. */
|
|
13
|
+
icon?: ReactNode;
|
|
14
|
+
/** Whether the column accepts new items via the add-button. */
|
|
15
|
+
canAdd?: boolean;
|
|
16
|
+
}
|
|
17
|
+
export interface KanbanLabels {
|
|
18
|
+
/** "Add to {{column}}" — `{{column}}` replaced at render. */
|
|
19
|
+
addItemTo: string;
|
|
20
|
+
hideColumn: string;
|
|
21
|
+
noItems: string;
|
|
22
|
+
/** "Show {{column}}" */
|
|
23
|
+
showColumn: string;
|
|
24
|
+
hiddenColumns: string;
|
|
25
|
+
}
|
|
26
|
+
export declare const defaultKanbanLabels: KanbanLabels;
|
|
27
|
+
export interface KanbanBoardProps<TItem extends KanbanItem, TStatus extends string = string> {
|
|
28
|
+
columns: KanbanColumn[];
|
|
29
|
+
items: TItem[];
|
|
30
|
+
renderCard: (item: TItem) => ReactNode;
|
|
31
|
+
onReorder?: (itemId: string, newStatus: TStatus, newPosition: number) => void;
|
|
32
|
+
onCardMove?: (itemId: string, newStatus: TStatus) => void;
|
|
33
|
+
canDropCard?: (itemId: string, targetStatus: TStatus) => boolean;
|
|
34
|
+
onAddItem?: (columnId: TStatus) => void;
|
|
35
|
+
showEmptyColumns?: boolean;
|
|
36
|
+
hiddenColumnIds?: TStatus[];
|
|
37
|
+
onHiddenColumnIdsChange?: (ids: TStatus[]) => void;
|
|
38
|
+
isLoading?: boolean;
|
|
39
|
+
emptyColumnContent?: ReactNode;
|
|
40
|
+
labels?: Partial<KanbanLabels>;
|
|
41
|
+
className?: string;
|
|
42
|
+
/** When true, KanbanBoard skips its internal `<DndContext>`. The consumer must
|
|
43
|
+
* wrap KanbanBoard and any sibling drop targets (e.g. RailDropSlot) in a shared
|
|
44
|
+
* `<DndContext>` and supply a unified `onDragEnd` that routes column drops via
|
|
45
|
+
* `handleKanbanColumnDrop` and external drops manually. */
|
|
46
|
+
headless?: boolean;
|
|
47
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { ListCell, ListRow } from './list-row.js';
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* List Row Component
|
|
3
|
+
*
|
|
4
|
+
* Base row wrapper for Linear-style table rows.
|
|
5
|
+
* Provides consistent styling for hover, selection, and keyboard focus states.
|
|
6
|
+
*/
|
|
7
|
+
import type { ReactNode } from 'react';
|
|
8
|
+
export interface ListRowProps {
|
|
9
|
+
/** Whether this row is selected */
|
|
10
|
+
selected?: boolean;
|
|
11
|
+
/** Whether any item in the list is selected (keeps checkbox always visible) */
|
|
12
|
+
selectionActive?: boolean;
|
|
13
|
+
/** Callback when selection changes (shiftKey for range selection) */
|
|
14
|
+
onSelect?: (selected: boolean, shiftKey?: boolean) => void;
|
|
15
|
+
/** Click handler for row navigation */
|
|
16
|
+
onClick?: () => void;
|
|
17
|
+
/** Mouse enter handler for prefetching */
|
|
18
|
+
onMouseEnter?: () => void;
|
|
19
|
+
/** Mouse leave handler */
|
|
20
|
+
onMouseLeave?: () => void;
|
|
21
|
+
/** Focus handler for keyboard intent prefetching */
|
|
22
|
+
onFocus?: () => void;
|
|
23
|
+
/** Blur handler */
|
|
24
|
+
onBlur?: () => void;
|
|
25
|
+
/** Whether this row has keyboard focus */
|
|
26
|
+
focused?: boolean;
|
|
27
|
+
/** Children to render inside the row */
|
|
28
|
+
children: ReactNode;
|
|
29
|
+
/** Additional class names */
|
|
30
|
+
className?: string;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* A Linear-style list row with checkbox, hover states, and click handling.
|
|
34
|
+
* Compact ~40-48px height for dense, scannable lists.
|
|
35
|
+
*/
|
|
36
|
+
export declare function ListRow({ selected, selectionActive, onSelect, onClick, onMouseEnter, onMouseLeave, onFocus, onBlur, focused, children, className, }: ListRowProps): import("react/jsx-runtime").JSX.Element;
|
|
37
|
+
/**
|
|
38
|
+
* Cell wrapper for consistent column sizing
|
|
39
|
+
*/
|
|
40
|
+
export interface ListCellProps {
|
|
41
|
+
/** Width class (e.g., 'w-8', 'w-24', 'flex-1') */
|
|
42
|
+
width?: string;
|
|
43
|
+
/** Whether to shrink if needed */
|
|
44
|
+
shrink?: boolean;
|
|
45
|
+
/** Alignment */
|
|
46
|
+
align?: 'left' | 'center' | 'right';
|
|
47
|
+
/** Children */
|
|
48
|
+
children?: ReactNode;
|
|
49
|
+
/** Additional class names */
|
|
50
|
+
className?: string;
|
|
51
|
+
}
|
|
52
|
+
export declare function ListCell({ width, shrink, align, children, className, }: ListCellProps): import("react/jsx-runtime").JSX.Element;
|