@mostrom/app-shell 0.1.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.
Files changed (142) hide show
  1. package/.claude/ralph-loop.local.md +9 -0
  2. package/README.md +172 -0
  3. package/bin/init.js +269 -0
  4. package/bun.lock +401 -0
  5. package/components.json +28 -0
  6. package/package.json +74 -0
  7. package/scripts/publish-npm.sh +202 -0
  8. package/src/AppShell.tsx +847 -0
  9. package/src/components/PageHeader.tsx +160 -0
  10. package/src/components/data-table/README.md +447 -0
  11. package/src/components/data-table/data-table-preferences.tsx +184 -0
  12. package/src/components/data-table/data-table-toolbar.tsx +118 -0
  13. package/src/components/data-table/data-table.tsx +37 -0
  14. package/src/components/data-table/index.ts +32 -0
  15. package/src/components/global-header/AllServicesButton.tsx +127 -0
  16. package/src/components/global-header/CategoriesButton.tsx +120 -0
  17. package/src/components/global-header/GlobalHeader.tsx +59 -0
  18. package/src/components/global-header/GlobalHeaderSearch.tsx +57 -0
  19. package/src/components/global-header/HeaderUtilities.tsx +243 -0
  20. package/src/components/global-header/ServicesMenu.tsx +246 -0
  21. package/src/components/layout/AppBreadcrumb.tsx +70 -0
  22. package/src/components/layout/AppFlashbar.tsx +95 -0
  23. package/src/components/layout/AppLayout.tsx +271 -0
  24. package/src/components/layout/AppNavigation.tsx +313 -0
  25. package/src/components/layout/AppSidebar.tsx +229 -0
  26. package/src/components/patterns/index.ts +14 -0
  27. package/src/components/patterns/p-alert-5.tsx +19 -0
  28. package/src/components/patterns/p-autocomplete-5.tsx +89 -0
  29. package/src/components/patterns/p-breadcrumb-1.tsx +28 -0
  30. package/src/components/patterns/p-button-42.tsx +37 -0
  31. package/src/components/patterns/p-button-51.tsx +14 -0
  32. package/src/components/patterns/p-button-6.tsx +5 -0
  33. package/src/components/patterns/p-calendar-1.tsx +18 -0
  34. package/src/components/patterns/p-card-1.tsx +33 -0
  35. package/src/components/patterns/p-card-2.tsx +26 -0
  36. package/src/components/patterns/p-card-5.tsx +31 -0
  37. package/src/components/patterns/p-collapsible-7.tsx +121 -0
  38. package/src/components/patterns/p-command-6.tsx +113 -0
  39. package/src/components/patterns/p-dialog-1.tsx +56 -0
  40. package/src/components/patterns/p-dropdown-menu-1.tsx +38 -0
  41. package/src/components/patterns/p-dropdown-menu-11.tsx +122 -0
  42. package/src/components/patterns/p-dropdown-menu-14.tsx +165 -0
  43. package/src/components/patterns/p-dropdown-menu-9.tsx +108 -0
  44. package/src/components/patterns/p-empty-2.tsx +34 -0
  45. package/src/components/patterns/p-file-upload-1.tsx +72 -0
  46. package/src/components/patterns/p-filters-1.tsx +666 -0
  47. package/src/components/patterns/p-frame-2.tsx +26 -0
  48. package/src/components/patterns/p-tabs-2.tsx +129 -0
  49. package/src/components/reui/alert.tsx +92 -0
  50. package/src/components/reui/autocomplete.tsx +343 -0
  51. package/src/components/reui/badge.tsx +87 -0
  52. package/src/components/reui/data-grid/data-grid-column-filter.tsx +165 -0
  53. package/src/components/reui/data-grid/data-grid-column-header.tsx +339 -0
  54. package/src/components/reui/data-grid/data-grid-column-visibility.tsx +55 -0
  55. package/src/components/reui/data-grid/data-grid-pagination.tsx +224 -0
  56. package/src/components/reui/data-grid/data-grid-table-dnd-rows.tsx +260 -0
  57. package/src/components/reui/data-grid/data-grid-table-dnd.tsx +253 -0
  58. package/src/components/reui/data-grid/data-grid-table.tsx +639 -0
  59. package/src/components/reui/data-grid/data-grid.tsx +209 -0
  60. package/src/components/reui/date-selector.tsx +1330 -0
  61. package/src/components/reui/filters.tsx +1869 -0
  62. package/src/components/reui/frame.tsx +134 -0
  63. package/src/components/reui/index.ts +17 -0
  64. package/src/components/reui/timeline.tsx +219 -0
  65. package/src/components/search/Autocomplete.tsx +183 -0
  66. package/src/components/search/AutocompleteClient.tsx +293 -0
  67. package/src/components/search/GlobalSearch.tsx +187 -0
  68. package/src/components/section-drawer/deal-drawer-content.tsx +891 -0
  69. package/src/components/section-drawer/index.ts +19 -0
  70. package/src/components/section-drawer/section-drawer.css +665 -0
  71. package/src/components/section-drawer/section-drawer.tsx +467 -0
  72. package/src/components/sectioned-list-board/README.md +78 -0
  73. package/src/components/sectioned-list-board/board-card-content.tsx +340 -0
  74. package/src/components/sectioned-list-board/date-range-filter.tsx +249 -0
  75. package/src/components/sectioned-list-board/index.ts +19 -0
  76. package/src/components/sectioned-list-board/sectioned-list-board.css +564 -0
  77. package/src/components/sectioned-list-board/sectioned-list-board.tsx +731 -0
  78. package/src/components/sectioned-list-board/sortable-card.tsx +314 -0
  79. package/src/components/sectioned-list-board/sortable-section.tsx +319 -0
  80. package/src/components/sectioned-list-board/types.ts +216 -0
  81. package/src/components/sectioned-list-table/README.md +80 -0
  82. package/src/components/sectioned-list-table/index.ts +14 -0
  83. package/src/components/sectioned-list-table/sectioned-list-table.css +534 -0
  84. package/src/components/sectioned-list-table/sectioned-list-table.tsx +740 -0
  85. package/src/components/sectioned-list-table/sortable-column-header.tsx +120 -0
  86. package/src/components/sectioned-list-table/sortable-row.tsx +420 -0
  87. package/src/components/sectioned-list-table/sortable-section.tsx +251 -0
  88. package/src/components/sectioned-list-table/table-cell-content.tsx +129 -0
  89. package/src/components/sectioned-list-table/types.ts +120 -0
  90. package/src/components/sectioned-list-table/use-column-preferences.ts +103 -0
  91. package/src/components/ui/actions-dropdown.tsx +109 -0
  92. package/src/components/ui/assignee-selector.tsx +209 -0
  93. package/src/components/ui/avatar.tsx +107 -0
  94. package/src/components/ui/breadcrumb.tsx +109 -0
  95. package/src/components/ui/button-group.tsx +83 -0
  96. package/src/components/ui/button.tsx +64 -0
  97. package/src/components/ui/calendar.tsx +220 -0
  98. package/src/components/ui/card.tsx +92 -0
  99. package/src/components/ui/chart.tsx +376 -0
  100. package/src/components/ui/checkbox.tsx +30 -0
  101. package/src/components/ui/collapsible.tsx +33 -0
  102. package/src/components/ui/command.tsx +182 -0
  103. package/src/components/ui/context-menu.tsx +250 -0
  104. package/src/components/ui/create-button-group.tsx +128 -0
  105. package/src/components/ui/dialog.tsx +156 -0
  106. package/src/components/ui/drawer.tsx +133 -0
  107. package/src/components/ui/dropdown-menu.tsx +255 -0
  108. package/src/components/ui/empty.tsx +104 -0
  109. package/src/components/ui/field.tsx +248 -0
  110. package/src/components/ui/form.tsx +165 -0
  111. package/src/components/ui/index.ts +37 -0
  112. package/src/components/ui/input-group.tsx +168 -0
  113. package/src/components/ui/input.tsx +21 -0
  114. package/src/components/ui/kbd.tsx +28 -0
  115. package/src/components/ui/label.tsx +22 -0
  116. package/src/components/ui/navigation-menu.tsx +168 -0
  117. package/src/components/ui/page-header.tsx +80 -0
  118. package/src/components/ui/popover.tsx +87 -0
  119. package/src/components/ui/scroll-area.tsx +56 -0
  120. package/src/components/ui/select.tsx +190 -0
  121. package/src/components/ui/separator.tsx +26 -0
  122. package/src/components/ui/sheet.tsx +141 -0
  123. package/src/components/ui/sidebar.tsx +726 -0
  124. package/src/components/ui/skeleton.tsx +13 -0
  125. package/src/components/ui/sonner.tsx +38 -0
  126. package/src/components/ui/switch.tsx +33 -0
  127. package/src/components/ui/tabs.tsx +91 -0
  128. package/src/components/ui/textarea.tsx +18 -0
  129. package/src/components/ui/toggle-group.tsx +83 -0
  130. package/src/components/ui/toggle.tsx +45 -0
  131. package/src/components/ui/tooltip.tsx +57 -0
  132. package/src/hooks/use-copy-to-clipboard.ts +37 -0
  133. package/src/hooks/use-file-upload.ts +415 -0
  134. package/src/hooks/use-mobile.ts +19 -0
  135. package/src/index.ts +95 -0
  136. package/src/lib/utils.ts +6 -0
  137. package/src/styles.css +1859 -0
  138. package/src/urls.ts +83 -0
  139. package/src/vite.d.ts +22 -0
  140. package/src/vite.js +241 -0
  141. package/tsconfig.base.json +18 -0
  142. package/tsconfig.json +24 -0
@@ -0,0 +1,740 @@
1
+ import * as React from "react";
2
+ import {
3
+ DndContext,
4
+ DragOverlay,
5
+ closestCenter,
6
+ KeyboardSensor,
7
+ PointerSensor,
8
+ useSensor,
9
+ useSensors,
10
+ type DragEndEvent,
11
+ type DragOverEvent,
12
+ type DragStartEvent,
13
+ } from "@dnd-kit/core";
14
+ import {
15
+ SortableContext,
16
+ arrayMove,
17
+ horizontalListSortingStrategy,
18
+ verticalListSortingStrategy,
19
+ sortableKeyboardCoordinates,
20
+ } from "@dnd-kit/sortable";
21
+ import {
22
+ Drawer,
23
+ DrawerContent,
24
+ } from "@/components/ui/drawer";
25
+ import type {
26
+ SectionedListTableProps,
27
+ SortState,
28
+ DragState,
29
+ Assignee,
30
+ ItemOpenEvent,
31
+ } from "./types";
32
+ import { useColumnPreferences } from "./use-column-preferences";
33
+ import { SortableSection } from "./sortable-section";
34
+ import { SortableColumnHeader } from "./sortable-column-header";
35
+ import { TableCellContent } from "./table-cell-content";
36
+ import "./sectioned-list-table.css";
37
+
38
+ // Prefix section IDs to distinguish from row IDs in the combined context
39
+ const SECTION_PREFIX = "section:";
40
+
41
+ function buildAssigneeId(name: string): string {
42
+ return name
43
+ .toLowerCase()
44
+ .replace(/[^a-z0-9]+/g, "-")
45
+ .replace(/^-+|-+$/g, "");
46
+ }
47
+
48
+ function buildAssigneeEmail(name: string): string {
49
+ const local = name
50
+ .toLowerCase()
51
+ .replace(/[^a-z0-9]+/g, ".")
52
+ .replace(/^\.+|\.+$/g, "");
53
+ return `${local}@example.com`;
54
+ }
55
+
56
+ function deriveAssigneesFromSections<T>(
57
+ sections: Array<{ items: T[] }>,
58
+ field: string
59
+ ): Assignee[] {
60
+ const uniqueNames = new Set<string>();
61
+ const derived: Assignee[] = [];
62
+
63
+ for (const section of sections) {
64
+ for (const item of section.items) {
65
+ const raw = (item as Record<string, unknown>)[field];
66
+ const name = typeof raw === "string" ? raw.trim() : "";
67
+ if (!name || uniqueNames.has(name)) continue;
68
+ uniqueNames.add(name);
69
+ const id = buildAssigneeId(name);
70
+ derived.push({
71
+ id: id ? id : `assignee-${derived.length + 1}`,
72
+ name,
73
+ email: buildAssigneeEmail(name),
74
+ });
75
+ }
76
+ }
77
+
78
+ return derived;
79
+ }
80
+
81
+ export function SectionedListTable<T>({
82
+ columns,
83
+ sections,
84
+ onSectionReorder,
85
+ onRowReorder,
86
+ onRowMoveToSection,
87
+ onAddColumn,
88
+ onAddRow,
89
+ onAddSection,
90
+ onDeleteSection,
91
+ onRenameSection,
92
+ onToggleSection,
93
+ onCellEdit,
94
+ onRowComplete,
95
+ onRowAction,
96
+ onColumnReorder,
97
+ onColumnResize,
98
+ onColumnVisibilityChange,
99
+ onSort,
100
+ filterText = "",
101
+ sort = { columnId: null, direction: null },
102
+ storageKey,
103
+ getItemId,
104
+ isItemCompleted,
105
+ assignees,
106
+ onItemOpen,
107
+ renderItemDetails,
108
+ drawerProps,
109
+ }: SectionedListTableProps<T>) {
110
+ const [dragState, setDragState] = React.useState<DragState>({
111
+ isDragging: false,
112
+ dragType: null,
113
+ activeId: null,
114
+ });
115
+ const [sectionDropIndicator, setSectionDropIndicator] = React.useState<{
116
+ sectionId: string;
117
+ position: "before" | "after";
118
+ } | null>(null);
119
+ const [rowDropIndicator, setRowDropIndicator] = React.useState<{
120
+ sectionId: string;
121
+ index: number;
122
+ } | null>(null);
123
+ const [showColumnPicker, setShowColumnPicker] = React.useState(false);
124
+ const effectiveAssignees = React.useMemo(() => {
125
+ if (assignees && assignees.length > 0) {
126
+ return assignees;
127
+ }
128
+ const avatarColumn = columns.find((column) => column.type === "avatar");
129
+ if (!avatarColumn) {
130
+ return [];
131
+ }
132
+ return deriveAssigneesFromSections(sections, avatarColumn.field as string);
133
+ }, [assignees, columns, sections]);
134
+
135
+ // Drawer state for built-in drawer mode
136
+ const [selectedItem, setSelectedItem] = React.useState<T | null>(null);
137
+
138
+ // Handler for item open events - manages drawer state and calls user callback
139
+ const handleItemOpen = React.useCallback(
140
+ (event: ItemOpenEvent<T>) => {
141
+ console.log("[SectionedListTable] handleItemOpen called:", event, "renderItemDetails:", !!renderItemDetails);
142
+ if (renderItemDetails) {
143
+ console.log("[SectionedListTable] Setting selectedItem:", event.item);
144
+ setSelectedItem(event.item);
145
+ }
146
+ onItemOpen?.(event);
147
+ },
148
+ [renderItemDetails, onItemOpen]
149
+ );
150
+
151
+ const {
152
+ visibleColumns,
153
+ orderedColumns,
154
+ setColumnOrder,
155
+ setColumnVisibility,
156
+ setColumnWidth,
157
+ } = useColumnPreferences(columns, storageKey);
158
+
159
+ const sensors = useSensors(
160
+ useSensor(PointerSensor, {
161
+ activationConstraint: {
162
+ distance: 8,
163
+ },
164
+ }),
165
+ useSensor(KeyboardSensor, {
166
+ coordinateGetter: sortableKeyboardCoordinates,
167
+ })
168
+ );
169
+
170
+ // Determine if row drag should be disabled
171
+ const isRowDragDisabled = sort.columnId !== null || filterText.length > 0;
172
+
173
+ // Prefixed section IDs for drag context
174
+ const sectionIds = sections.map((s) => `${SECTION_PREFIX}${s.id}`);
175
+ const columnIds = visibleColumns.map((c) => c.id);
176
+
177
+ const parseSectionId = (id: string): string | null => {
178
+ if (!id.startsWith(SECTION_PREFIX)) return null;
179
+ return id.slice(SECTION_PREFIX.length);
180
+ };
181
+
182
+ const activeSection =
183
+ dragState.dragType === "section" && dragState.activeId
184
+ ? sections.find((section) => section.id === dragState.activeId) ?? null
185
+ : null;
186
+
187
+ const activeRow =
188
+ dragState.dragType === "row" && dragState.activeId
189
+ ? (() => {
190
+ for (const section of sections) {
191
+ const index = section.items.findIndex(
192
+ (item) => getItemId(item) === dragState.activeId
193
+ );
194
+ if (index !== -1) {
195
+ return { item: section.items[index], index };
196
+ }
197
+ }
198
+ return null;
199
+ })()
200
+ : null;
201
+
202
+ // Column drag handlers
203
+ const handleColumnDragStart = (event: DragStartEvent) => {
204
+ setSectionDropIndicator(null);
205
+ setRowDropIndicator(null);
206
+ setDragState({
207
+ isDragging: true,
208
+ dragType: "column",
209
+ activeId: event.active.id as string,
210
+ });
211
+ };
212
+
213
+ const handleColumnDragEnd = (event: DragEndEvent) => {
214
+ const { active, over } = event;
215
+ setSectionDropIndicator(null);
216
+ setRowDropIndicator(null);
217
+ setDragState({ isDragging: false, dragType: null, activeId: null });
218
+
219
+ if (over && active.id !== over.id) {
220
+ const oldIndex = visibleColumns.findIndex((c) => c.id === active.id);
221
+ const newIndex = visibleColumns.findIndex((c) => c.id === over.id);
222
+ if (oldIndex !== -1 && newIndex !== -1) {
223
+ const newOrder = arrayMove(
224
+ visibleColumns.map((c) => c.id),
225
+ oldIndex,
226
+ newIndex
227
+ );
228
+ setColumnOrder(newOrder);
229
+ onColumnReorder?.(arrayMove(visibleColumns, oldIndex, newIndex));
230
+ }
231
+ }
232
+ };
233
+
234
+ // Combined section and row drag handlers
235
+ const handleDragStart = (event: DragStartEvent) => {
236
+ setSectionDropIndicator(null);
237
+ setRowDropIndicator(null);
238
+ const activeData = event.active.data.current as
239
+ | { type?: "section" | "row"; sectionId?: string }
240
+ | undefined;
241
+ const id = event.active.id as string;
242
+
243
+ if (activeData?.type === "section" || id.startsWith(SECTION_PREFIX)) {
244
+ setDragState({
245
+ isDragging: true,
246
+ dragType: "section",
247
+ activeId: activeData?.sectionId ?? parseSectionId(id),
248
+ });
249
+ } else {
250
+ setDragState({
251
+ isDragging: true,
252
+ dragType: "row",
253
+ activeId: id,
254
+ });
255
+ }
256
+ };
257
+
258
+ const handleDragOver = (event: DragOverEvent) => {
259
+ const activeId = event.active.id as string;
260
+ const activeData = event.active.data.current as
261
+ | { type?: "section" | "row"; sectionId?: string; index?: number }
262
+ | undefined;
263
+
264
+ const isSectionDrag =
265
+ activeData?.type === "section" || activeId.startsWith(SECTION_PREFIX);
266
+ const over = event.over;
267
+ if (!over) {
268
+ if (sectionDropIndicator) setSectionDropIndicator(null);
269
+ if (rowDropIndicator) setRowDropIndicator(null);
270
+ return;
271
+ }
272
+
273
+ if (isSectionDrag) {
274
+ if (rowDropIndicator) setRowDropIndicator(null);
275
+
276
+ const activeSectionId = activeData?.sectionId ?? parseSectionId(activeId);
277
+ if (!activeSectionId) {
278
+ if (sectionDropIndicator) setSectionDropIndicator(null);
279
+ return;
280
+ }
281
+
282
+ const overId = over.id as string;
283
+ const overData = over.data.current as
284
+ | { type?: "section" | "row"; sectionId?: string; index?: number }
285
+ | undefined;
286
+ const targetSectionId = overData?.sectionId ?? parseSectionId(overId);
287
+
288
+ if (!targetSectionId || targetSectionId === activeSectionId) {
289
+ if (sectionDropIndicator) setSectionDropIndicator(null);
290
+ return;
291
+ }
292
+
293
+ const activeRect = event.active.rect.current.translated;
294
+ const activeMidY = activeRect
295
+ ? activeRect.top + activeRect.height / 2
296
+ : null;
297
+ const overMidY = over.rect.top + over.rect.height / 2;
298
+ let position: "before" | "after" =
299
+ activeMidY !== null && activeMidY > overMidY ? "after" : "before";
300
+
301
+ // Snap indicator direction to drag direction for smoother, predictable section placement.
302
+ const activeIndex = sections.findIndex((s) => s.id === activeSectionId);
303
+ const targetIndex = sections.findIndex((s) => s.id === targetSectionId);
304
+ if (activeIndex !== -1 && targetIndex !== -1) {
305
+ if (activeIndex < targetIndex) {
306
+ position = "after";
307
+ } else if (activeIndex > targetIndex) {
308
+ position = "before";
309
+ }
310
+ }
311
+
312
+ setSectionDropIndicator((prev) => {
313
+ if (
314
+ prev &&
315
+ prev.sectionId === targetSectionId &&
316
+ prev.position === position
317
+ ) {
318
+ return prev;
319
+ }
320
+ return { sectionId: targetSectionId, position };
321
+ });
322
+ return;
323
+ }
324
+
325
+ if (sectionDropIndicator) setSectionDropIndicator(null);
326
+
327
+ if (!activeData || activeData.type !== "row" || !activeData.sectionId) {
328
+ if (rowDropIndicator) setRowDropIndicator(null);
329
+ return;
330
+ }
331
+
332
+ const sourceSectionId = activeData.sectionId;
333
+ const overId = over.id as string;
334
+ const overData = over.data.current as
335
+ | { type?: "section" | "row"; sectionId?: string; index?: number }
336
+ | undefined;
337
+ const targetSectionId = overData?.sectionId ?? parseSectionId(overId);
338
+
339
+ if (!targetSectionId) {
340
+ if (rowDropIndicator) setRowDropIndicator(null);
341
+ return;
342
+ }
343
+
344
+ const targetSection = sections.find((section) => section.id === targetSectionId);
345
+ if (!targetSection) {
346
+ if (rowDropIndicator) setRowDropIndicator(null);
347
+ return;
348
+ }
349
+
350
+ let insertIndex = targetSection.items.length;
351
+ if (overData?.type === "row" && typeof overData.index === "number") {
352
+ const overIndex = overData.index;
353
+ if (
354
+ sourceSectionId === targetSectionId &&
355
+ typeof activeData.index === "number"
356
+ ) {
357
+ insertIndex = activeData.index < overIndex ? overIndex + 1 : overIndex;
358
+ } else {
359
+ const activeRect = event.active.rect.current.translated;
360
+ const activeMidY = activeRect
361
+ ? activeRect.top + activeRect.height / 2
362
+ : null;
363
+ const overMidY = over.rect.top + over.rect.height / 2;
364
+ insertIndex =
365
+ overIndex + (activeMidY !== null && activeMidY > overMidY ? 1 : 0);
366
+ }
367
+ } else if (overData?.type === "section") {
368
+ const activeRect = event.active.rect.current.translated;
369
+ const activeMidY = activeRect
370
+ ? activeRect.top + activeRect.height / 2
371
+ : null;
372
+ const overMidY = over.rect.top + over.rect.height / 2;
373
+ insertIndex =
374
+ activeMidY !== null && activeMidY < overMidY
375
+ ? 0
376
+ : targetSection.items.length;
377
+ }
378
+
379
+ insertIndex = Math.max(0, Math.min(insertIndex, targetSection.items.length));
380
+
381
+ const isNoOpWithinSameSection =
382
+ sourceSectionId === targetSectionId &&
383
+ typeof activeData.index === "number" &&
384
+ (insertIndex === activeData.index || insertIndex === activeData.index + 1);
385
+
386
+ if (isNoOpWithinSameSection) {
387
+ if (rowDropIndicator) setRowDropIndicator(null);
388
+ return;
389
+ }
390
+
391
+ setRowDropIndicator((prev) => {
392
+ if (
393
+ prev &&
394
+ prev.sectionId === targetSectionId &&
395
+ prev.index === insertIndex
396
+ ) {
397
+ return prev;
398
+ }
399
+ return { sectionId: targetSectionId, index: insertIndex };
400
+ });
401
+ };
402
+
403
+ const handleDragEnd = (event: DragEndEvent) => {
404
+ const { active, over } = event;
405
+ const pendingSectionDropIndicator = sectionDropIndicator;
406
+ const pendingRowDropIndicator = rowDropIndicator;
407
+ setSectionDropIndicator(null);
408
+ setRowDropIndicator(null);
409
+ setDragState({ isDragging: false, dragType: null, activeId: null });
410
+
411
+ if (!over) return;
412
+
413
+ const activeId = active.id as string;
414
+ const overId = over.id as string;
415
+ const activeData = active.data.current as
416
+ | { type?: "section" | "row"; sectionId?: string; index?: number }
417
+ | undefined;
418
+ const overData = over.data.current as
419
+ | { type?: "section" | "row"; sectionId?: string; index?: number }
420
+ | undefined;
421
+
422
+ // Check if this is a section drag
423
+ if (activeData?.type === "section" || activeId.startsWith(SECTION_PREFIX)) {
424
+ const activeSectionId = activeData?.sectionId ?? parseSectionId(activeId);
425
+ const overSectionId =
426
+ pendingSectionDropIndicator?.sectionId ??
427
+ overData?.sectionId ??
428
+ (overData?.type === "section" ? overData.sectionId ?? null : parseSectionId(overId));
429
+
430
+ if (
431
+ activeSectionId &&
432
+ overSectionId &&
433
+ activeSectionId !== overSectionId
434
+ ) {
435
+ const oldIndex = sections.findIndex((s) => s.id === activeSectionId);
436
+ const overIndex = sections.findIndex((s) => s.id === overSectionId);
437
+ let newIndex = overIndex;
438
+
439
+ if (pendingSectionDropIndicator?.position === "before") {
440
+ newIndex = overIndex - (oldIndex < overIndex ? 1 : 0);
441
+ } else if (pendingSectionDropIndicator?.position === "after") {
442
+ newIndex = overIndex + (oldIndex < overIndex ? 0 : 1);
443
+ }
444
+
445
+ const maxIndex = sections.length - 1;
446
+ newIndex = Math.max(0, Math.min(maxIndex, newIndex));
447
+
448
+ if (oldIndex !== -1 && newIndex !== -1) {
449
+ const newSections = arrayMove(sections, oldIndex, newIndex);
450
+ onSectionReorder?.(newSections);
451
+ }
452
+ }
453
+ return;
454
+ }
455
+
456
+ // Row drag
457
+ if (!activeData || activeData.type !== "row") return;
458
+
459
+ const sourceSectionId = activeData.sectionId;
460
+ if (!sourceSectionId) return;
461
+ const sourceSection = sections.find((s) => s.id === sourceSectionId);
462
+ if (!sourceSection) return;
463
+
464
+ // Find target section
465
+ let targetSectionId = sourceSectionId;
466
+ if (overData?.sectionId) {
467
+ targetSectionId = overData.sectionId;
468
+ } else {
469
+ const parsedSectionId = parseSectionId(overId);
470
+ if (parsedSectionId) {
471
+ targetSectionId = parsedSectionId;
472
+ }
473
+ }
474
+
475
+ if (sourceSectionId === targetSectionId) {
476
+ // Same section reorder
477
+ const oldIndex = sourceSection.items.findIndex(
478
+ (item) => getItemId(item) === activeId
479
+ );
480
+
481
+ let newIndex: number;
482
+ if (
483
+ pendingRowDropIndicator &&
484
+ pendingRowDropIndicator.sectionId === sourceSectionId
485
+ ) {
486
+ const insertSlot = Math.max(
487
+ 0,
488
+ Math.min(pendingRowDropIndicator.index, sourceSection.items.length)
489
+ );
490
+ newIndex = insertSlot > oldIndex ? insertSlot - 1 : insertSlot;
491
+ } else if (overData?.type === "row" && overData.index !== undefined) {
492
+ newIndex = overData.index;
493
+ } else {
494
+ const overItemIndex = sourceSection.items.findIndex(
495
+ (item) => getItemId(item) === overId
496
+ );
497
+ newIndex = overItemIndex !== -1 ? overItemIndex : sourceSection.items.length - 1;
498
+ }
499
+
500
+ if (oldIndex !== -1 && oldIndex !== newIndex) {
501
+ const newItems = arrayMove(sourceSection.items, oldIndex, newIndex);
502
+ onRowReorder?.(sourceSectionId, newItems);
503
+ }
504
+ } else {
505
+ // Cross-section move
506
+ const item = sourceSection.items.find((i) => getItemId(i) === activeId);
507
+ if (item) {
508
+ const targetIndex =
509
+ pendingRowDropIndicator?.sectionId === targetSectionId
510
+ ? pendingRowDropIndicator.index
511
+ : undefined;
512
+ onRowMoveToSection?.(item, sourceSectionId, targetSectionId, targetIndex);
513
+ }
514
+ }
515
+ };
516
+
517
+ const handleSort = (columnId: string) => {
518
+ let newSort: SortState;
519
+ if (sort.columnId === columnId) {
520
+ if (sort.direction === "asc") {
521
+ newSort = { columnId, direction: "desc" };
522
+ } else if (sort.direction === "desc") {
523
+ newSort = { columnId: null, direction: null };
524
+ } else {
525
+ newSort = { columnId, direction: "asc" };
526
+ }
527
+ } else {
528
+ newSort = { columnId, direction: "asc" };
529
+ }
530
+ onSort?.(newSort);
531
+ };
532
+
533
+ const handleColumnResize = (columnId: string, width: number) => {
534
+ setColumnWidth(columnId, width);
535
+ onColumnResize?.(columnId, width);
536
+ };
537
+
538
+ return (
539
+ <>
540
+ <div className="sectioned-list-table" data-testid="sectioned-list-table">
541
+ {/* Sticky Header */}
542
+ <div className="sectioned-list-header" data-testid="table-header">
543
+ <DndContext
544
+ id="column-dnd"
545
+ sensors={sensors}
546
+ collisionDetection={closestCenter}
547
+ onDragStart={handleColumnDragStart}
548
+ onDragEnd={handleColumnDragEnd}
549
+ >
550
+ <SortableContext
551
+ items={columnIds}
552
+ strategy={horizontalListSortingStrategy}
553
+ >
554
+ {/* Drag handle + checkbox placeholder */}
555
+ <div className="column-header placeholder" style={{ width: 60 }} />
556
+
557
+ {visibleColumns.map((column) => (
558
+ <SortableColumnHeader
559
+ key={column.id}
560
+ column={column}
561
+ sort={sort}
562
+ onSort={handleSort}
563
+ onResize={(width) => handleColumnResize(column.id, width)}
564
+ />
565
+ ))}
566
+
567
+ {/* Add Column Button */}
568
+ <div
569
+ className="column-header add-column"
570
+ onClick={() => setShowColumnPicker(true)}
571
+ data-testid="add-column-button"
572
+ >
573
+ <span className="add-column-icon">+</span>
574
+ </div>
575
+ </SortableContext>
576
+ </DndContext>
577
+ </div>
578
+
579
+ {/* Column Picker Modal */}
580
+ {showColumnPicker && (
581
+ <div className="column-picker-modal" data-testid="column-picker-modal">
582
+ <div className="column-picker-content">
583
+ <h3>Column Visibility</h3>
584
+ {orderedColumns.map((column) => (
585
+ <label key={column.id} className="column-picker-item">
586
+ <input
587
+ type="checkbox"
588
+ checked={column.visible !== false}
589
+ onChange={(e) => {
590
+ setColumnVisibility(column.id, e.target.checked);
591
+ onColumnVisibilityChange?.(column.id, e.target.checked);
592
+ }}
593
+ data-testid={`column-visibility-${column.id}`}
594
+ />
595
+ {column.header}
596
+ </label>
597
+ ))}
598
+ <div className="column-picker-actions">
599
+ <button onClick={() => onAddColumn?.()} data-testid="add-new-column">
600
+ Add new column
601
+ </button>
602
+ <button onClick={() => setShowColumnPicker(false)}>Close</button>
603
+ </div>
604
+ </div>
605
+ </div>
606
+ )}
607
+
608
+ {/* Sections - Single DndContext for both sections and rows */}
609
+ <div className="sectioned-list-body">
610
+ <DndContext
611
+ id="sections-and-rows-dnd"
612
+ sensors={sensors}
613
+ collisionDetection={closestCenter}
614
+ onDragStart={handleDragStart}
615
+ onDragOver={handleDragOver}
616
+ onDragEnd={handleDragEnd}
617
+ onDragCancel={() => {
618
+ setSectionDropIndicator(null);
619
+ setRowDropIndicator(null);
620
+ setDragState({ isDragging: false, dragType: null, activeId: null });
621
+ }}
622
+ >
623
+ <SortableContext
624
+ items={sectionIds}
625
+ strategy={verticalListSortingStrategy}
626
+ >
627
+ {sections.map((section) => (
628
+ <SortableSection
629
+ key={section.id}
630
+ sortableId={`${SECTION_PREFIX}${section.id}`}
631
+ section={section}
632
+ onToggle={() => onToggleSection?.(section.id)}
633
+ onRename={(name) => onRenameSection?.(section.id, name)}
634
+ onDelete={() => onDeleteSection?.(section.id)}
635
+ onAddRow={() => onAddRow?.(section.id)}
636
+ onCellEdit={(itemIndex, columnId, value) =>
637
+ onCellEdit?.(section.id, itemIndex, columnId, value)
638
+ }
639
+ onRowComplete={(itemIndex, completed) =>
640
+ onRowComplete?.(section.id, itemIndex, completed)
641
+ }
642
+ onRowAction={(itemIndex, itemId, action) =>
643
+ onRowAction?.(section.id, itemIndex, itemId, action)
644
+ }
645
+ onItemOpen={handleItemOpen}
646
+ getItemId={getItemId}
647
+ isItemCompleted={isItemCompleted}
648
+ isDragDisabled={isRowDragDisabled}
649
+ columns={visibleColumns}
650
+ collapseDuringDrag={
651
+ dragState.dragType === "section" &&
652
+ dragState.activeId === section.id
653
+ }
654
+ dropIndicatorPosition={
655
+ sectionDropIndicator?.sectionId === section.id
656
+ ? sectionDropIndicator.position
657
+ : null
658
+ }
659
+ rowDropIndicatorIndex={
660
+ rowDropIndicator?.sectionId === section.id
661
+ ? rowDropIndicator.index
662
+ : null
663
+ }
664
+ assignees={effectiveAssignees}
665
+ />
666
+ ))}
667
+ </SortableContext>
668
+
669
+ <DragOverlay>
670
+ {dragState.dragType === "section" && activeSection ? (
671
+ <div className="drag-overlay-section">
672
+ <div className="sectioned-list-section-header">
673
+ <span className="drag-icon">&#x2847;</span>
674
+ <span className="section-name">{activeSection.name}</span>
675
+ <span className="section-count">
676
+ ({activeSection.items.length})
677
+ </span>
678
+ </div>
679
+ </div>
680
+ ) : null}
681
+
682
+ {dragState.dragType === "row" && activeRow ? (
683
+ <div className="sectioned-list-row sectioned-list-row-overlay">
684
+ <div className="row-drag-handle">
685
+ <span className="drag-icon">⠿</span>
686
+ </div>
687
+ <div className="row-checkbox-cell">
688
+ <input type="checkbox" checked={isItemCompleted?.(activeRow.item) ?? false} readOnly />
689
+ </div>
690
+ {visibleColumns.map((column) => (
691
+ <div
692
+ key={column.id}
693
+ className="row-cell"
694
+ style={{ width: column.width, minWidth: column.minWidth }}
695
+ >
696
+ <TableCellContent
697
+ column={column}
698
+ item={activeRow.item}
699
+ rowIndex={activeRow.index}
700
+ assignees={effectiveAssignees}
701
+ />
702
+ </div>
703
+ ))}
704
+ </div>
705
+ ) : null}
706
+ </DragOverlay>
707
+ </DndContext>
708
+
709
+ {/* Add Section Button */}
710
+ <button
711
+ className="add-section-button"
712
+ onClick={() => onAddSection?.()}
713
+ data-testid="add-section-button"
714
+ >
715
+ <span className="add-section-icon">+</span>
716
+ <span>Add section</span>
717
+ </button>
718
+ </div>
719
+ </div>
720
+
721
+ {/* Built-in drawer for item details when renderItemDetails is provided */}
722
+ {renderItemDetails && (
723
+ <Drawer
724
+ open={selectedItem !== null}
725
+ onOpenChange={(open) => !open && setSelectedItem(null)}
726
+ direction={drawerProps?.side ?? "right"}
727
+ >
728
+ <DrawerContent
729
+ style={{
730
+ width: drawerProps?.width ?? 400,
731
+ maxWidth: "100vw",
732
+ }}
733
+ >
734
+ {selectedItem && renderItemDetails(selectedItem)}
735
+ </DrawerContent>
736
+ </Drawer>
737
+ )}
738
+ </>
739
+ );
740
+ }