@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,467 @@
1
+ import * as React from "react";
2
+ import {
3
+ Check,
4
+ ThumbsUp,
5
+ Paperclip,
6
+ ListTodo,
7
+ Maximize2,
8
+ MoreHorizontal,
9
+ Plus,
10
+ ArrowRight,
11
+ Link as LinkIcon,
12
+ Pencil,
13
+ } from "lucide-react";
14
+ import {
15
+ Sheet,
16
+ SheetContent,
17
+ } from "@/components/ui/sheet";
18
+ import {
19
+ DropdownMenu,
20
+ DropdownMenuContent,
21
+ DropdownMenuItem,
22
+ DropdownMenuSeparator,
23
+ DropdownMenuTrigger,
24
+ } from "@/components/ui/dropdown-menu";
25
+ import { Button } from "@/components/ui/button";
26
+ import { Separator } from "@/components/ui/separator";
27
+ import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
28
+ import { Textarea } from "@/components/ui/textarea";
29
+ import {
30
+ Avatar,
31
+ AvatarImage,
32
+ AvatarFallback,
33
+ AvatarGroup,
34
+ } from "@/components/ui/avatar";
35
+ import "./section-drawer.css";
36
+
37
+ // ============================================================================
38
+ // Types
39
+ // ============================================================================
40
+
41
+ export interface SectionDrawerUser {
42
+ id: string;
43
+ name: string;
44
+ avatar?: string;
45
+ }
46
+
47
+ export interface SectionDrawerActivity {
48
+ id: string;
49
+ user: SectionDrawerUser;
50
+ action: string;
51
+ timestamp: string;
52
+ }
53
+
54
+ export interface SectionDrawerProps {
55
+ /** Whether the drawer is open */
56
+ open: boolean;
57
+ /** Callback when open state changes */
58
+ onOpenChange: (open: boolean) => void;
59
+ /** Drawer title */
60
+ title: string;
61
+ /** Whether the item is marked complete */
62
+ isCompleted?: boolean;
63
+ /** Which side the drawer opens from */
64
+ side?: "right" | "left";
65
+ /** Drawer width */
66
+ width?: number | string;
67
+ /** Domain-specific content to render in the main area */
68
+ children: React.ReactNode;
69
+ /** Activity items to display */
70
+ activities?: SectionDrawerActivity[];
71
+ /** Collaborators to display in footer */
72
+ collaborators?: SectionDrawerUser[];
73
+ /** Current user for comment input */
74
+ currentUser?: SectionDrawerUser;
75
+ /** Callback when mark complete is toggled */
76
+ onMarkComplete?: (completed: boolean) => void;
77
+ /** Callback when title is changed (enables title editing when provided) */
78
+ onTitleChange?: (title: string) => void;
79
+ /** Callback when a comment is added */
80
+ onAddComment?: (comment: string) => void;
81
+ /** Callback when attachment button is clicked */
82
+ onAddAttachment?: () => void;
83
+ /** Callback when leave task is clicked */
84
+ onLeaveTask?: () => void;
85
+ /** Callback when copy link is clicked */
86
+ onCopyLink?: () => void;
87
+ /** Callback when fullscreen is clicked */
88
+ onFullScreen?: () => void;
89
+ /** Whether to show the activity section (default: true) */
90
+ showActivity?: boolean;
91
+ /** Whether to show the mark complete button (default: true) */
92
+ showMarkComplete?: boolean;
93
+ /** Callback when delete is clicked */
94
+ onDelete?: () => void;
95
+ /** Callback when duplicate is clicked */
96
+ onDuplicate?: () => void;
97
+ /** Callback when print is clicked */
98
+ onPrint?: () => void;
99
+ /** Callback when add collaborator is clicked */
100
+ onAddCollaborator?: () => void;
101
+ /** Callback when like/thumbs up is clicked */
102
+ onLike?: () => void;
103
+ /** Callback when subtasks header icon is clicked */
104
+ onSubtasksClick?: () => void;
105
+ /** Callback when sort order is toggled */
106
+ onSortToggle?: () => void;
107
+ /** Current sort order for activities */
108
+ sortOrder?: "oldest" | "newest";
109
+ }
110
+
111
+ // ============================================================================
112
+ // Helpers
113
+ // ============================================================================
114
+
115
+ function getInitials(name: string): string {
116
+ return name
117
+ .split(" ")
118
+ .map((n) => n[0])
119
+ .join("")
120
+ .toUpperCase()
121
+ .slice(0, 2);
122
+ }
123
+
124
+ // ============================================================================
125
+ // SectionDrawer Component (Shell)
126
+ // ============================================================================
127
+
128
+ export function SectionDrawer({
129
+ open,
130
+ onOpenChange,
131
+ title,
132
+ isCompleted = false,
133
+ side = "right",
134
+ width = 500,
135
+ children,
136
+ activities = [],
137
+ collaborators = [],
138
+ currentUser,
139
+ onMarkComplete,
140
+ onTitleChange,
141
+ onAddComment,
142
+ onAddAttachment,
143
+ onLeaveTask,
144
+ onCopyLink,
145
+ onFullScreen,
146
+ showActivity = true,
147
+ showMarkComplete = true,
148
+ onDelete,
149
+ onDuplicate,
150
+ onPrint,
151
+ onAddCollaborator,
152
+ onLike,
153
+ onSubtasksClick,
154
+ onSortToggle,
155
+ sortOrder = "oldest",
156
+ }: SectionDrawerProps) {
157
+ // Title editing state
158
+ const [isEditingTitle, setIsEditingTitle] = React.useState(false);
159
+ const [editedTitle, setEditedTitle] = React.useState(title);
160
+ const titleInputRef = React.useRef<HTMLInputElement>(null);
161
+
162
+ // Comment state
163
+ const [comment, setComment] = React.useState("");
164
+
165
+ React.useEffect(() => {
166
+ setEditedTitle(title);
167
+ }, [title]);
168
+
169
+ React.useEffect(() => {
170
+ if (isEditingTitle && titleInputRef.current) {
171
+ titleInputRef.current.focus();
172
+ titleInputRef.current.select();
173
+ }
174
+ }, [isEditingTitle]);
175
+
176
+ const handleTitleDoubleClick = () => {
177
+ if (onTitleChange) {
178
+ setIsEditingTitle(true);
179
+ }
180
+ };
181
+
182
+ const handleTitleBlur = () => {
183
+ setIsEditingTitle(false);
184
+ if (editedTitle.trim() && editedTitle !== title) {
185
+ onTitleChange?.(editedTitle.trim());
186
+ }
187
+ };
188
+
189
+ const handleTitleKeyDown = (e: React.KeyboardEvent) => {
190
+ if (e.key === "Enter") {
191
+ handleTitleBlur();
192
+ } else if (e.key === "Escape") {
193
+ setIsEditingTitle(false);
194
+ setEditedTitle(title);
195
+ }
196
+ };
197
+
198
+ const handleCommentSubmit = () => {
199
+ if (comment.trim()) {
200
+ onAddComment?.(comment.trim());
201
+ setComment("");
202
+ }
203
+ };
204
+
205
+ const handleCopyLink = () => {
206
+ if (onCopyLink) {
207
+ onCopyLink();
208
+ } else if (typeof navigator !== "undefined" && navigator.clipboard) {
209
+ void navigator.clipboard.writeText(window.location.href);
210
+ }
211
+ };
212
+
213
+ return (
214
+ <Sheet open={open} onOpenChange={onOpenChange}>
215
+ <SheetContent
216
+ side={side}
217
+ showCloseButton={false}
218
+ className="section-drawer !max-w-none p-0 flex flex-col gap-0"
219
+ style={{ width, maxWidth: "100vw" }}
220
+ >
221
+ {/* Header */}
222
+ <div className="section-drawer-header">
223
+ <div className="section-drawer-header-actions">
224
+ {showMarkComplete && onMarkComplete && (
225
+ <Button
226
+ variant={isCompleted ? "default" : "outline"}
227
+ size="sm"
228
+ onClick={() => onMarkComplete(!isCompleted)}
229
+ className="section-drawer-complete-btn"
230
+ >
231
+ <Check className="size-4" />
232
+ <span>{isCompleted ? "Completed" : "Mark complete"}</span>
233
+ </Button>
234
+ )}
235
+ {!showMarkComplete && <div />}
236
+
237
+ <div className="section-drawer-header-icons">
238
+ <Button variant="ghost" size="icon-sm" title="Like" onClick={onLike}>
239
+ <ThumbsUp className="size-4" />
240
+ </Button>
241
+ {onAddAttachment && (
242
+ <Button variant="ghost" size="icon-sm" title="Add attachment" onClick={onAddAttachment}>
243
+ <Paperclip className="size-4" />
244
+ </Button>
245
+ )}
246
+ <Button variant="ghost" size="icon-sm" title="Subtasks" onClick={onSubtasksClick}>
247
+ <ListTodo className="size-4" />
248
+ </Button>
249
+ <Button variant="ghost" size="icon-sm" title="Copy link" onClick={handleCopyLink}>
250
+ <LinkIcon className="size-4" />
251
+ </Button>
252
+ {onFullScreen && (
253
+ <Button variant="ghost" size="icon-sm" title="Full screen" onClick={onFullScreen}>
254
+ <Maximize2 className="size-4" />
255
+ </Button>
256
+ )}
257
+ <DropdownMenu>
258
+ <DropdownMenuTrigger asChild>
259
+ <Button variant="ghost" size="icon-sm" title="More actions">
260
+ <MoreHorizontal className="size-4" />
261
+ </Button>
262
+ </DropdownMenuTrigger>
263
+ <DropdownMenuContent align="end">
264
+ {onDuplicate && (
265
+ <DropdownMenuItem onClick={onDuplicate}>
266
+ Duplicate
267
+ </DropdownMenuItem>
268
+ )}
269
+ {onPrint && (
270
+ <DropdownMenuItem onClick={onPrint}>
271
+ Print
272
+ </DropdownMenuItem>
273
+ )}
274
+ <DropdownMenuItem onClick={handleCopyLink}>
275
+ Copy link
276
+ </DropdownMenuItem>
277
+ {onDelete && (
278
+ <>
279
+ <DropdownMenuSeparator />
280
+ <DropdownMenuItem onClick={onDelete} className="text-destructive">
281
+ Delete
282
+ </DropdownMenuItem>
283
+ </>
284
+ )}
285
+ </DropdownMenuContent>
286
+ </DropdownMenu>
287
+ <Button
288
+ variant="ghost"
289
+ size="icon-sm"
290
+ onClick={() => onOpenChange(false)}
291
+ title="Close"
292
+ >
293
+ <ArrowRight className="size-4" />
294
+ </Button>
295
+ </div>
296
+ </div>
297
+ </div>
298
+
299
+ <Separator />
300
+
301
+ {/* Main Content - Scrollable */}
302
+ <div className="section-drawer-content">
303
+ {/* Title */}
304
+ <div className="section-drawer-title-section">
305
+ {isEditingTitle ? (
306
+ <input
307
+ ref={titleInputRef}
308
+ type="text"
309
+ value={editedTitle}
310
+ onChange={(e) => setEditedTitle(e.target.value)}
311
+ onBlur={handleTitleBlur}
312
+ onKeyDown={handleTitleKeyDown}
313
+ className="section-drawer-title-input"
314
+ />
315
+ ) : (
316
+ <div className="section-drawer-title-wrapper">
317
+ <h2
318
+ className="section-drawer-title"
319
+ onClick={onTitleChange ? handleTitleDoubleClick : undefined}
320
+ title={onTitleChange ? "Click to edit" : undefined}
321
+ style={{ cursor: onTitleChange ? "text" : "default" }}
322
+ >
323
+ {title}
324
+ </h2>
325
+ {onTitleChange && (
326
+ <Button
327
+ variant="ghost"
328
+ size="icon-xs"
329
+ className="section-drawer-title-edit-btn"
330
+ onClick={handleTitleDoubleClick}
331
+ title="Edit title"
332
+ >
333
+ <Pencil className="size-3" />
334
+ </Button>
335
+ )}
336
+ </div>
337
+ )}
338
+ </div>
339
+
340
+ {/* Domain-specific content slot */}
341
+ {children}
342
+ </div>
343
+
344
+ {/* Activity Section - Fixed at bottom */}
345
+ {showActivity && (
346
+ <div className="section-drawer-activity">
347
+ <Separator />
348
+
349
+ {/* Tabs */}
350
+ <Tabs defaultValue="comments" className="section-drawer-tabs">
351
+ <div className="section-drawer-tabs-header">
352
+ <TabsList variant="line">
353
+ <TabsTrigger value="comments">Comments</TabsTrigger>
354
+ <TabsTrigger value="activity">All activity</TabsTrigger>
355
+ </TabsList>
356
+ <Button variant="ghost" size="xs" className="section-drawer-sort-btn" onClick={onSortToggle}>
357
+ ↕ {sortOrder === "oldest" ? "Oldest" : "Newest"}
358
+ </Button>
359
+ </div>
360
+
361
+ <TabsContent value="comments" className="section-drawer-tabs-content">
362
+ <div className="section-drawer-activity-list">
363
+ {activities.map((activity) => (
364
+ <div key={activity.id} className="section-drawer-activity-item">
365
+ <Avatar size="sm">
366
+ {activity.user.avatar ? (
367
+ <AvatarImage src={activity.user.avatar} alt={activity.user.name} />
368
+ ) : null}
369
+ <AvatarFallback>{getInitials(activity.user.name)}</AvatarFallback>
370
+ </Avatar>
371
+ <div className="section-drawer-activity-content">
372
+ <span className="font-medium">{activity.user.name}</span>
373
+ <span className="text-muted-foreground"> {activity.action}</span>
374
+ <span className="text-muted-foreground"> · {activity.timestamp}</span>
375
+ </div>
376
+ </div>
377
+ ))}
378
+ </div>
379
+ </TabsContent>
380
+
381
+ <TabsContent value="activity" className="section-drawer-tabs-content">
382
+ <div className="section-drawer-activity-list">
383
+ {activities.map((activity) => (
384
+ <div key={activity.id} className="section-drawer-activity-item">
385
+ <Avatar size="sm">
386
+ {activity.user.avatar ? (
387
+ <AvatarImage src={activity.user.avatar} alt={activity.user.name} />
388
+ ) : null}
389
+ <AvatarFallback>{getInitials(activity.user.name)}</AvatarFallback>
390
+ </Avatar>
391
+ <div className="section-drawer-activity-content">
392
+ <span className="font-medium">{activity.user.name}</span>
393
+ <span className="text-muted-foreground"> {activity.action}</span>
394
+ <span className="text-muted-foreground"> · {activity.timestamp}</span>
395
+ </div>
396
+ </div>
397
+ ))}
398
+ </div>
399
+ </TabsContent>
400
+ </Tabs>
401
+
402
+ {/* Comment Input */}
403
+ {onAddComment && (
404
+ <div className="section-drawer-comment-input">
405
+ <Avatar size="sm">
406
+ {currentUser?.avatar ? (
407
+ <AvatarImage src={currentUser.avatar} alt={currentUser.name} />
408
+ ) : null}
409
+ <AvatarFallback>
410
+ {currentUser ? getInitials(currentUser.name) : "?"}
411
+ </AvatarFallback>
412
+ </Avatar>
413
+ <Textarea
414
+ placeholder="Add a comment"
415
+ value={comment}
416
+ onChange={(e) => setComment(e.target.value)}
417
+ onKeyDown={(e) => {
418
+ if (e.key === "Enter" && !e.shiftKey) {
419
+ e.preventDefault();
420
+ handleCommentSubmit();
421
+ }
422
+ }}
423
+ className="section-drawer-comment-textarea"
424
+ />
425
+ </div>
426
+ )}
427
+
428
+ {/* Footer */}
429
+ <div className="section-drawer-footer">
430
+ <div className="section-drawer-collaborators">
431
+ <span className="section-drawer-collaborators-label">Collaborators</span>
432
+ <AvatarGroup>
433
+ {collaborators.slice(0, 3).map((collab) => (
434
+ <Avatar key={collab.id} size="sm">
435
+ {collab.avatar ? (
436
+ <AvatarImage src={collab.avatar} alt={collab.name} />
437
+ ) : null}
438
+ <AvatarFallback>{getInitials(collab.name)}</AvatarFallback>
439
+ </Avatar>
440
+ ))}
441
+ </AvatarGroup>
442
+ <Button
443
+ variant="ghost"
444
+ size="icon-xs"
445
+ onClick={onAddCollaborator}
446
+ title="Add collaborator"
447
+ >
448
+ <Plus className="size-3" />
449
+ </Button>
450
+ </div>
451
+ {onLeaveTask && (
452
+ <Button
453
+ variant="ghost"
454
+ size="sm"
455
+ onClick={onLeaveTask}
456
+ className="section-drawer-leave-btn"
457
+ >
458
+ 🔔 Leave task
459
+ </Button>
460
+ )}
461
+ </div>
462
+ </div>
463
+ )}
464
+ </SheetContent>
465
+ </Sheet>
466
+ );
467
+ }
@@ -0,0 +1,78 @@
1
+ # Sectioned List Board
2
+
3
+ Data-driven, reusable kanban-style board with draggable sections and cards.
4
+
5
+ ## Goal
6
+
7
+ `SectionedListBoard` is **bring your own data only**:
8
+ - Consumers provide `sections`, identity, and mutation handlers.
9
+ - Consumers configure card rendering with `cardConfig`.
10
+ - Consumers do **not** provide card JSX/render functions.
11
+
12
+ ## Usage
13
+
14
+ ```tsx
15
+ import {
16
+ SectionedListBoard,
17
+ type BoardSection,
18
+ type BoardCardConfig,
19
+ type SortState,
20
+ type FilterState,
21
+ } from "~/components/shared/sectioned-list-board";
22
+
23
+ type Deal = {
24
+ id: string;
25
+ name: string;
26
+ assignee: string;
27
+ dueDate: string;
28
+ status: "incomplete" | "complete";
29
+ priority: "High" | "Medium" | "Low";
30
+ };
31
+
32
+ const cardConfig: BoardCardConfig<Deal> = {
33
+ titleField: "name",
34
+ badgeField: {
35
+ field: "priority",
36
+ colorMap: { High: "red", Medium: "blue", Low: "green" },
37
+ },
38
+ avatarField: { field: "assignee" },
39
+ dateField: { field: "dueDate", editable: true },
40
+ };
41
+
42
+ function ExampleBoard({ sections }: { sections: BoardSection<Deal>[] }) {
43
+ const [sort, setSort] = React.useState<SortState>({ field: null, direction: null });
44
+ const [filter, setFilter] = React.useState<FilterState>({ field: null, value: null });
45
+
46
+ return (
47
+ <SectionedListBoard
48
+ sections={sections}
49
+ getItemId={(item) => item.id}
50
+ cardConfig={cardConfig}
51
+ isItemCompleted={(item) => item.status === "complete"}
52
+ onItemFieldChange={(sectionId, itemId, field, value) => {
53
+ // Persist field updates (e.g. dueDate changes)
54
+ }}
55
+ onItemTitleEdit={(sectionId, itemId, title) => {
56
+ // Persist title updates
57
+ }}
58
+ sort={sort}
59
+ filter={filter}
60
+ onSortChange={setSort}
61
+ onFilterChange={setFilter}
62
+ />
63
+ );
64
+ }
65
+ ```
66
+
67
+ ## Card Config
68
+
69
+ - `titleField`: Primary card text.
70
+ - `badgeField`: Optional badge from a data field, with optional color mapping.
71
+ - `avatarField`: Optional initials avatar from a data field.
72
+ - `dateField`: Optional date field; if `editable: true`, date picker is enabled.
73
+
74
+ ## Notes
75
+
76
+ - Dragging cards is disabled when sort or filter is active.
77
+ - Inline title editing uses `onItemTitleEdit`.
78
+ - Date edits use `onItemFieldChange`.