@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,120 @@
1
+ import * as React from "react";
2
+ import { useSortable } from "@dnd-kit/sortable";
3
+ import { CSS } from "@dnd-kit/utilities";
4
+ import type { ColumnDefinition, SortState } from "./types";
5
+
6
+ interface SortableColumnHeaderProps<T> {
7
+ column: ColumnDefinition<T>;
8
+ sort: SortState;
9
+ onSort: (columnId: string) => void;
10
+ onResize: (width: number) => void;
11
+ }
12
+
13
+ export function SortableColumnHeader<T>({
14
+ column,
15
+ sort,
16
+ onSort,
17
+ onResize,
18
+ }: SortableColumnHeaderProps<T>) {
19
+ const [isResizing, setIsResizing] = React.useState(false);
20
+ const [startX, setStartX] = React.useState(0);
21
+ const [startWidth, setStartWidth] = React.useState(column.width ?? 150);
22
+ const headerRef = React.useRef<HTMLDivElement>(null);
23
+
24
+ const {
25
+ attributes,
26
+ listeners,
27
+ setNodeRef,
28
+ transform,
29
+ transition,
30
+ isDragging,
31
+ } = useSortable({ id: column.id });
32
+
33
+ const style: React.CSSProperties = {
34
+ transform: CSS.Transform.toString(transform),
35
+ transition,
36
+ opacity: isDragging ? 0.5 : 1,
37
+ width: column.width,
38
+ minWidth: column.minWidth ?? 80,
39
+ };
40
+
41
+ const isSorted = sort.columnId === column.id;
42
+ const sortDirection = isSorted ? sort.direction : null;
43
+
44
+ const handleSortClick = () => {
45
+ if (column.sortable) {
46
+ onSort(column.id);
47
+ }
48
+ };
49
+
50
+ const handleResizeStart = (e: React.MouseEvent) => {
51
+ e.preventDefault();
52
+ e.stopPropagation();
53
+ setIsResizing(true);
54
+ setStartX(e.clientX);
55
+ setStartWidth(headerRef.current?.offsetWidth ?? column.width ?? 150);
56
+ };
57
+
58
+ React.useEffect(() => {
59
+ if (!isResizing) return;
60
+
61
+ const handleMouseMove = (e: MouseEvent) => {
62
+ const diff = e.clientX - startX;
63
+ const newWidth = Math.max(80, startWidth + diff);
64
+ onResize(newWidth);
65
+ };
66
+
67
+ const handleMouseUp = () => {
68
+ setIsResizing(false);
69
+ };
70
+
71
+ document.addEventListener("mousemove", handleMouseMove);
72
+ document.addEventListener("mouseup", handleMouseUp);
73
+
74
+ return () => {
75
+ document.removeEventListener("mousemove", handleMouseMove);
76
+ document.removeEventListener("mouseup", handleMouseUp);
77
+ };
78
+ }, [isResizing, startX, startWidth, onResize]);
79
+
80
+ return (
81
+ <div
82
+ ref={(node) => {
83
+ setNodeRef(node);
84
+ (headerRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
85
+ }}
86
+ style={style}
87
+ className={`column-header ${isDragging ? "dragging" : ""} ${isSorted ? "sorted" : ""}`}
88
+ data-testid={`column-header-${column.id}`}
89
+ >
90
+ <div
91
+ className="column-header-content"
92
+ {...attributes}
93
+ {...listeners}
94
+ >
95
+ <span
96
+ className={`column-header-text ${column.sortable ? "sortable" : ""}`}
97
+ onClick={handleSortClick}
98
+ >
99
+ {column.header}
100
+ {column.sortable && (
101
+ <span className="sort-indicator" data-testid={`sort-indicator-${column.id}`}>
102
+ {sortDirection === "asc" && " ↑"}
103
+ {sortDirection === "desc" && " ↓"}
104
+ {!sortDirection && " ↕"}
105
+ </span>
106
+ )}
107
+ </span>
108
+ </div>
109
+
110
+ {/* Resize Handle */}
111
+ {column.resizable !== false && (
112
+ <div
113
+ className="column-resize-handle"
114
+ onMouseDown={handleResizeStart}
115
+ data-testid={`column-resize-${column.id}`}
116
+ />
117
+ )}
118
+ </div>
119
+ );
120
+ }
@@ -0,0 +1,420 @@
1
+ import * as React from "react";
2
+ import { useSortable } from "@dnd-kit/sortable";
3
+ import {
4
+ ContextMenu,
5
+ ContextMenuContent,
6
+ ContextMenuItem,
7
+ ContextMenuSeparator,
8
+ ContextMenuTrigger,
9
+ } from "@/components/ui/context-menu";
10
+ import {
11
+ Select,
12
+ SelectContent,
13
+ SelectItem,
14
+ SelectTrigger,
15
+ SelectValue,
16
+ } from "@/components/ui/select";
17
+ import type { Assignee, ColumnDefinition, TableTaskAction } from "./types";
18
+ import { TableCellContent, getEditableCellValue } from "./table-cell-content";
19
+
20
+ interface SortableRowProps<T> {
21
+ item: T;
22
+ itemId: string;
23
+ index: number;
24
+ columns: ColumnDefinition<T>[];
25
+ sectionId: string;
26
+ onCellEdit: (columnId: string, value: unknown) => void;
27
+ onComplete: (completed: boolean) => void;
28
+ onTaskAction?: (action: TableTaskAction) => void;
29
+ /** Callback fired when the item is opened (click, context menu, or keyboard) */
30
+ onItemOpen?: (source: "click" | "context-menu" | "keyboard") => void;
31
+ isCompleted: boolean;
32
+ isDragDisabled: boolean;
33
+ /** List of available assignees for the assignee selector */
34
+ assignees?: Assignee[];
35
+ }
36
+
37
+ export function SortableRow<T>({
38
+ item,
39
+ itemId,
40
+ index,
41
+ columns,
42
+ sectionId,
43
+ onCellEdit,
44
+ onComplete,
45
+ onTaskAction,
46
+ onItemOpen,
47
+ isCompleted,
48
+ isDragDisabled,
49
+ assignees,
50
+ }: SortableRowProps<T>) {
51
+ const justDragged = React.useRef(false);
52
+ const clickOpenTimeoutRef = React.useRef<number | null>(null);
53
+ const {
54
+ attributes,
55
+ listeners,
56
+ setNodeRef,
57
+ transform,
58
+ transition,
59
+ isDragging,
60
+ } = useSortable({
61
+ id: itemId,
62
+ disabled: isDragDisabled,
63
+ data: { type: "row", sectionId, index },
64
+ });
65
+
66
+ // Track when drag ends to prevent click from firing
67
+ React.useEffect(() => {
68
+ if (!isDragging && justDragged.current === false) {
69
+ // Not dragging and wasn't dragging
70
+ } else if (isDragging) {
71
+ justDragged.current = true;
72
+ } else if (!isDragging && justDragged.current) {
73
+ // Drag just ended, keep flag true briefly
74
+ const timeout = setTimeout(() => {
75
+ justDragged.current = false;
76
+ }, 100);
77
+ return () => clearTimeout(timeout);
78
+ }
79
+ }, [isDragging]);
80
+
81
+ const style: React.CSSProperties = {
82
+ transform: transform
83
+ ? `translate3d(${transform.x}px, ${transform.y}px, 0)`
84
+ : undefined,
85
+ transition,
86
+ opacity: isDragging ? 0.5 : 1,
87
+ };
88
+
89
+ const taskLink = React.useMemo(() => {
90
+ if (typeof window === "undefined") return null;
91
+ const url = new URL(window.location.href);
92
+ url.hash = `task-${itemId}`;
93
+ return url.toString();
94
+ }, [itemId]);
95
+
96
+ const clearPendingItemOpen = React.useCallback(() => {
97
+ if (clickOpenTimeoutRef.current !== null) {
98
+ window.clearTimeout(clickOpenTimeoutRef.current);
99
+ clickOpenTimeoutRef.current = null;
100
+ }
101
+ }, []);
102
+
103
+ React.useEffect(() => {
104
+ return () => {
105
+ clearPendingItemOpen();
106
+ };
107
+ }, [clearPendingItemOpen]);
108
+
109
+ // Handle click to open item details
110
+ const handleClick = (e: React.MouseEvent) => {
111
+ // Don't open if we just finished dragging
112
+ if (justDragged.current) {
113
+ return;
114
+ }
115
+
116
+ // Don't open if clicking interactive elements
117
+ const target = e.target as HTMLElement;
118
+ if (
119
+ target.closest("[data-no-item-open]") ||
120
+ target.closest("input") ||
121
+ target.closest("button") ||
122
+ target.closest('[role="checkbox"]') ||
123
+ target.closest('[data-slot="select"]') ||
124
+ target.closest('[data-slot="popover"]') ||
125
+ target.closest('[role="combobox"]') ||
126
+ target.closest(".row-drag-handle") ||
127
+ target.closest(".row-checkbox-cell")
128
+ ) {
129
+ return;
130
+ }
131
+
132
+ // If this click is part of a double-click sequence, keep inline edit behavior.
133
+ if (e.detail > 1) {
134
+ clearPendingItemOpen();
135
+ return;
136
+ }
137
+
138
+ // Editable cells support double-click inline edit; delay open slightly so edit can win.
139
+ if (target.closest("[data-editable-cell]")) {
140
+ clearPendingItemOpen();
141
+ clickOpenTimeoutRef.current = window.setTimeout(() => {
142
+ onItemOpen?.("click");
143
+ clickOpenTimeoutRef.current = null;
144
+ }, 220);
145
+ return;
146
+ }
147
+
148
+ clearPendingItemOpen();
149
+ onItemOpen?.("click");
150
+ };
151
+
152
+ const handleRowDoubleClick = () => {
153
+ clearPendingItemOpen();
154
+ };
155
+
156
+ // Handle keyboard to open item details
157
+ const handleRowKeyDown = (e: React.KeyboardEvent) => {
158
+ // Only handle Enter when not on interactive elements
159
+ if (e.key === "Enter") {
160
+ const target = e.target as HTMLElement;
161
+ if (
162
+ target.closest("input") ||
163
+ target.closest("button") ||
164
+ target.closest('[role="checkbox"]')
165
+ ) {
166
+ return;
167
+ }
168
+ e.preventDefault();
169
+ onItemOpen?.("keyboard");
170
+ }
171
+ };
172
+
173
+ const handleTaskAction = (action: TableTaskAction) => {
174
+ onTaskAction?.(action);
175
+
176
+ if (action === "markComplete") {
177
+ onComplete(!isCompleted);
178
+ return;
179
+ }
180
+
181
+ if (action === "copyTaskLink") {
182
+ if (taskLink && typeof navigator !== "undefined" && navigator.clipboard) {
183
+ void navigator.clipboard.writeText(taskLink);
184
+ }
185
+ return;
186
+ }
187
+
188
+ if (action === "openTaskDetails") {
189
+ // Use callback if provided, otherwise fall back to hash navigation
190
+ if (onItemOpen) {
191
+ onItemOpen("context-menu");
192
+ } else if (taskLink && typeof window !== "undefined") {
193
+ window.location.hash = `task-${itemId}`;
194
+ }
195
+ return;
196
+ }
197
+
198
+ if (action === "openInNewTab") {
199
+ if (taskLink && typeof window !== "undefined") {
200
+ window.open(taskLink, "_blank", "noopener,noreferrer");
201
+ }
202
+ }
203
+ };
204
+
205
+ return (
206
+ <ContextMenu>
207
+ <ContextMenuTrigger asChild>
208
+ <div
209
+ ref={setNodeRef}
210
+ style={style}
211
+ className={`sectioned-list-row ${isCompleted ? "completed" : ""} ${isDragging ? "dragging" : ""}`}
212
+ data-testid={`row-${itemId}`}
213
+ onClick={handleClick}
214
+ onDoubleClick={handleRowDoubleClick}
215
+ onKeyDown={handleRowKeyDown}
216
+ tabIndex={0}
217
+ role="button"
218
+ >
219
+ {/* Drag Handle */}
220
+ {!isDragDisabled ? (
221
+ <button
222
+ className="row-drag-handle"
223
+ {...attributes}
224
+ {...listeners}
225
+ data-testid={`row-drag-handle-${itemId}`}
226
+ title="Drag to reorder"
227
+ >
228
+ <span className="drag-icon">⠿</span>
229
+ </button>
230
+ ) : (
231
+ <div
232
+ className="row-drag-handle disabled"
233
+ title="Clear sort/filter to reorder"
234
+ data-testid={`row-drag-handle-disabled-${itemId}`}
235
+ >
236
+ <span className="drag-icon">⠿</span>
237
+ </div>
238
+ )}
239
+
240
+ {/* Checkbox */}
241
+ <div className="row-checkbox-cell">
242
+ <input
243
+ type="checkbox"
244
+ checked={isCompleted}
245
+ onChange={(e) => onComplete(e.target.checked)}
246
+ data-testid={`row-checkbox-${itemId}`}
247
+ />
248
+ </div>
249
+
250
+ {/* Data Cells */}
251
+ {columns.map((column) => (
252
+ <EditableCell
253
+ key={column.id}
254
+ column={column}
255
+ item={item}
256
+ rowIndex={index}
257
+ onEdit={(value) => onCellEdit(column.field, value)}
258
+ onFieldChange={(field, value) => onCellEdit(field, value)}
259
+ itemId={itemId}
260
+ assignees={assignees}
261
+ />
262
+ ))}
263
+ </div>
264
+ </ContextMenuTrigger>
265
+ <ContextMenuContent data-testid={`row-context-menu-${itemId}`}>
266
+ <ContextMenuItem
267
+ data-testid={`row-duplicate-${itemId}`}
268
+ onSelect={() => handleTaskAction("duplicate")}
269
+ >
270
+ Duplicate Task
271
+ </ContextMenuItem>
272
+ <ContextMenuItem
273
+ data-testid={`row-mark-complete-${itemId}`}
274
+ onSelect={() => handleTaskAction("markComplete")}
275
+ >
276
+ Mark Complete
277
+ </ContextMenuItem>
278
+ <ContextMenuItem
279
+ data-testid={`row-add-subtask-${itemId}`}
280
+ onSelect={() => handleTaskAction("addSubtask")}
281
+ >
282
+ Add Subtask
283
+ </ContextMenuItem>
284
+ <ContextMenuItem
285
+ data-testid={`row-open-details-${itemId}`}
286
+ onSelect={() => handleTaskAction("openTaskDetails")}
287
+ >
288
+ Open Task Details
289
+ </ContextMenuItem>
290
+ <ContextMenuItem
291
+ data-testid={`row-open-new-tab-${itemId}`}
292
+ onSelect={() => handleTaskAction("openInNewTab")}
293
+ >
294
+ Open in new Tab
295
+ </ContextMenuItem>
296
+ <ContextMenuItem
297
+ data-testid={`row-copy-link-${itemId}`}
298
+ onSelect={() => handleTaskAction("copyTaskLink")}
299
+ >
300
+ Copy Task Link
301
+ </ContextMenuItem>
302
+ <ContextMenuSeparator />
303
+ <ContextMenuItem
304
+ data-testid={`row-delete-${itemId}`}
305
+ variant="destructive"
306
+ onSelect={() => handleTaskAction("delete")}
307
+ >
308
+ Delete Task
309
+ </ContextMenuItem>
310
+ </ContextMenuContent>
311
+ </ContextMenu>
312
+ );
313
+ }
314
+
315
+ interface EditableCellProps<T> {
316
+ column: ColumnDefinition<T>;
317
+ item: T;
318
+ rowIndex: number;
319
+ onEdit: (value: unknown) => void;
320
+ onFieldChange?: (field: string, value: unknown) => void;
321
+ itemId: string;
322
+ assignees?: Assignee[];
323
+ }
324
+
325
+ function EditableCell<T>({
326
+ column,
327
+ item,
328
+ rowIndex,
329
+ onEdit,
330
+ onFieldChange,
331
+ itemId,
332
+ assignees,
333
+ }: EditableCellProps<T>) {
334
+ const [isEditing, setIsEditing] = React.useState(false);
335
+ const [editValue, setEditValue] = React.useState("");
336
+ const inputRef = React.useRef<HTMLInputElement>(null);
337
+ const cellValue = getEditableCellValue(column, item);
338
+ const priorityOptions =
339
+ column.type === "badge" &&
340
+ column.field === "priority" &&
341
+ column.badgeColorMap
342
+ ? Object.keys(column.badgeColorMap)
343
+ : [];
344
+ const isPriorityDropdown = priorityOptions.length > 0;
345
+ const isEditable =
346
+ (column.type === undefined || column.type === "text") &&
347
+ column.editable !== false;
348
+
349
+ const handleDoubleClick = () => {
350
+ if (!isEditable) {
351
+ return;
352
+ }
353
+ setEditValue(cellValue);
354
+ setIsEditing(true);
355
+ setTimeout(() => inputRef.current?.focus(), 0);
356
+ };
357
+
358
+ const handleBlur = () => {
359
+ setIsEditing(false);
360
+ // Only update if value actually changed
361
+ if (editValue !== cellValue) {
362
+ onEdit(editValue);
363
+ }
364
+ };
365
+
366
+ const handleKeyDown = (e: React.KeyboardEvent) => {
367
+ if (e.key === "Enter") {
368
+ handleBlur();
369
+ } else if (e.key === "Escape") {
370
+ setIsEditing(false);
371
+ }
372
+ };
373
+
374
+ return (
375
+ <div
376
+ className="row-cell"
377
+ style={{ width: column.width, minWidth: column.minWidth }}
378
+ data-testid={`cell-${itemId}-${column.id}`}
379
+ data-editable-cell={isEditable || isPriorityDropdown ? "true" : undefined}
380
+ onDoubleClick={handleDoubleClick}
381
+ >
382
+ {isPriorityDropdown ? (
383
+ <Select value={cellValue} onValueChange={onEdit}>
384
+ <SelectTrigger
385
+ data-testid={`cell-select-${itemId}-${column.id}`}
386
+ aria-label={`${column.header} value`}
387
+ >
388
+ <SelectValue />
389
+ </SelectTrigger>
390
+ <SelectContent>
391
+ {priorityOptions.map((option) => (
392
+ <SelectItem key={option} value={option}>
393
+ {option}
394
+ </SelectItem>
395
+ ))}
396
+ </SelectContent>
397
+ </Select>
398
+ ) : isEditing ? (
399
+ <input
400
+ ref={inputRef}
401
+ type="text"
402
+ className="cell-input"
403
+ value={editValue}
404
+ onChange={(e) => setEditValue(e.target.value)}
405
+ onBlur={handleBlur}
406
+ onKeyDown={handleKeyDown}
407
+ data-testid={`cell-input-${itemId}-${column.id}`}
408
+ />
409
+ ) : (
410
+ <TableCellContent
411
+ column={column}
412
+ item={item}
413
+ rowIndex={rowIndex}
414
+ assignees={assignees}
415
+ onFieldChange={onFieldChange}
416
+ />
417
+ )}
418
+ </div>
419
+ );
420
+ }