@medalsocial/meda 1.1.2 → 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/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;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { Checkbox } from '../components/ui/checkbox.js';
|
|
4
|
+
import { cn } from '../lib/utils.js';
|
|
5
|
+
/**
|
|
6
|
+
* A Linear-style list row with checkbox, hover states, and click handling.
|
|
7
|
+
* Compact ~40-48px height for dense, scannable lists.
|
|
8
|
+
*/
|
|
9
|
+
export function ListRow({ selected, selectionActive, onSelect, onClick, onMouseEnter, onMouseLeave, onFocus, onBlur, focused, children, className, }) {
|
|
10
|
+
const isInteractive = !!onClick;
|
|
11
|
+
const interactiveProps = isInteractive
|
|
12
|
+
? {
|
|
13
|
+
role: 'button',
|
|
14
|
+
tabIndex: 0,
|
|
15
|
+
onClick,
|
|
16
|
+
onKeyDown: (e) => {
|
|
17
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
18
|
+
e.preventDefault();
|
|
19
|
+
onClick?.();
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
}
|
|
23
|
+
: {};
|
|
24
|
+
return (_jsxs("div", { ...interactiveProps, onMouseEnter: onMouseEnter, onMouseLeave: onMouseLeave, onFocus: onFocus, onBlur: onBlur, className: cn('group relative flex items-center gap-3 border-border border-b px-3 py-2.5', 'transition-all duration-150', isInteractive && 'cursor-pointer hover:bg-accent/50', isInteractive &&
|
|
25
|
+
"before:absolute before:inset-y-0 before:left-0 before:w-[2px] before:scale-y-0 before:bg-primary before:transition-transform before:duration-150 before:content-['']", isInteractive && 'hover:before:scale-y-100', isInteractive && 'focus-within:bg-accent/30 focus-within:before:scale-y-100', selected && isInteractive && 'before:!scale-y-100 bg-accent', selected && !isInteractive && 'bg-accent', focused &&
|
|
26
|
+
isInteractive &&
|
|
27
|
+
'before:!scale-y-100 bg-accent/30 ring-1 ring-primary/50 ring-inset', focused && !isInteractive && 'bg-accent/30 ring-1 ring-primary/50 ring-inset', className), children: [onSelect && (_jsx("div", { className: "w-6 flex-shrink-0", onClick: (e) => e.stopPropagation(), onKeyDown: (e) => e.stopPropagation(), children: _jsx(Checkbox, { checked: selected, onClick: (e) => {
|
|
28
|
+
e.preventDefault();
|
|
29
|
+
onSelect(!selected, e.shiftKey);
|
|
30
|
+
}, className: cn('transition-opacity', selectionActive || selected
|
|
31
|
+
? 'opacity-100'
|
|
32
|
+
: 'opacity-0 group-hover:opacity-100 data-[state=checked]:opacity-100') }) })), children] }));
|
|
33
|
+
}
|
|
34
|
+
export function ListCell({ width = 'flex-1', shrink = true, align = 'left', children, className, }) {
|
|
35
|
+
return (_jsx("div", { className: cn(width, shrink === false && 'flex-shrink-0', align === 'center' && 'text-center', align === 'right' && 'text-right', 'min-w-0', // Allow truncation
|
|
36
|
+
className), children: children }));
|
|
37
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* Props for DragModeBanner.
|
|
4
|
+
*
|
|
5
|
+
* **Important:** This component uses `useDndMonitor` internally and must be
|
|
6
|
+
* rendered inside the same `<DndContext>` tree it is monitoring.
|
|
7
|
+
*
|
|
8
|
+
* **Known limitation:** The ESC chip is decorative only. Keyboard-initiated
|
|
9
|
+
* drags cancel naturally via dnd-kit's KeyboardSensor; pointer-initiated drags
|
|
10
|
+
* require the user to release outside any drop zone — a manual ESC handler for
|
|
11
|
+
* pointer drags is not yet implemented.
|
|
12
|
+
*/
|
|
13
|
+
export interface DragModeBannerProps {
|
|
14
|
+
/** Banner message shown while a drag is active. */
|
|
15
|
+
message: ReactNode;
|
|
16
|
+
/**
|
|
17
|
+
* When provided, render an ESC chip indicating the user can press Escape to
|
|
18
|
+
* cancel. See known limitation note above.
|
|
19
|
+
*/
|
|
20
|
+
cancelKey?: 'ESC';
|
|
21
|
+
/**
|
|
22
|
+
* Optional predicate; when supplied only activates the banner for matching
|
|
23
|
+
* drag ids. Receives the `active.id` of the current drag.
|
|
24
|
+
*/
|
|
25
|
+
isActive?: (activeId: string | number) => boolean;
|
|
26
|
+
className?: string;
|
|
27
|
+
}
|
|
28
|
+
export declare function DragModeBanner({ message, cancelKey, isActive, className }: DragModeBannerProps): import("react/jsx-runtime").JSX.Element | null;
|
|
29
|
+
export declare namespace DragModeBanner {
|
|
30
|
+
var displayName: string;
|
|
31
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useDndMonitor } from '@dnd-kit/core';
|
|
4
|
+
import { useState } from 'react';
|
|
5
|
+
import { cn } from '../lib/utils.js';
|
|
6
|
+
export function DragModeBanner({ message, cancelKey, isActive, className }) {
|
|
7
|
+
const [activeId, setActiveId] = useState(null);
|
|
8
|
+
useDndMonitor({
|
|
9
|
+
onDragStart: (e) => setActiveId(e.active.id),
|
|
10
|
+
onDragEnd: () => setActiveId(null),
|
|
11
|
+
onDragCancel: () => setActiveId(null),
|
|
12
|
+
});
|
|
13
|
+
if (activeId == null)
|
|
14
|
+
return null;
|
|
15
|
+
if (isActive && !isActive(activeId))
|
|
16
|
+
return null;
|
|
17
|
+
return (_jsxs("div", { role: "status", "aria-live": "polite", className: cn('pointer-events-none flex items-center justify-center gap-3 px-4 py-2 text-sm text-muted-foreground', className), children: [_jsx("span", { children: message }), cancelKey === 'ESC' && (_jsx("kbd", { className: "rounded border border-border bg-muted px-1.5 py-0.5 font-medium font-mono text-xs", children: "ESC" }))] }));
|
|
18
|
+
}
|
|
19
|
+
DragModeBanner.displayName = 'DragModeBanner';
|
package/dist/shell/index.d.ts
CHANGED
|
@@ -2,11 +2,17 @@ export { AppShell, AppShellBody } from './app-shell.js';
|
|
|
2
2
|
export type { CommandGroupDefinition } from './command-palette.js';
|
|
3
3
|
export { CommandPalette, useCommandGroup, useCommands } from './command-palette.js';
|
|
4
4
|
export { ContextRail } from './context-rail.js';
|
|
5
|
+
export type { DragModeBannerProps } from './drag-mode-banner.js';
|
|
6
|
+
export { DragModeBanner } from './drag-mode-banner.js';
|
|
5
7
|
export * as Extras from './extras/index.js';
|
|
6
8
|
export { IconRail, RailDivider } from './icon-rail.js';
|
|
7
9
|
export type { ShellStorageAdapter } from './layout-state.js';
|
|
8
10
|
export { createLocalStorageAdapter } from './layout-state.js';
|
|
9
11
|
export { motion } from './motion.js';
|
|
12
|
+
export type { RailDropSlotProps, RailDropSlotState } from './rail-drop-slot.js';
|
|
13
|
+
export { RailDropSlot } from './rail-drop-slot.js';
|
|
14
|
+
export type { RailDropZonesProps } from './rail-drop-zones.js';
|
|
15
|
+
export { RailDropZones } from './rail-drop-zones.js';
|
|
10
16
|
export { ResizableHandle, ResizableShell, ResizableShellPanel } from './resizable-shell.js';
|
|
11
17
|
export { RightPanel } from './right-panel.js';
|
|
12
18
|
export { AppTabs, PanelToggle, ShellHeader, WorkspaceSwitcher } from './shell-header.js';
|
package/dist/shell/index.js
CHANGED
|
@@ -8,6 +8,8 @@ export { AppShell, AppShellBody } from './app-shell.js';
|
|
|
8
8
|
// Command palette
|
|
9
9
|
export { CommandPalette, useCommandGroup, useCommands } from './command-palette.js';
|
|
10
10
|
export { ContextRail } from './context-rail.js';
|
|
11
|
+
// Drag-and-drop utilities
|
|
12
|
+
export { DragModeBanner } from './drag-mode-banner.js';
|
|
11
13
|
// Extras (legacy components ported during Phase 15 — opt-in for apps that need them)
|
|
12
14
|
export * as Extras from './extras/index.js';
|
|
13
15
|
// Rails + main + panel
|
|
@@ -17,6 +19,8 @@ export { createLocalStorageAdapter } from './layout-state.js';
|
|
|
17
19
|
// Hooks + tokens
|
|
18
20
|
export { motion } from './motion.js';
|
|
19
21
|
// Resize primitives
|
|
22
|
+
export { RailDropSlot } from './rail-drop-slot.js';
|
|
23
|
+
export { RailDropZones } from './rail-drop-zones.js';
|
|
20
24
|
export { ResizableHandle, ResizableShell, ResizableShellPanel } from './resizable-shell.js';
|
|
21
25
|
export { RightPanel } from './right-panel.js';
|
|
22
26
|
// Header (and its individual children for advanced composition)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { type ReactNode, type Ref } from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* Describes the slot's current relation to an active drag operation.
|
|
4
|
+
*
|
|
5
|
+
* - `'idle'` — no drag in progress.
|
|
6
|
+
* - `'active'` — a drag is in progress and this slot would accept it, but
|
|
7
|
+
* the cursor is not currently over this slot.
|
|
8
|
+
* - `'over'` — the dragged item is hovering over this slot and it would
|
|
9
|
+
* accept the drop.
|
|
10
|
+
* - `'rejected'` — the dragged item is hovering over this slot but the slot
|
|
11
|
+
* cannot accept it (disabled or `accepts` returns false), OR
|
|
12
|
+
* the slot is `disabled` while any drag is active.
|
|
13
|
+
*/
|
|
14
|
+
export type RailDropSlotState = 'idle' | 'active' | 'over' | 'rejected';
|
|
15
|
+
export interface RailDropSlotProps {
|
|
16
|
+
/** Stable id used by dnd-kit; also rendered as `data-slot-id`. */
|
|
17
|
+
id: string;
|
|
18
|
+
/** Decide whether this slot accepts the dragged card. */
|
|
19
|
+
accepts?: (activeId: string) => boolean;
|
|
20
|
+
/** Whether this slot can currently receive a drop (e.g. capacity, online). */
|
|
21
|
+
disabled?: boolean;
|
|
22
|
+
/**
|
|
23
|
+
* Static content. Mutually exclusive with `render`.
|
|
24
|
+
* Use `render` when you need state-aware content.
|
|
25
|
+
*/
|
|
26
|
+
children?: ReactNode;
|
|
27
|
+
/**
|
|
28
|
+
* State-aware content factory. Receives the slot's current `RailDropSlotState`
|
|
29
|
+
* so you can render different UI for idle / active / over / rejected states.
|
|
30
|
+
* Mutually exclusive with `children`.
|
|
31
|
+
*/
|
|
32
|
+
render?: (state: RailDropSlotState) => ReactNode;
|
|
33
|
+
className?: string;
|
|
34
|
+
/** Visible label for assistive tech. */
|
|
35
|
+
ariaLabel?: string;
|
|
36
|
+
ref?: Ref<HTMLElement>;
|
|
37
|
+
}
|
|
38
|
+
export declare function RailDropSlot({ id, accepts, disabled, children, render, className, ariaLabel, ref, }: RailDropSlotProps): import("react/jsx-runtime").JSX.Element;
|
|
39
|
+
export declare namespace RailDropSlot {
|
|
40
|
+
var displayName: string;
|
|
41
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { useDndMonitor, useDroppable } from '@dnd-kit/core';
|
|
4
|
+
import { useState } from 'react';
|
|
5
|
+
import { cn } from '../lib/utils.js';
|
|
6
|
+
export function RailDropSlot({ id, accepts, disabled, children, render, className, ariaLabel, ref, }) {
|
|
7
|
+
const { isOver, active, setNodeRef } = useDroppable({
|
|
8
|
+
id,
|
|
9
|
+
disabled,
|
|
10
|
+
data: { type: 'rail-drop-slot' },
|
|
11
|
+
});
|
|
12
|
+
const [globalDragActive, setGlobalDragActive] = useState(false);
|
|
13
|
+
useDndMonitor({
|
|
14
|
+
onDragStart: () => setGlobalDragActive(true),
|
|
15
|
+
onDragEnd: () => setGlobalDragActive(false),
|
|
16
|
+
onDragCancel: () => setGlobalDragActive(false),
|
|
17
|
+
});
|
|
18
|
+
const wouldAccept = !disabled && (!accepts || (active?.id != null && accepts(String(active.id))));
|
|
19
|
+
let state;
|
|
20
|
+
if (isOver && wouldAccept) {
|
|
21
|
+
state = 'over';
|
|
22
|
+
}
|
|
23
|
+
else if (isOver && !wouldAccept) {
|
|
24
|
+
state = 'rejected';
|
|
25
|
+
}
|
|
26
|
+
else if (globalDragActive && disabled) {
|
|
27
|
+
state = 'rejected';
|
|
28
|
+
}
|
|
29
|
+
else if (globalDragActive && wouldAccept) {
|
|
30
|
+
state = 'active';
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
state = 'idle';
|
|
34
|
+
}
|
|
35
|
+
// Legacy boolean aliases kept for backward-compat data attributes
|
|
36
|
+
const showHover = state === 'over';
|
|
37
|
+
const showRejected = state === 'rejected';
|
|
38
|
+
return (_jsx("section", { ref: (el) => {
|
|
39
|
+
setNodeRef(el);
|
|
40
|
+
if (typeof ref === 'function')
|
|
41
|
+
ref(el);
|
|
42
|
+
else if (ref)
|
|
43
|
+
ref.current = el;
|
|
44
|
+
}, "data-slot-id": id, "data-state": state, "aria-label": ariaLabel, "aria-disabled": disabled || undefined, className: cn('relative rounded-lg border border-border bg-card transition-all duration-150', state === 'idle' && '', state === 'active' && 'border-dashed border-primary/60 bg-primary/5', state === 'over' &&
|
|
45
|
+
'border-primary bg-primary/5 ring-2 ring-primary/30 shadow-[0_0_0_4px_hsl(var(--primary)/0.1)]', state === 'rejected' && 'border-destructive/40 opacity-60',
|
|
46
|
+
// Legacy classes — keep working for consumers using data-state selectors
|
|
47
|
+
showHover && '', showRejected && '', disabled && state === 'idle' && 'opacity-60', className), children: render ? render(state) : children }));
|
|
48
|
+
}
|
|
49
|
+
RailDropSlot.displayName = 'RailDropSlot';
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
export interface RailDropZonesProps {
|
|
3
|
+
/** Section title — uppercased in the header. Defaults to "Drop zones". */
|
|
4
|
+
title?: string;
|
|
5
|
+
/** Subtitle shown under the title (e.g. "Eligible machines for ENG-405 · claude"). */
|
|
6
|
+
subtitle?: ReactNode;
|
|
7
|
+
/** Count badge rendered in the header corner, e.g. "3 / 4". */
|
|
8
|
+
count?: ReactNode;
|
|
9
|
+
/** Optional icon rendered at the start of the header. */
|
|
10
|
+
icon?: ReactNode;
|
|
11
|
+
/**
|
|
12
|
+
* When set, overrides the auto-detected drag-active state. Useful for
|
|
13
|
+
* Storybook stories that want to demonstrate the active visual without a
|
|
14
|
+
* real drag in progress.
|
|
15
|
+
*/
|
|
16
|
+
forceActive?: boolean;
|
|
17
|
+
/**
|
|
18
|
+
* Optional predicate; when supplied only activates the rail outline for
|
|
19
|
+
* matching drag ids. Receives `active.id` of the current drag.
|
|
20
|
+
*/
|
|
21
|
+
isActive?: (activeId: string | number) => boolean;
|
|
22
|
+
children: ReactNode;
|
|
23
|
+
className?: string;
|
|
24
|
+
}
|
|
25
|
+
export declare function RailDropZones({ title, subtitle, count, icon, forceActive, isActive, children, className, }: RailDropZonesProps): import("react/jsx-runtime").JSX.Element;
|
|
26
|
+
export declare namespace RailDropZones {
|
|
27
|
+
var displayName: string;
|
|
28
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useDndMonitor } from '@dnd-kit/core';
|
|
4
|
+
import { useState } from 'react';
|
|
5
|
+
import { cn } from '../lib/utils.js';
|
|
6
|
+
export function RailDropZones({ title = 'Drop zones', subtitle, count, icon, forceActive, isActive, children, className, }) {
|
|
7
|
+
const [activeId, setActiveId] = useState(null);
|
|
8
|
+
useDndMonitor({
|
|
9
|
+
onDragStart: (e) => setActiveId(e.active.id),
|
|
10
|
+
onDragEnd: () => setActiveId(null),
|
|
11
|
+
onDragCancel: () => setActiveId(null),
|
|
12
|
+
});
|
|
13
|
+
const auto = activeId != null && (!isActive || isActive(activeId));
|
|
14
|
+
const active = forceActive ?? auto;
|
|
15
|
+
return (_jsxs("section", { "data-active": active || undefined, className: cn('flex flex-col gap-3 rounded-lg border border-transparent p-3 transition-colors', active && 'border-2 border-dashed border-primary/60 bg-primary/5', className), children: [(title || subtitle || count) && (_jsxs("header", { className: "flex items-start justify-between gap-3", children: [_jsxs("div", { className: "flex items-start gap-2", children: [icon && _jsx("span", { className: "text-primary", children: icon }), _jsxs("div", { className: "flex flex-col gap-0.5", children: [title && (_jsx("span", { className: "font-medium text-muted-foreground text-xs uppercase tracking-wider", children: title })), subtitle && _jsx("span", { className: "text-muted-foreground text-xs", children: subtitle })] })] }), count && _jsx("span", { className: "text-muted-foreground text-xs", children: count })] })), _jsx("div", { className: "flex flex-col gap-2", children: children })] }));
|
|
16
|
+
}
|
|
17
|
+
RailDropZones.displayName = 'RailDropZones';
|
package/dist/timeline/index.d.ts
CHANGED
|
@@ -2,6 +2,9 @@ export type { DateSwitcherProps } from './date-switcher.js';
|
|
|
2
2
|
export { DateSwitcher } from './date-switcher.js';
|
|
3
3
|
export type { EventCardProps, EventCardSize } from './event-card.js';
|
|
4
4
|
export { EventCard } from './event-card.js';
|
|
5
|
+
export { LaneTimeline } from './lane-timeline.js';
|
|
6
|
+
export type { Lane, LaneBar, LaneLegendItem, LaneTimelineLabels, LaneTimelineProps, LaneTimelineRange, } from './lane-timeline-types.js';
|
|
7
|
+
export { defaultLaneTimelineLabels } from './lane-timeline-types.js';
|
|
5
8
|
export type { LiveIndicatorProps } from './live-indicator.js';
|
|
6
9
|
export { LiveIndicator } from './live-indicator.js';
|
|
7
10
|
export type { ScrubBarProps } from './scrub-bar.js';
|
package/dist/timeline/index.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export { DateSwitcher } from './date-switcher.js';
|
|
2
2
|
export { EventCard } from './event-card.js';
|
|
3
|
+
export { LaneTimeline } from './lane-timeline.js';
|
|
4
|
+
export { defaultLaneTimelineLabels } from './lane-timeline-types.js';
|
|
3
5
|
export { LiveIndicator } from './live-indicator.js';
|
|
4
6
|
export { ScrubBar } from './scrub-bar.js';
|
|
5
7
|
export { TimelineRail } from './timeline-rail.js';
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
export type LaneTimelineRange = '1h' | '6h' | '24h' | '7d';
|
|
3
|
+
export interface LaneBar {
|
|
4
|
+
id: string;
|
|
5
|
+
start: Date;
|
|
6
|
+
end: Date;
|
|
7
|
+
label: string;
|
|
8
|
+
/** Tailwind class for the bar fill, e.g. 'bg-success-500/80'. */
|
|
9
|
+
fillClass: string;
|
|
10
|
+
/** Optional left-edge accent class, e.g. 'border-l-4 border-l-success-700'. */
|
|
11
|
+
accentClass?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface Lane {
|
|
14
|
+
id: string;
|
|
15
|
+
label: string;
|
|
16
|
+
/** Optional secondary line under label (e.g. "3 sessions" or "offline"). */
|
|
17
|
+
sublabel?: string;
|
|
18
|
+
/** Tailwind class for the leading status dot (e.g. 'bg-success-500'). */
|
|
19
|
+
statusDotClass?: string;
|
|
20
|
+
bars: LaneBar[];
|
|
21
|
+
/** Render lane label muted; bars still render. */
|
|
22
|
+
muted?: boolean;
|
|
23
|
+
}
|
|
24
|
+
export interface LaneLegendItem {
|
|
25
|
+
label: string;
|
|
26
|
+
swatchClass: string;
|
|
27
|
+
}
|
|
28
|
+
export interface LaneTimelineLabels {
|
|
29
|
+
previousDate: string;
|
|
30
|
+
nextDate: string;
|
|
31
|
+
now: string;
|
|
32
|
+
/** "{n} active" — `{n}` replaced at render. */
|
|
33
|
+
activeCount: string;
|
|
34
|
+
}
|
|
35
|
+
export declare const defaultLaneTimelineLabels: LaneTimelineLabels;
|
|
36
|
+
export interface TimeTick {
|
|
37
|
+
/** Display label, e.g. "09:00". */
|
|
38
|
+
label: string;
|
|
39
|
+
/** Position as percentage 0–100 along the time axis. */
|
|
40
|
+
position: number;
|
|
41
|
+
}
|
|
42
|
+
export interface LaneTimelineProps {
|
|
43
|
+
lanes: Lane[];
|
|
44
|
+
/** Reference time for now-line + window end. Defaults to `new Date()`. */
|
|
45
|
+
now?: Date;
|
|
46
|
+
defaultRange?: LaneTimelineRange;
|
|
47
|
+
range?: LaneTimelineRange;
|
|
48
|
+
onRangeChange?: (range: LaneTimelineRange) => void;
|
|
49
|
+
selectedDate?: Date;
|
|
50
|
+
onDateChange?: (date: Date) => void;
|
|
51
|
+
title?: ReactNode;
|
|
52
|
+
/** Render-prop slot for a "Group: …" chip in the header. */
|
|
53
|
+
groupChip?: ReactNode;
|
|
54
|
+
/** Number of currently-active bars; powers the "● {n} active" indicator. */
|
|
55
|
+
activeCount?: number;
|
|
56
|
+
legend?: LaneLegendItem[];
|
|
57
|
+
selectedBarId?: string;
|
|
58
|
+
onSelectBar?: (bar: LaneBar, lane: Lane) => void;
|
|
59
|
+
labels?: Partial<LaneTimelineLabels>;
|
|
60
|
+
className?: string;
|
|
61
|
+
}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import { type LaneTimelineProps } from './lane-timeline-types.js';
|
|
2
|
+
export declare function LaneTimeline({ lanes, now, defaultRange, range: controlledRange, onRangeChange, selectedDate: controlledDate, onDateChange, title, groupChip, activeCount, legend, selectedBarId, onSelectBar, labels, className, }: LaneTimelineProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useMemo, useState } from 'react';
|
|
4
|
+
import { cn } from '../lib/utils.js';
|
|
5
|
+
import { LaneRow } from './lane.js';
|
|
6
|
+
import { defaultLaneTimelineLabels, } from './lane-timeline-types.js';
|
|
7
|
+
import { TimeAxis } from './time-axis.js';
|
|
8
|
+
const LABEL_GUTTER_PX = 140;
|
|
9
|
+
const RANGE_MS = {
|
|
10
|
+
'1h': 60 * 60_000,
|
|
11
|
+
'6h': 6 * 60 * 60_000,
|
|
12
|
+
'24h': 24 * 60 * 60_000,
|
|
13
|
+
'7d': 7 * 24 * 60 * 60_000,
|
|
14
|
+
};
|
|
15
|
+
export function LaneTimeline({ lanes, now, defaultRange = '6h', range: controlledRange, onRangeChange, selectedDate: controlledDate, onDateChange, title, groupChip, activeCount, legend, selectedBarId, onSelectBar, labels, className, }) {
|
|
16
|
+
const resolvedLabels = { ...defaultLaneTimelineLabels, ...(labels ?? {}) };
|
|
17
|
+
const [internalRange, setInternalRange] = useState(defaultRange);
|
|
18
|
+
const [internalDate, setInternalDate] = useState(() => now ?? new Date());
|
|
19
|
+
const range = controlledRange ?? internalRange;
|
|
20
|
+
const selectedDate = controlledDate ?? internalDate;
|
|
21
|
+
const referenceNow = now ?? new Date();
|
|
22
|
+
const { windowStart, windowEnd } = useMemo(() => {
|
|
23
|
+
const rangeMs = RANGE_MS[range];
|
|
24
|
+
const futurePadMs = Math.max(rangeMs / 6, 5 * 60_000);
|
|
25
|
+
const isToday = selectedDate.toDateString() === referenceNow.toDateString();
|
|
26
|
+
const end = isToday ? new Date(referenceNow.getTime() + futurePadMs) : endOfDay(selectedDate);
|
|
27
|
+
// futurePadMs is only relevant for the "today" path (gives the now-line breathing room).
|
|
28
|
+
// Historical days should render exactly rangeMs of window — no asymmetric pad.
|
|
29
|
+
const start = new Date(end.getTime() - rangeMs - (isToday ? futurePadMs : 0));
|
|
30
|
+
return { windowStart: start, windowEnd: end };
|
|
31
|
+
}, [range, selectedDate, referenceNow]);
|
|
32
|
+
const ticks = useMemo(() => buildTicks(windowStart, windowEnd, range), [windowStart, windowEnd, range]);
|
|
33
|
+
const handleRange = (r) => {
|
|
34
|
+
if (controlledRange === undefined)
|
|
35
|
+
setInternalRange(r);
|
|
36
|
+
onRangeChange?.(r);
|
|
37
|
+
};
|
|
38
|
+
const handleDate = (d) => {
|
|
39
|
+
if (controlledDate === undefined)
|
|
40
|
+
setInternalDate(d);
|
|
41
|
+
onDateChange?.(d);
|
|
42
|
+
};
|
|
43
|
+
const showNowLine = referenceNow.getTime() >= windowStart.getTime() &&
|
|
44
|
+
referenceNow.getTime() <= windowEnd.getTime();
|
|
45
|
+
const nowPositionPct = showNowLine
|
|
46
|
+
? ((referenceNow.getTime() - windowStart.getTime()) /
|
|
47
|
+
(windowEnd.getTime() - windowStart.getTime())) *
|
|
48
|
+
100
|
|
49
|
+
: 0;
|
|
50
|
+
return (_jsxs("section", { className: cn('flex flex-col rounded-lg border border-border bg-card p-4', className), children: [_jsx(TimeAxis, { range: range, onRangeChange: handleRange, selectedDate: selectedDate, onDateChange: handleDate, now: referenceNow, title: title, groupChip: groupChip, activeCount: activeCount, labels: resolvedLabels, ticks: ticks, labelGutterPx: LABEL_GUTTER_PX }), _jsxs("div", { className: "relative", children: [lanes.map((lane) => (_jsx(LaneRow, { lane: lane, windowStart: windowStart, windowEnd: windowEnd, labelGutterPx: LABEL_GUTTER_PX, selectedBarId: selectedBarId, onSelectBar: onSelectBar }, lane.id))), showNowLine && (_jsx("div", { "aria-hidden": "true", className: "pointer-events-none absolute top-0 bottom-0 w-px bg-primary", style: {
|
|
51
|
+
left: `calc(${LABEL_GUTTER_PX}px + (100% - ${LABEL_GUTTER_PX}px) * ${nowPositionPct / 100})`,
|
|
52
|
+
}, children: _jsx("div", { className: "-translate-x-1/2 -top-1 absolute size-2 rounded-full bg-primary" }) }))] }), legend && legend.length > 0 && (_jsxs("div", { className: "mt-3 flex items-center gap-4 border-border/40 border-t pt-3 text-muted-foreground text-xs", children: [_jsx("span", { className: "uppercase tracking-wider", children: "Legend" }), legend.map((item) => (_jsxs("span", { className: "flex items-center gap-1.5", children: [_jsx("span", { className: cn('inline-block size-2 rounded-sm', item.swatchClass) }), item.label] }, item.label))), showNowLine && (_jsxs("span", { className: "ml-auto flex items-center gap-1.5", children: [_jsx("span", { className: "inline-block size-2 rounded-full bg-primary" }), resolvedLabels.now, " \u00B7 ", formatTime(referenceNow)] }))] }))] }));
|
|
53
|
+
}
|
|
54
|
+
function endOfDay(d) {
|
|
55
|
+
const e = new Date(d);
|
|
56
|
+
e.setHours(23, 59, 59, 999);
|
|
57
|
+
return e;
|
|
58
|
+
}
|
|
59
|
+
function formatTime(d) {
|
|
60
|
+
return new Intl.DateTimeFormat(undefined, {
|
|
61
|
+
hour: '2-digit',
|
|
62
|
+
minute: '2-digit',
|
|
63
|
+
hour12: false,
|
|
64
|
+
}).format(d);
|
|
65
|
+
}
|
|
66
|
+
function buildTicks(start, end, range) {
|
|
67
|
+
const span = end.getTime() - start.getTime();
|
|
68
|
+
const tickCount = range === '7d' ? 7 : range === '24h' ? 8 : 7;
|
|
69
|
+
const out = [];
|
|
70
|
+
for (let i = 0; i <= tickCount; i++) {
|
|
71
|
+
const t = new Date(start.getTime() + (span * i) / tickCount);
|
|
72
|
+
out.push({ position: (i / tickCount) * 100, label: formatTickLabel(t, range) });
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
function formatTickLabel(d, range) {
|
|
77
|
+
if (range === '7d') {
|
|
78
|
+
return new Intl.DateTimeFormat(undefined, { weekday: 'short' }).format(d);
|
|
79
|
+
}
|
|
80
|
+
return new Intl.DateTimeFormat(undefined, {
|
|
81
|
+
hour: '2-digit',
|
|
82
|
+
minute: '2-digit',
|
|
83
|
+
hour12: false,
|
|
84
|
+
}).format(d);
|
|
85
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Lane, LaneBar } from './lane-timeline-types.js';
|
|
2
|
+
export interface LaneRowProps {
|
|
3
|
+
lane: Lane;
|
|
4
|
+
windowStart: Date;
|
|
5
|
+
windowEnd: Date;
|
|
6
|
+
labelGutterPx: number;
|
|
7
|
+
selectedBarId?: string;
|
|
8
|
+
onSelectBar?: (bar: LaneBar, lane: Lane) => void;
|
|
9
|
+
}
|
|
10
|
+
export declare function LaneRow({ lane, windowStart, windowEnd, labelGutterPx, selectedBarId, onSelectBar, }: LaneRowProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { cn } from '../lib/utils.js';
|
|
4
|
+
export function LaneRow({ lane, windowStart, windowEnd, labelGutterPx, selectedBarId, onSelectBar, }) {
|
|
5
|
+
return (_jsxs("div", { className: "grid items-center gap-0 border-border/40 border-b py-3", style: { gridTemplateColumns: `${labelGutterPx}px 1fr` }, children: [_jsxs("div", { className: "flex flex-col gap-0.5 pr-3", children: [_jsxs("div", { className: cn('flex items-center gap-2 text-sm', lane.muted && 'text-muted-foreground'), children: [lane.statusDotClass && (_jsx("span", { className: cn('inline-block size-2 rounded-full', lane.statusDotClass, lane.muted && 'opacity-40') })), _jsx("span", { className: "font-medium", children: lane.label })] }), lane.sublabel && _jsx("div", { className: "text-muted-foreground text-xs", children: lane.sublabel })] }), _jsx("div", { className: "relative h-7", children: lane.bars.map((bar) => {
|
|
6
|
+
const placement = placeBar(bar, windowStart, windowEnd);
|
|
7
|
+
if (!placement)
|
|
8
|
+
return null;
|
|
9
|
+
const selected = selectedBarId === bar.id;
|
|
10
|
+
const isSelectable = !!onSelectBar;
|
|
11
|
+
const barClassName = cn('absolute top-0 flex h-full items-center overflow-hidden rounded-md px-2 text-left text-xs text-white', bar.fillClass, bar.accentClass, isSelectable && [
|
|
12
|
+
'data-[selected]:ring-2 data-[selected]:ring-primary data-[selected]:ring-offset-1 data-[selected]:ring-offset-background',
|
|
13
|
+
'hover:brightness-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
|
14
|
+
]);
|
|
15
|
+
const barStyle = { left: `${placement.left}%`, width: `${placement.width}%` };
|
|
16
|
+
const ariaLabel = `${lane.label} — ${bar.label}`;
|
|
17
|
+
if (!isSelectable) {
|
|
18
|
+
return (_jsx("div", { role: "img", "aria-label": ariaLabel, className: barClassName, style: barStyle, children: _jsx("span", { "aria-hidden": "true", className: "truncate", children: bar.label }) }, bar.id));
|
|
19
|
+
}
|
|
20
|
+
return (_jsx("button", { type: "button", onClick: () => onSelectBar(bar, lane), "data-selected": selected || undefined, className: barClassName, style: barStyle, "aria-label": ariaLabel, children: _jsx("span", { "aria-hidden": "true", className: "truncate", children: bar.label }) }, bar.id));
|
|
21
|
+
}) })] }));
|
|
22
|
+
}
|
|
23
|
+
function placeBar(bar, windowStart, windowEnd) {
|
|
24
|
+
const ws = windowStart.getTime();
|
|
25
|
+
const we = windowEnd.getTime();
|
|
26
|
+
const span = we - ws;
|
|
27
|
+
if (span <= 0)
|
|
28
|
+
return null;
|
|
29
|
+
const bs = bar.start.getTime();
|
|
30
|
+
const be = bar.end.getTime();
|
|
31
|
+
// Bar entirely outside the window (or exactly touching boundary) — skip.
|
|
32
|
+
if (be <= ws || bs >= we)
|
|
33
|
+
return null;
|
|
34
|
+
const clampedStart = Math.max(bs, ws);
|
|
35
|
+
const clampedEnd = Math.min(be, we);
|
|
36
|
+
const left = ((clampedStart - ws) / span) * 100;
|
|
37
|
+
const width = Math.max(((clampedEnd - clampedStart) / span) * 100, 0.5);
|
|
38
|
+
return { left, width };
|
|
39
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import type { LaneTimelineLabels, LaneTimelineRange, TimeTick } from './lane-timeline-types.js';
|
|
3
|
+
export interface TimeAxisProps {
|
|
4
|
+
range: LaneTimelineRange;
|
|
5
|
+
onRangeChange: (range: LaneTimelineRange) => void;
|
|
6
|
+
selectedDate: Date;
|
|
7
|
+
onDateChange: (date: Date) => void;
|
|
8
|
+
/** Reference time used to determine whether `selectedDate` is "today" in the header label. */
|
|
9
|
+
now: Date;
|
|
10
|
+
title?: ReactNode;
|
|
11
|
+
groupChip?: ReactNode;
|
|
12
|
+
activeCount?: number;
|
|
13
|
+
labels: LaneTimelineLabels;
|
|
14
|
+
ticks: TimeTick[];
|
|
15
|
+
/** Width of the lane-label gutter on the left, in px (matches Lane component). */
|
|
16
|
+
labelGutterPx: number;
|
|
17
|
+
}
|
|
18
|
+
export declare function TimeAxis({ range, onRangeChange, selectedDate, onDateChange, now, title, groupChip, activeCount, labels, ticks, labelGutterPx, }: TimeAxisProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
|
4
|
+
import { cn } from '../lib/utils.js';
|
|
5
|
+
const RANGES = ['1h', '6h', '24h', '7d'];
|
|
6
|
+
export function TimeAxis({ range, onRangeChange, selectedDate, onDateChange, now, title, groupChip, activeCount, labels, ticks, labelGutterPx, }) {
|
|
7
|
+
const stepDate = (delta) => {
|
|
8
|
+
const next = new Date(selectedDate);
|
|
9
|
+
next.setDate(next.getDate() + delta);
|
|
10
|
+
onDateChange(next);
|
|
11
|
+
};
|
|
12
|
+
return (_jsxs("div", { className: "flex flex-col gap-3 border-border border-b pb-3", children: [_jsxs("div", { className: "flex items-center gap-3", children: [title && _jsx("div", { className: "font-medium text-sm", children: title }), _jsx("div", { className: "flex items-center gap-1 rounded-md bg-muted p-0.5", children: RANGES.map((r) => (_jsx("button", { type: "button", "aria-pressed": r === range, onClick: () => onRangeChange(r), "data-active": r === range || undefined, className: cn('rounded px-2 py-0.5 text-xs', 'data-[active]:bg-background data-[active]:text-foreground', r !== range && 'text-muted-foreground hover:text-foreground'), children: r }, r))) }), groupChip && _jsx("div", { className: "text-muted-foreground text-xs", children: groupChip }), typeof activeCount === 'number' && (_jsxs("div", { className: "text-muted-foreground text-xs", children: [_jsx("span", { className: "mr-1 inline-block size-1.5 rounded-full bg-success-500" }), labels.activeCount.replace('{n}', String(activeCount))] })), _jsxs("div", { className: "ml-auto flex items-center gap-1 rounded-md border border-border bg-card px-2 py-1 text-xs", children: [_jsx("button", { type: "button", "aria-label": labels.previousDate, onClick: () => stepDate(-1), className: "text-muted-foreground hover:text-foreground", children: _jsx(ChevronLeft, { className: "size-3.5" }) }), _jsx("div", { className: "px-1", children: formatDateHeader(selectedDate, now) }), _jsx("button", { type: "button", "aria-label": labels.nextDate, onClick: () => stepDate(1), className: "text-muted-foreground hover:text-foreground", children: _jsx(ChevronRight, { className: "size-3.5" }) })] })] }), _jsxs("div", { className: "grid items-end gap-0", style: { gridTemplateColumns: `${labelGutterPx}px 1fr` }, children: [_jsx("div", {}), _jsx("div", { className: "relative h-5", children: ticks.map((t) => (_jsx("div", { className: "-translate-x-1/2 absolute top-0 text-muted-foreground text-xs", style: { left: `${t.position}%` }, children: t.label }, `${t.position}:${t.label}`))) })] })] }));
|
|
13
|
+
}
|
|
14
|
+
function formatDateHeader(d, referenceNow) {
|
|
15
|
+
const isToday = d.toDateString() === referenceNow.toDateString();
|
|
16
|
+
const fmt = new Intl.DateTimeFormat(undefined, {
|
|
17
|
+
weekday: 'short',
|
|
18
|
+
month: 'short',
|
|
19
|
+
day: 'numeric',
|
|
20
|
+
}).format(d);
|
|
21
|
+
return isToday ? `Today · ${fmt}` : fmt;
|
|
22
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@medalsocial/meda",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Shared Meda UI shell and runtime package.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -43,6 +43,14 @@
|
|
|
43
43
|
"types": "./dist/timeline/index.d.ts",
|
|
44
44
|
"default": "./dist/timeline/index.js"
|
|
45
45
|
},
|
|
46
|
+
"./kanban": {
|
|
47
|
+
"types": "./dist/kanban/index.d.ts",
|
|
48
|
+
"import": "./dist/kanban/index.js"
|
|
49
|
+
},
|
|
50
|
+
"./list": {
|
|
51
|
+
"types": "./dist/list/index.d.ts",
|
|
52
|
+
"import": "./dist/list/index.js"
|
|
53
|
+
},
|
|
46
54
|
"./marketing": {
|
|
47
55
|
"types": "./dist/marketing/index.d.ts",
|
|
48
56
|
"default": "./dist/marketing/index.js"
|
|
@@ -73,6 +81,9 @@
|
|
|
73
81
|
"access": "public"
|
|
74
82
|
},
|
|
75
83
|
"peerDependencies": {
|
|
84
|
+
"@dnd-kit/core": "^6.3.1",
|
|
85
|
+
"@dnd-kit/sortable": "^10.0.0",
|
|
86
|
+
"@dnd-kit/utilities": "^3.2.2",
|
|
76
87
|
"@react-three/fiber": "^9.0.0",
|
|
77
88
|
"lucide-react": "^1.8.0",
|
|
78
89
|
"next-themes": "^0.4.0",
|
|
@@ -80,6 +91,15 @@
|
|
|
80
91
|
"react-dom": ">=19"
|
|
81
92
|
},
|
|
82
93
|
"peerDependenciesMeta": {
|
|
94
|
+
"@dnd-kit/core": {
|
|
95
|
+
"optional": false
|
|
96
|
+
},
|
|
97
|
+
"@dnd-kit/sortable": {
|
|
98
|
+
"optional": false
|
|
99
|
+
},
|
|
100
|
+
"@dnd-kit/utilities": {
|
|
101
|
+
"optional": false
|
|
102
|
+
},
|
|
83
103
|
"@react-three/fiber": {
|
|
84
104
|
"optional": true
|
|
85
105
|
},
|
|
@@ -103,6 +123,9 @@
|
|
|
103
123
|
"@biomejs/biome": "^2.4.12",
|
|
104
124
|
"@changesets/changelog-github": "^0.5.1",
|
|
105
125
|
"@changesets/cli": "^2.29.0",
|
|
126
|
+
"@dnd-kit/core": "^6.3.1",
|
|
127
|
+
"@dnd-kit/sortable": "^10.0.0",
|
|
128
|
+
"@dnd-kit/utilities": "^3.2.2",
|
|
106
129
|
"@react-three/drei": "^10.7.7",
|
|
107
130
|
"@react-three/fiber": "^9.6.0",
|
|
108
131
|
"@size-limit/preset-small-lib": "^12.1.0",
|