@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,731 @@
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
+ sortableKeyboardCoordinates,
19
+ } from "@dnd-kit/sortable";
20
+ import {
21
+ DropdownMenu,
22
+ DropdownMenuContent,
23
+ DropdownMenuItem,
24
+ DropdownMenuTrigger,
25
+ DropdownMenuSeparator,
26
+ } from "@/components/ui/dropdown-menu";
27
+ import {
28
+ Drawer,
29
+ DrawerContent,
30
+ } from "@/components/ui/drawer";
31
+ import { ArrowUpDown, Check, ChevronDown, Filter } from "lucide-react";
32
+ import type { SectionedListBoardProps, DragState, Assignee, ItemOpenEvent } from "./types";
33
+ import { SortableBoardSection } from "./sortable-section";
34
+ import { DateRangeFilter } from "./date-range-filter";
35
+ import { BoardCardContent } from "./board-card-content";
36
+ import "./sectioned-list-board.css";
37
+
38
+ const SECTION_PREFIX = "section:";
39
+
40
+ function buildAssigneeId(name: string): string {
41
+ return name
42
+ .toLowerCase()
43
+ .replace(/[^a-z0-9]+/g, "-")
44
+ .replace(/^-+|-+$/g, "");
45
+ }
46
+
47
+ function buildAssigneeEmail(name: string): string {
48
+ const local = name
49
+ .toLowerCase()
50
+ .replace(/[^a-z0-9]+/g, ".")
51
+ .replace(/^\.+|\.+$/g, "");
52
+ return `${local}@example.com`;
53
+ }
54
+
55
+ function deriveAssigneesFromSections<T>(
56
+ sections: Array<{ items: T[] }>,
57
+ field: string
58
+ ): Assignee[] {
59
+ const uniqueNames = new Set<string>();
60
+ const derived: Assignee[] = [];
61
+
62
+ for (const section of sections) {
63
+ for (const item of section.items) {
64
+ const raw = (item as Record<string, unknown>)[field];
65
+ const name = typeof raw === "string" ? raw.trim() : "";
66
+ if (!name || uniqueNames.has(name)) continue;
67
+ uniqueNames.add(name);
68
+ const id = buildAssigneeId(name);
69
+ derived.push({
70
+ id: id ? id : `assignee-${derived.length + 1}`,
71
+ name,
72
+ email: buildAssigneeEmail(name),
73
+ });
74
+ }
75
+ }
76
+
77
+ return derived;
78
+ }
79
+
80
+ export function SectionedListBoard<T>({
81
+ sections,
82
+ getItemId,
83
+ cardConfig,
84
+ onItemFieldChange,
85
+ isItemCompleted,
86
+ onSectionReorder,
87
+ onToggleSection,
88
+ onRenameSection,
89
+ onAddSection,
90
+ onDeleteSection,
91
+ onItemReorder,
92
+ onItemMoveToSection,
93
+ onAddItem,
94
+ onItemTitleEdit,
95
+ onItemComplete,
96
+ onItemAction,
97
+ filterOptions,
98
+ sortOptions,
99
+ filter = { field: null, value: null },
100
+ sort = { field: null, direction: null },
101
+ onFilterChange,
102
+ onSortChange,
103
+ dateRange = { from: undefined, to: undefined },
104
+ onDateRangeChange,
105
+ dateRangeLabel,
106
+ assignees,
107
+ onItemOpen,
108
+ renderItemDetails,
109
+ drawerProps,
110
+ }: SectionedListBoardProps<T>) {
111
+ const [dragState, setDragState] = React.useState<DragState>({
112
+ isDragging: false,
113
+ dragType: null,
114
+ activeId: null,
115
+ });
116
+ const [sectionDropIndicator, setSectionDropIndicator] = React.useState<{
117
+ sectionId: string;
118
+ position: "before" | "after";
119
+ } | null>(null);
120
+ const [cardDropIndicator, setCardDropIndicator] = React.useState<{
121
+ sectionId: string;
122
+ index: number;
123
+ } | null>(null);
124
+ const effectiveAssignees = React.useMemo(() => {
125
+ if (assignees && assignees.length > 0) {
126
+ return assignees;
127
+ }
128
+ if (!cardConfig.avatarField) {
129
+ return [];
130
+ }
131
+ return deriveAssigneesFromSections(
132
+ sections,
133
+ cardConfig.avatarField.field as string
134
+ );
135
+ }, [assignees, sections, cardConfig.avatarField]);
136
+
137
+ // Drawer state for built-in drawer mode
138
+ const [selectedItem, setSelectedItem] = React.useState<T | null>(null);
139
+
140
+ // Handler for item open events - manages drawer state and calls user callback
141
+ const handleItemOpen = React.useCallback(
142
+ (event: ItemOpenEvent<T>) => {
143
+ console.log("[SectionedListBoard] handleItemOpen called:", event, "renderItemDetails:", !!renderItemDetails);
144
+ if (renderItemDetails) {
145
+ console.log("[SectionedListBoard] Setting selectedItem:", event.item);
146
+ setSelectedItem(event.item);
147
+ }
148
+ onItemOpen?.(event);
149
+ },
150
+ [renderItemDetails, onItemOpen]
151
+ );
152
+
153
+ const sensors = useSensors(
154
+ useSensor(PointerSensor, {
155
+ activationConstraint: {
156
+ distance: 8,
157
+ },
158
+ }),
159
+ useSensor(KeyboardSensor, {
160
+ coordinateGetter: sortableKeyboardCoordinates,
161
+ })
162
+ );
163
+
164
+ // Determine if card drag should be disabled
165
+ const isCardDragDisabled = sort.field !== null || filter.field !== null;
166
+
167
+ const sectionIds = sections.map((s) => `${SECTION_PREFIX}${s.id}`);
168
+
169
+ const parseSectionId = (id: string): string | null => {
170
+ if (!id.startsWith(SECTION_PREFIX)) return null;
171
+ return id.slice(SECTION_PREFIX.length);
172
+ };
173
+
174
+ const activeSection =
175
+ dragState.dragType === "section" && dragState.activeId
176
+ ? sections.find((section) => section.id === dragState.activeId) ?? null
177
+ : null;
178
+
179
+ const activeCard =
180
+ dragState.dragType === "card" && dragState.activeId
181
+ ? (() => {
182
+ for (const section of sections) {
183
+ const index = section.items.findIndex(
184
+ (item) => getItemId(item) === dragState.activeId
185
+ );
186
+ if (index !== -1) {
187
+ return { item: section.items[index], index, sectionId: section.id };
188
+ }
189
+ }
190
+ return null;
191
+ })()
192
+ : null;
193
+
194
+ const handleDragStart = (event: DragStartEvent) => {
195
+ setSectionDropIndicator(null);
196
+ setCardDropIndicator(null);
197
+ const activeData = event.active.data.current as
198
+ | { type?: "section" | "card"; sectionId?: string }
199
+ | undefined;
200
+ const id = event.active.id as string;
201
+
202
+ if (activeData?.type === "section" || id.startsWith(SECTION_PREFIX)) {
203
+ setDragState({
204
+ isDragging: true,
205
+ dragType: "section",
206
+ activeId: activeData?.sectionId ?? parseSectionId(id),
207
+ });
208
+ } else {
209
+ setDragState({
210
+ isDragging: true,
211
+ dragType: "card",
212
+ activeId: id,
213
+ });
214
+ }
215
+ };
216
+
217
+ const handleDragOver = (event: DragOverEvent) => {
218
+ const activeId = event.active.id as string;
219
+ const activeData = event.active.data.current as
220
+ | { type?: "section" | "card"; sectionId?: string; index?: number }
221
+ | undefined;
222
+
223
+ const isSectionDrag =
224
+ activeData?.type === "section" || activeId.startsWith(SECTION_PREFIX);
225
+ const over = event.over;
226
+ if (!over) {
227
+ if (sectionDropIndicator) setSectionDropIndicator(null);
228
+ if (cardDropIndicator) setCardDropIndicator(null);
229
+ return;
230
+ }
231
+
232
+ if (isSectionDrag) {
233
+ if (cardDropIndicator) setCardDropIndicator(null);
234
+
235
+ const activeSectionId = activeData?.sectionId ?? parseSectionId(activeId);
236
+ if (!activeSectionId) {
237
+ if (sectionDropIndicator) setSectionDropIndicator(null);
238
+ return;
239
+ }
240
+
241
+ const overId = over.id as string;
242
+ const overData = over.data.current as
243
+ | { type?: "section" | "card"; sectionId?: string; index?: number }
244
+ | undefined;
245
+ const targetSectionId = overData?.sectionId ?? parseSectionId(overId);
246
+
247
+ if (!targetSectionId || targetSectionId === activeSectionId) {
248
+ if (sectionDropIndicator) setSectionDropIndicator(null);
249
+ return;
250
+ }
251
+
252
+ const activeIndex = sections.findIndex((s) => s.id === activeSectionId);
253
+ const targetIndex = sections.findIndex((s) => s.id === targetSectionId);
254
+ let position: "before" | "after" = activeIndex < targetIndex ? "after" : "before";
255
+
256
+ setSectionDropIndicator((prev) => {
257
+ if (
258
+ prev &&
259
+ prev.sectionId === targetSectionId &&
260
+ prev.position === position
261
+ ) {
262
+ return prev;
263
+ }
264
+ return { sectionId: targetSectionId, position };
265
+ });
266
+ return;
267
+ }
268
+
269
+ if (sectionDropIndicator) setSectionDropIndicator(null);
270
+
271
+ if (!activeData || activeData.type !== "card" || !activeData.sectionId) {
272
+ if (cardDropIndicator) setCardDropIndicator(null);
273
+ return;
274
+ }
275
+
276
+ const sourceSectionId = activeData.sectionId;
277
+ const overId = over.id as string;
278
+ const overData = over.data.current as
279
+ | { type?: "section" | "card"; sectionId?: string; index?: number }
280
+ | undefined;
281
+ const targetSectionId = overData?.sectionId ?? parseSectionId(overId);
282
+
283
+ if (!targetSectionId) {
284
+ if (cardDropIndicator) setCardDropIndicator(null);
285
+ return;
286
+ }
287
+
288
+ const targetSection = sections.find((section) => section.id === targetSectionId);
289
+ if (!targetSection) {
290
+ if (cardDropIndicator) setCardDropIndicator(null);
291
+ return;
292
+ }
293
+
294
+ let insertIndex = targetSection.items.length;
295
+ if (overData?.type === "card" && typeof overData.index === "number") {
296
+ const overIndex = overData.index;
297
+ if (
298
+ sourceSectionId === targetSectionId &&
299
+ typeof activeData.index === "number"
300
+ ) {
301
+ insertIndex = activeData.index < overIndex ? overIndex + 1 : overIndex;
302
+ } else {
303
+ const activeRect = event.active.rect.current.translated;
304
+ const activeMidY = activeRect
305
+ ? activeRect.top + activeRect.height / 2
306
+ : null;
307
+ const overMidY = over.rect.top + over.rect.height / 2;
308
+ insertIndex =
309
+ overIndex + (activeMidY !== null && activeMidY > overMidY ? 1 : 0);
310
+ }
311
+ } else if (overData?.type === "section") {
312
+ insertIndex = 0;
313
+ }
314
+
315
+ insertIndex = Math.max(0, Math.min(insertIndex, targetSection.items.length));
316
+
317
+ const isNoOpWithinSameSection =
318
+ sourceSectionId === targetSectionId &&
319
+ typeof activeData.index === "number" &&
320
+ (insertIndex === activeData.index || insertIndex === activeData.index + 1);
321
+
322
+ if (isNoOpWithinSameSection) {
323
+ if (cardDropIndicator) setCardDropIndicator(null);
324
+ return;
325
+ }
326
+
327
+ setCardDropIndicator((prev) => {
328
+ if (
329
+ prev &&
330
+ prev.sectionId === targetSectionId &&
331
+ prev.index === insertIndex
332
+ ) {
333
+ return prev;
334
+ }
335
+ return { sectionId: targetSectionId, index: insertIndex };
336
+ });
337
+ };
338
+
339
+ const handleDragEnd = (event: DragEndEvent) => {
340
+ const { active, over } = event;
341
+ const pendingSectionDropIndicator = sectionDropIndicator;
342
+ const pendingCardDropIndicator = cardDropIndicator;
343
+ setSectionDropIndicator(null);
344
+ setCardDropIndicator(null);
345
+ setDragState({ isDragging: false, dragType: null, activeId: null });
346
+
347
+ if (!over) return;
348
+
349
+ const activeId = active.id as string;
350
+ const overId = over.id as string;
351
+ const activeData = active.data.current as
352
+ | { type?: "section" | "card"; sectionId?: string; index?: number }
353
+ | undefined;
354
+ const overData = over.data.current as
355
+ | { type?: "section" | "card"; sectionId?: string; index?: number }
356
+ | undefined;
357
+
358
+ // Section drag
359
+ if (activeData?.type === "section" || activeId.startsWith(SECTION_PREFIX)) {
360
+ const activeSectionId = activeData?.sectionId ?? parseSectionId(activeId);
361
+ const overSectionId =
362
+ pendingSectionDropIndicator?.sectionId ??
363
+ overData?.sectionId ??
364
+ parseSectionId(overId);
365
+
366
+ if (
367
+ activeSectionId &&
368
+ overSectionId &&
369
+ activeSectionId !== overSectionId
370
+ ) {
371
+ const oldIndex = sections.findIndex((s) => s.id === activeSectionId);
372
+ const overIndex = sections.findIndex((s) => s.id === overSectionId);
373
+ let newIndex = overIndex;
374
+
375
+ if (pendingSectionDropIndicator?.position === "before") {
376
+ newIndex = overIndex - (oldIndex < overIndex ? 1 : 0);
377
+ } else if (pendingSectionDropIndicator?.position === "after") {
378
+ newIndex = overIndex + (oldIndex < overIndex ? 0 : 1);
379
+ }
380
+
381
+ const maxIndex = sections.length - 1;
382
+ newIndex = Math.max(0, Math.min(maxIndex, newIndex));
383
+
384
+ if (oldIndex !== -1 && newIndex !== -1) {
385
+ const newSections = arrayMove(sections, oldIndex, newIndex);
386
+ onSectionReorder?.(newSections);
387
+ }
388
+ }
389
+ return;
390
+ }
391
+
392
+ // Card drag
393
+ if (!activeData || activeData.type !== "card") return;
394
+
395
+ const sourceSectionId = activeData.sectionId;
396
+ if (!sourceSectionId) return;
397
+ const sourceSection = sections.find((s) => s.id === sourceSectionId);
398
+ if (!sourceSection) return;
399
+
400
+ let targetSectionId = sourceSectionId;
401
+ if (overData?.sectionId) {
402
+ targetSectionId = overData.sectionId;
403
+ } else {
404
+ const parsedSectionId = parseSectionId(overId);
405
+ if (parsedSectionId) {
406
+ targetSectionId = parsedSectionId;
407
+ }
408
+ }
409
+
410
+ if (sourceSectionId === targetSectionId) {
411
+ // Same section reorder
412
+ const oldIndex = sourceSection.items.findIndex(
413
+ (item) => getItemId(item) === activeId
414
+ );
415
+
416
+ let newIndex: number;
417
+ if (
418
+ pendingCardDropIndicator &&
419
+ pendingCardDropIndicator.sectionId === sourceSectionId
420
+ ) {
421
+ const insertSlot = Math.max(
422
+ 0,
423
+ Math.min(pendingCardDropIndicator.index, sourceSection.items.length)
424
+ );
425
+ newIndex = insertSlot > oldIndex ? insertSlot - 1 : insertSlot;
426
+ } else if (overData?.type === "card" && overData.index !== undefined) {
427
+ newIndex = overData.index;
428
+ } else {
429
+ const overItemIndex = sourceSection.items.findIndex(
430
+ (item) => getItemId(item) === overId
431
+ );
432
+ newIndex = overItemIndex !== -1 ? overItemIndex : sourceSection.items.length - 1;
433
+ }
434
+
435
+ if (oldIndex !== -1 && oldIndex !== newIndex) {
436
+ const newItems = arrayMove(sourceSection.items, oldIndex, newIndex);
437
+ onItemReorder?.(sourceSectionId, newItems);
438
+ }
439
+ } else {
440
+ // Cross-section move
441
+ const item = sourceSection.items.find((i) => getItemId(i) === activeId);
442
+ if (item) {
443
+ const targetIndex =
444
+ pendingCardDropIndicator?.sectionId === targetSectionId
445
+ ? pendingCardDropIndicator.index
446
+ : undefined;
447
+ onItemMoveToSection?.(item, sourceSectionId, targetSectionId, targetIndex);
448
+ }
449
+ }
450
+ };
451
+
452
+ const handleFilterChange = (field: string | null, value: string | null) => {
453
+ onFilterChange?.({ field, value });
454
+ };
455
+
456
+ const handleSortChange = (field: string | null) => {
457
+ if (!field) {
458
+ onSortChange?.({ field: null, direction: null });
459
+ return;
460
+ }
461
+ if (sort.field === field) {
462
+ if (sort.direction === "asc") {
463
+ onSortChange?.({ field, direction: "desc" });
464
+ } else if (sort.direction === "desc") {
465
+ onSortChange?.({ field: null, direction: null });
466
+ } else {
467
+ onSortChange?.({ field, direction: "asc" });
468
+ }
469
+ } else {
470
+ onSortChange?.({ field, direction: "asc" });
471
+ }
472
+ };
473
+
474
+ return (
475
+ <>
476
+ <div className="sectioned-list-board" data-testid="sectioned-list-board">
477
+ {/* Filter/Sort/Date Controls */}
478
+ {(filterOptions || sortOptions || onDateRangeChange) && (
479
+ <div className="board-controls" data-testid="board-controls">
480
+ {filterOptions && filterOptions.length > 0 && (
481
+ <div className="board-filter-controls">
482
+ <DropdownMenu>
483
+ <DropdownMenuTrigger asChild>
484
+ <button
485
+ className="board-dropdown-trigger"
486
+ data-testid="board-filter-select"
487
+ >
488
+ <Filter size={16} />
489
+ <span>
490
+ {filter.value
491
+ ? filterOptions.find((o) => o.value === filter.value)?.label ?? "Filter"
492
+ : "Filter"}
493
+ </span>
494
+ <ChevronDown size={14} />
495
+ </button>
496
+ </DropdownMenuTrigger>
497
+ <DropdownMenuContent align="start">
498
+ <DropdownMenuItem
499
+ onClick={() => handleFilterChange(null, null)}
500
+ className={!filter.value ? "bg-gray-100" : ""}
501
+ >
502
+ <span className="w-4 mr-2">
503
+ {!filter.value && <Check size={14} />}
504
+ </span>
505
+ All
506
+ </DropdownMenuItem>
507
+ <DropdownMenuSeparator />
508
+ {filterOptions.map((option) => (
509
+ <DropdownMenuItem
510
+ key={option.id}
511
+ onClick={() => handleFilterChange(option.field, option.value)}
512
+ className={filter.value === option.value ? "bg-gray-100" : ""}
513
+ >
514
+ <span className="w-4 mr-2">
515
+ {filter.value === option.value && <Check size={14} />}
516
+ </span>
517
+ {option.label}
518
+ </DropdownMenuItem>
519
+ ))}
520
+ </DropdownMenuContent>
521
+ </DropdownMenu>
522
+ </div>
523
+ )}
524
+
525
+ {sortOptions && sortOptions.length > 0 && (
526
+ <div className="board-sort-controls">
527
+ <DropdownMenu>
528
+ <DropdownMenuTrigger asChild>
529
+ <button
530
+ className="board-dropdown-trigger"
531
+ data-testid="board-sort-select"
532
+ >
533
+ <ArrowUpDown size={16} />
534
+ <span>
535
+ {sort.field
536
+ ? `${sortOptions.find((o) => o.field === sort.field)?.label ?? "Sort"}${sort.direction === "asc" ? " ↑" : sort.direction === "desc" ? " ↓" : ""}`
537
+ : "Sort"}
538
+ </span>
539
+ <ChevronDown size={14} />
540
+ </button>
541
+ </DropdownMenuTrigger>
542
+ <DropdownMenuContent align="start">
543
+ <DropdownMenuItem
544
+ onClick={() => handleSortChange(null)}
545
+ className={!sort.field ? "bg-gray-100" : ""}
546
+ >
547
+ <span className="w-4 mr-2">
548
+ {!sort.field && <Check size={14} />}
549
+ </span>
550
+ None
551
+ </DropdownMenuItem>
552
+ <DropdownMenuSeparator />
553
+ {sortOptions.map((option) => (
554
+ <DropdownMenuItem
555
+ key={option.id}
556
+ onClick={() => handleSortChange(option.field)}
557
+ className={sort.field === option.field ? "bg-gray-100" : ""}
558
+ >
559
+ <span className="w-4 mr-2">
560
+ {sort.field === option.field && <Check size={14} />}
561
+ </span>
562
+ {option.label}
563
+ {sort.field === option.field && sort.direction && (
564
+ <span className="ml-auto text-gray-500">
565
+ {sort.direction === "asc" ? "↑" : "↓"}
566
+ </span>
567
+ )}
568
+ </DropdownMenuItem>
569
+ ))}
570
+ </DropdownMenuContent>
571
+ </DropdownMenu>
572
+ </div>
573
+ )}
574
+
575
+ {onDateRangeChange && (
576
+ <div className="board-date-controls">
577
+ <DateRangeFilter
578
+ dateRange={dateRange}
579
+ onDateRangeChange={onDateRangeChange}
580
+ label={dateRangeLabel}
581
+ />
582
+ </div>
583
+ )}
584
+ </div>
585
+ )}
586
+
587
+ {/* Board Sections */}
588
+ <div className="board-sections-container">
589
+ <DndContext
590
+ id="board-dnd"
591
+ sensors={sensors}
592
+ collisionDetection={closestCenter}
593
+ onDragStart={handleDragStart}
594
+ onDragOver={handleDragOver}
595
+ onDragEnd={handleDragEnd}
596
+ onDragCancel={() => {
597
+ setSectionDropIndicator(null);
598
+ setCardDropIndicator(null);
599
+ setDragState({ isDragging: false, dragType: null, activeId: null });
600
+ }}
601
+ >
602
+ <SortableContext
603
+ items={sectionIds}
604
+ strategy={horizontalListSortingStrategy}
605
+ >
606
+ {sections.map((section) => (
607
+ <SortableBoardSection
608
+ key={section.id}
609
+ sortableId={`${SECTION_PREFIX}${section.id}`}
610
+ section={section}
611
+ getItemId={getItemId}
612
+ cardConfig={cardConfig}
613
+ onItemFieldChange={
614
+ onItemFieldChange
615
+ ? (itemId, field, value) =>
616
+ onItemFieldChange(section.id, itemId, field, value)
617
+ : undefined
618
+ }
619
+ isItemCompleted={isItemCompleted}
620
+ onToggle={() => onToggleSection?.(section.id)}
621
+ onRename={(name) => onRenameSection?.(section.id, name)}
622
+ onAddItem={() => onAddItem?.(section.id)}
623
+ onDelete={() => onDeleteSection?.(section.id)}
624
+ onItemTitleEdit={
625
+ onItemTitleEdit
626
+ ? (itemId, title) => onItemTitleEdit(section.id, itemId, title)
627
+ : undefined
628
+ }
629
+ onItemComplete={
630
+ onItemComplete
631
+ ? (itemId, completed) => onItemComplete(section.id, itemId, completed)
632
+ : undefined
633
+ }
634
+ onItemAction={
635
+ onItemAction
636
+ ? (itemId, action) => onItemAction(section.id, itemId, action)
637
+ : undefined
638
+ }
639
+ onItemOpen={handleItemOpen}
640
+ isDragDisabled={isCardDragDisabled}
641
+ collapseDuringDrag={
642
+ dragState.dragType === "section" &&
643
+ dragState.activeId === section.id
644
+ }
645
+ dropIndicatorPosition={
646
+ sectionDropIndicator?.sectionId === section.id
647
+ ? sectionDropIndicator.position
648
+ : null
649
+ }
650
+ cardDropIndicatorIndex={
651
+ cardDropIndicator?.sectionId === section.id
652
+ ? cardDropIndicator.index
653
+ : null
654
+ }
655
+ assignees={effectiveAssignees}
656
+ />
657
+ ))}
658
+ </SortableContext>
659
+
660
+ <DragOverlay>
661
+ {dragState.dragType === "section" && activeSection ? (
662
+ <div className="drag-overlay-section">
663
+ <div className="board-section-header">
664
+ <span className="drag-icon">&#x2847;</span>
665
+ <span className="section-name">{activeSection.name}</span>
666
+ <span className="section-count">
667
+ ({activeSection.items.length})
668
+ </span>
669
+ </div>
670
+ </div>
671
+ ) : null}
672
+
673
+ {dragState.dragType === "card" && activeCard ? (
674
+ <div className="board-card board-card-overlay">
675
+ {isItemCompleted && (
676
+ <div className="card-checkbox">
677
+ <input
678
+ type="checkbox"
679
+ checked={isItemCompleted(activeCard.item)}
680
+ readOnly
681
+ />
682
+ </div>
683
+ )}
684
+ <div className="card-content">
685
+ <BoardCardContent
686
+ item={activeCard.item}
687
+ cardConfig={cardConfig}
688
+ interactive={false}
689
+ />
690
+ </div>
691
+ </div>
692
+ ) : null}
693
+ </DragOverlay>
694
+ </DndContext>
695
+
696
+ {/* Add Section Button */}
697
+ <button
698
+ className="add-section-button"
699
+ onClick={() => onAddSection?.()}
700
+ data-testid="add-section-button"
701
+ >
702
+ <span className="add-section-icon">+</span>
703
+ <span>Add section</span>
704
+ </button>
705
+ </div>
706
+ </div>
707
+
708
+ {/* Built-in drawer for item details when renderItemDetails is provided */}
709
+ {renderItemDetails && (
710
+ <Drawer
711
+ open={selectedItem !== null}
712
+ onOpenChange={(open) => {
713
+ console.log("[SectionedListBoard] Drawer onOpenChange:", open);
714
+ if (!open) setSelectedItem(null);
715
+ }}
716
+ direction={drawerProps?.side ?? "right"}
717
+ >
718
+ <DrawerContent
719
+ className="!max-w-none"
720
+ style={{
721
+ width: drawerProps?.width ?? 400,
722
+ maxWidth: "100vw",
723
+ }}
724
+ >
725
+ {selectedItem && renderItemDetails(selectedItem)}
726
+ </DrawerContent>
727
+ </Drawer>
728
+ )}
729
+ </>
730
+ );
731
+ }