@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,251 @@
1
+ import * as React from "react";
2
+ import {
3
+ SortableContext,
4
+ useSortable,
5
+ verticalListSortingStrategy,
6
+ } from "@dnd-kit/sortable";
7
+ import type { Assignee, ColumnDefinition, ItemOpenEvent, Section, TableTaskAction } from "./types";
8
+ import { SortableRow } from "./sortable-row";
9
+
10
+ interface SortableSectionProps<T> {
11
+ sortableId: string;
12
+ section: Section<T>;
13
+ columns: ColumnDefinition<T>[];
14
+ onToggle: () => void;
15
+ onRename: (name: string) => void;
16
+ onDelete: () => void;
17
+ onAddRow: () => void;
18
+ onCellEdit: (itemIndex: number, columnId: string, value: unknown) => void;
19
+ onRowComplete: (itemIndex: number, completed: boolean) => void;
20
+ onRowAction?: (itemIndex: number, itemId: string, action: TableTaskAction) => void;
21
+ /** Callback fired when an item is opened */
22
+ onItemOpen?: (event: ItemOpenEvent<T>) => void;
23
+ getItemId: (item: T) => string;
24
+ isItemCompleted?: (item: T) => boolean;
25
+ isDragDisabled: boolean;
26
+ collapseDuringDrag?: boolean;
27
+ dropIndicatorPosition?: "before" | "after" | null;
28
+ rowDropIndicatorIndex?: number | null;
29
+ /** List of available assignees for the assignee selector */
30
+ assignees?: Assignee[];
31
+ }
32
+
33
+ export function SortableSection<T>({
34
+ sortableId,
35
+ section,
36
+ columns,
37
+ onToggle,
38
+ onRename,
39
+ onDelete,
40
+ onAddRow,
41
+ onCellEdit,
42
+ onRowComplete,
43
+ onRowAction,
44
+ onItemOpen,
45
+ getItemId,
46
+ isItemCompleted,
47
+ isDragDisabled,
48
+ collapseDuringDrag = false,
49
+ dropIndicatorPosition = null,
50
+ rowDropIndicatorIndex = null,
51
+ assignees,
52
+ }: SortableSectionProps<T>) {
53
+ const [isEditing, setIsEditing] = React.useState(false);
54
+ const [editName, setEditName] = React.useState(section.name);
55
+ const [showContextMenu, setShowContextMenu] = React.useState(false);
56
+ const [contextMenuPos, setContextMenuPos] = React.useState({ x: 0, y: 0 });
57
+ const inputRef = React.useRef<HTMLInputElement>(null);
58
+
59
+ const {
60
+ attributes,
61
+ listeners,
62
+ setNodeRef,
63
+ transform,
64
+ transition,
65
+ isDragging,
66
+ } = useSortable({
67
+ id: sortableId,
68
+ data: { type: "section", sectionId: section.id },
69
+ });
70
+
71
+ const style: React.CSSProperties = {
72
+ // Use translate-only transform to avoid text scaling artifacts while sorting.
73
+ transform: transform
74
+ ? `translate3d(${transform.x}px, ${transform.y}px, 0)`
75
+ : undefined,
76
+ transition,
77
+ opacity: isDragging ? 0.75 : 1,
78
+ };
79
+ const isContentCollapsed =
80
+ Boolean(section.collapsed) || isDragging || collapseDuringDrag;
81
+ const rowIds = section.items.map(getItemId);
82
+
83
+ const handleDoubleClick = () => {
84
+ setIsEditing(true);
85
+ setEditName(section.name);
86
+ setTimeout(() => inputRef.current?.focus(), 0);
87
+ };
88
+
89
+ const handleBlur = () => {
90
+ setIsEditing(false);
91
+ if (editName.trim() && editName !== section.name) {
92
+ onRename(editName.trim());
93
+ }
94
+ };
95
+
96
+ const handleKeyDown = (e: React.KeyboardEvent) => {
97
+ if (e.key === "Enter") {
98
+ handleBlur();
99
+ } else if (e.key === "Escape") {
100
+ setIsEditing(false);
101
+ setEditName(section.name);
102
+ }
103
+ };
104
+
105
+ const handleContextMenu = (e: React.MouseEvent) => {
106
+ e.preventDefault();
107
+ setContextMenuPos({ x: e.clientX, y: e.clientY });
108
+ setShowContextMenu(true);
109
+ };
110
+
111
+ React.useEffect(() => {
112
+ const handleClick = () => setShowContextMenu(false);
113
+ if (showContextMenu) {
114
+ document.addEventListener("click", handleClick);
115
+ return () => document.removeEventListener("click", handleClick);
116
+ }
117
+ }, [showContextMenu]);
118
+
119
+ return (
120
+ <div
121
+ ref={setNodeRef}
122
+ style={style}
123
+ className={`sectioned-list-section ${isDragging ? "dragging" : ""}`}
124
+ data-testid={`section-${section.id}`}
125
+ >
126
+ {dropIndicatorPosition === "before" && (
127
+ <div className="section-drop-indicator section-drop-indicator-before" />
128
+ )}
129
+
130
+ <div
131
+ className="sectioned-list-section-header"
132
+ onContextMenu={handleContextMenu}
133
+ >
134
+ <button
135
+ className="section-drag-handle"
136
+ {...attributes}
137
+ {...listeners}
138
+ data-testid={`section-drag-handle-${section.id}`}
139
+ title="Drag to reorder section"
140
+ >
141
+ <span className="drag-icon">&#x2847;</span>
142
+ </button>
143
+
144
+ <button
145
+ className="section-collapse-toggle"
146
+ onClick={onToggle}
147
+ data-testid={`section-toggle-${section.id}`}
148
+ aria-expanded={!isContentCollapsed}
149
+ >
150
+ <span className={`chevron ${isContentCollapsed ? "collapsed" : ""}`}>
151
+ &#x25BC;
152
+ </span>
153
+ </button>
154
+
155
+ {isEditing ? (
156
+ <input
157
+ ref={inputRef}
158
+ type="text"
159
+ className="section-name-input"
160
+ value={editName}
161
+ onChange={(e) => setEditName(e.target.value)}
162
+ onBlur={handleBlur}
163
+ onKeyDown={handleKeyDown}
164
+ data-testid={`section-name-input-${section.id}`}
165
+ />
166
+ ) : (
167
+ <span
168
+ className="section-name"
169
+ onDoubleClick={handleDoubleClick}
170
+ data-testid={`section-name-${section.id}`}
171
+ >
172
+ {section.name}
173
+ </span>
174
+ )}
175
+
176
+ <span className="section-count">({section.items.length})</span>
177
+ </div>
178
+
179
+ {showContextMenu && (
180
+ <div
181
+ className="context-menu"
182
+ style={{ left: contextMenuPos.x, top: contextMenuPos.y }}
183
+ data-testid={`section-context-menu-${section.id}`}
184
+ >
185
+ <button onClick={onDelete} data-testid={`section-delete-${section.id}`}>
186
+ Delete section
187
+ </button>
188
+ </div>
189
+ )}
190
+
191
+ {/* Collapse content during drag for smooth Asana-style section dragging */}
192
+ {!isContentCollapsed && (
193
+ <div className="sectioned-list-section-content">
194
+ <SortableContext items={rowIds} strategy={verticalListSortingStrategy}>
195
+ {section.items.map((item, index) => (
196
+ <React.Fragment key={getItemId(item)}>
197
+ {rowDropIndicatorIndex === index && (
198
+ <div className="row-drop-indicator" />
199
+ )}
200
+ <SortableRow
201
+ item={item}
202
+ itemId={getItemId(item)}
203
+ index={index}
204
+ columns={columns}
205
+ sectionId={section.id}
206
+ onCellEdit={(columnId, value) => onCellEdit(index, columnId, value)}
207
+ onComplete={(completed) => onRowComplete(index, completed)}
208
+ onTaskAction={
209
+ onRowAction
210
+ ? (action) => onRowAction(index, getItemId(item), action)
211
+ : undefined
212
+ }
213
+ onItemOpen={
214
+ onItemOpen
215
+ ? (source) =>
216
+ onItemOpen({
217
+ item,
218
+ itemId: getItemId(item),
219
+ sectionId: section.id,
220
+ source,
221
+ })
222
+ : undefined
223
+ }
224
+ isCompleted={isItemCompleted?.(item) ?? false}
225
+ isDragDisabled={isDragDisabled}
226
+ assignees={assignees}
227
+ />
228
+ </React.Fragment>
229
+ ))}
230
+ {rowDropIndicatorIndex === section.items.length && (
231
+ <div className="row-drop-indicator" />
232
+ )}
233
+ </SortableContext>
234
+
235
+ <div
236
+ className="add-task-row"
237
+ onClick={onAddRow}
238
+ data-testid={`add-task-${section.id}`}
239
+ >
240
+ <span className="add-task-icon">+</span>
241
+ <span className="add-task-text">Add task...</span>
242
+ </div>
243
+ </div>
244
+ )}
245
+
246
+ {dropIndicatorPosition === "after" && (
247
+ <div className="section-drop-indicator section-drop-indicator-after" />
248
+ )}
249
+ </div>
250
+ );
251
+ }
@@ -0,0 +1,129 @@
1
+ import * as React from "react";
2
+ import Badge from "@cloudscape-design/components/badge";
3
+ import type { Assignee, ColumnDefinition, TableBadgeColor } from "./types";
4
+ import { AssigneeSelector } from "@/components/ui/assignee-selector";
5
+
6
+ function toText(value: unknown): string {
7
+ if (value == null) return "";
8
+ if (typeof value === "string") return value;
9
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
10
+ return "";
11
+ }
12
+
13
+ function getInitials(name: string): string {
14
+ const parts = name
15
+ .trim()
16
+ .split(/\s+/)
17
+ .filter(Boolean);
18
+
19
+ if (parts.length === 0) return "?";
20
+
21
+ return parts
22
+ .slice(0, 2)
23
+ .map((part) => part[0]?.toUpperCase() ?? "")
24
+ .join("");
25
+ }
26
+
27
+ function asNumber(value: unknown): number | null {
28
+ if (typeof value === "number") return Number.isFinite(value) ? value : null;
29
+ if (typeof value === "string" && value.trim()) {
30
+ const parsed = Number(value);
31
+ return Number.isFinite(parsed) ? parsed : null;
32
+ }
33
+ return null;
34
+ }
35
+
36
+ function formatCurrency(value: unknown, column: ColumnDefinition<unknown>): string {
37
+ const numeric = asNumber(value);
38
+ if (numeric == null) return "";
39
+ return new Intl.NumberFormat(column.currencyLocale ?? "en-US", {
40
+ style: "currency",
41
+ currency: column.currencyCode ?? "USD",
42
+ minimumFractionDigits: 0,
43
+ maximumFractionDigits: 0,
44
+ }).format(numeric);
45
+ }
46
+
47
+ function getBadgeColor(column: ColumnDefinition<unknown>, value: string): TableBadgeColor {
48
+ return column.badgeColorMap?.[value] ?? column.defaultBadgeColor ?? "blue";
49
+ }
50
+
51
+ interface TableCellContentProps<T> {
52
+ column: ColumnDefinition<T>;
53
+ item: T;
54
+ rowIndex: number;
55
+ /** List of available assignees for the assignee selector */
56
+ assignees?: Assignee[];
57
+ /** Callback when a cell value changes */
58
+ onFieldChange?: (field: string, value: unknown) => void;
59
+ }
60
+
61
+ export function TableCellContent<T>({ column, item, rowIndex, assignees, onFieldChange }: TableCellContentProps<T>) {
62
+ const [isAssigneeSelectorOpen, setIsAssigneeSelectorOpen] = React.useState(false);
63
+ void rowIndex;
64
+ const value = (item as Record<string, unknown>)[column.field];
65
+
66
+ switch (column.type) {
67
+ case "currency": {
68
+ return formatCurrency(value, column as ColumnDefinition<unknown>);
69
+ }
70
+ case "badge": {
71
+ const text = toText(value);
72
+ if (!text) return null;
73
+ return <Badge color={getBadgeColor(column as ColumnDefinition<unknown>, text)}>{text}</Badge>;
74
+ }
75
+ case "avatar": {
76
+ const text = toText(value);
77
+ const canEditAssignee = Boolean(
78
+ onFieldChange &&
79
+ assignees &&
80
+ assignees.length > 0
81
+ );
82
+ const currentAssignee = assignees?.find((a) => a.name === text);
83
+
84
+ if (canEditAssignee) {
85
+ return (
86
+ <AssigneeSelector
87
+ open={isAssigneeSelectorOpen}
88
+ onOpenChange={setIsAssigneeSelectorOpen}
89
+ assignees={assignees ?? []}
90
+ selectedId={currentAssignee?.id}
91
+ onSelect={(assignee) => {
92
+ if (!onFieldChange) return;
93
+ onFieldChange(column.field, assignee?.name ?? "");
94
+ }}
95
+ mode="popover"
96
+ align="start"
97
+ >
98
+ <button
99
+ type="button"
100
+ className="table-avatar table-avatar-editable"
101
+ title={text ? `${text} (click to change)` : "Assign someone"}
102
+ onClick={(e) => {
103
+ e.stopPropagation();
104
+ setIsAssigneeSelectorOpen(true);
105
+ }}
106
+ >
107
+ {getInitials(text)}
108
+ </button>
109
+ </AssigneeSelector>
110
+ );
111
+ }
112
+
113
+ return (
114
+ <div className="table-avatar" title={text || "Unassigned"}>
115
+ {getInitials(text)}
116
+ </div>
117
+ );
118
+ }
119
+ case "text":
120
+ default: {
121
+ return toText(value);
122
+ }
123
+ }
124
+ }
125
+
126
+ export function getEditableCellValue<T>(column: ColumnDefinition<T>, item: T): string {
127
+ const value = (item as Record<string, unknown>)[column.field];
128
+ return toText(value);
129
+ }
@@ -0,0 +1,120 @@
1
+ import type { ReactNode } from "react";
2
+ import type { Assignee } from "@/components/ui/assignee-selector";
3
+
4
+ // Re-export Assignee from shared location
5
+ export type { Assignee };
6
+
7
+ export type TableBadgeColor = "red" | "blue" | "green" | "grey";
8
+ export type TableColumnType = "text" | "currency" | "badge" | "avatar";
9
+ export type TableTaskAction =
10
+ | "duplicate"
11
+ | "markComplete"
12
+ | "addSubtask"
13
+ | "openTaskDetails"
14
+ | "openInNewTab"
15
+ | "copyTaskLink"
16
+ | "delete";
17
+
18
+ /** Event fired when an item is opened (click, context menu, or keyboard) */
19
+ export interface ItemOpenEvent<T> {
20
+ item: T;
21
+ itemId: string;
22
+ sectionId: string;
23
+ source: "click" | "context-menu" | "keyboard";
24
+ }
25
+
26
+ /** Configuration for the built-in drawer */
27
+ export interface DrawerProps {
28
+ /** Which side the drawer opens from */
29
+ side?: "right" | "left";
30
+ /** Width of the drawer */
31
+ width?: number | string;
32
+ }
33
+
34
+ export interface ColumnDefinition<T = Record<string, unknown>> {
35
+ id: string;
36
+ header: string;
37
+ field: Extract<keyof T, string>;
38
+ type?: TableColumnType;
39
+ editable?: boolean;
40
+ badgeColorMap?: Record<string, TableBadgeColor>;
41
+ defaultBadgeColor?: TableBadgeColor;
42
+ currencyCode?: string;
43
+ currencyLocale?: string;
44
+ width?: number;
45
+ minWidth?: number;
46
+ sortable?: boolean;
47
+ resizable?: boolean;
48
+ visible?: boolean;
49
+ }
50
+
51
+ export interface Section<T = Record<string, unknown>> {
52
+ id: string;
53
+ name: string;
54
+ items: T[];
55
+ collapsed?: boolean;
56
+ }
57
+
58
+ export interface SortState {
59
+ columnId: string | null;
60
+ direction: "asc" | "desc" | null;
61
+ }
62
+
63
+ export interface ColumnPreferences {
64
+ order: string[];
65
+ visibility: Record<string, boolean>;
66
+ widths: Record<string, number>;
67
+ }
68
+
69
+ export interface SectionedListTableProps<T = Record<string, unknown>> {
70
+ columns: ColumnDefinition<T>[];
71
+ sections: Section<T>[];
72
+ onSectionReorder?: (sections: Section<T>[]) => void;
73
+ onRowReorder?: (sectionId: string, items: T[]) => void;
74
+ onRowMoveToSection?: (
75
+ item: T,
76
+ fromSectionId: string,
77
+ toSectionId: string,
78
+ targetIndex?: number
79
+ ) => void;
80
+ onAddColumn?: () => void;
81
+ onAddRow?: (sectionId: string) => void;
82
+ onAddSection?: () => void;
83
+ onDeleteSection?: (sectionId: string) => void;
84
+ onRenameSection?: (sectionId: string, newName: string) => void;
85
+ onToggleSection?: (sectionId: string) => void;
86
+ onCellEdit?: (sectionId: string, itemIndex: number, columnId: string, value: unknown) => void;
87
+ onRowComplete?: (sectionId: string, itemIndex: number, completed: boolean) => void;
88
+ onRowAction?: (
89
+ sectionId: string,
90
+ itemIndex: number,
91
+ itemId: string,
92
+ action: TableTaskAction
93
+ ) => void;
94
+ onColumnReorder?: (columns: ColumnDefinition<T>[]) => void;
95
+ onColumnResize?: (columnId: string, width: number) => void;
96
+ onColumnVisibilityChange?: (columnId: string, visible: boolean) => void;
97
+ onSort?: (sort: SortState) => void;
98
+ onFilter?: (filterText: string) => void;
99
+ filterText?: string;
100
+ sort?: SortState;
101
+ storageKey?: string;
102
+ getItemId: (item: T) => string;
103
+ isItemCompleted?: (item: T) => boolean;
104
+ /** List of available assignees for the assignee selector */
105
+ assignees?: Assignee[];
106
+
107
+ // Item Details Drawer
108
+ /** Callback fired when an item is opened (click, context menu, or keyboard) */
109
+ onItemOpen?: (event: ItemOpenEvent<T>) => void;
110
+ /** Render function for item details - when provided, enables built-in drawer */
111
+ renderItemDetails?: (item: T) => ReactNode;
112
+ /** Configuration for the built-in drawer */
113
+ drawerProps?: DrawerProps;
114
+ }
115
+
116
+ export interface DragState {
117
+ isDragging: boolean;
118
+ dragType: "section" | "row" | "column" | null;
119
+ activeId: string | null;
120
+ }
@@ -0,0 +1,103 @@
1
+ import * as React from "react";
2
+ import type { ColumnDefinition, ColumnPreferences } from "./types";
3
+
4
+ const DEFAULT_PREFERENCES: ColumnPreferences = {
5
+ order: [],
6
+ visibility: {},
7
+ widths: {},
8
+ };
9
+
10
+ export function useColumnPreferences<T>(
11
+ columns: ColumnDefinition<T>[],
12
+ storageKey?: string
13
+ ) {
14
+ const [preferences, setPreferences] = React.useState<ColumnPreferences>(() => {
15
+ if (!storageKey) return DEFAULT_PREFERENCES;
16
+ try {
17
+ const stored = localStorage.getItem(`column-prefs-${storageKey}`);
18
+ if (stored) {
19
+ return JSON.parse(stored);
20
+ }
21
+ } catch {
22
+ // Ignore localStorage errors
23
+ }
24
+ return DEFAULT_PREFERENCES;
25
+ });
26
+
27
+ // Save to localStorage when preferences change
28
+ React.useEffect(() => {
29
+ if (!storageKey) return;
30
+ try {
31
+ localStorage.setItem(`column-prefs-${storageKey}`, JSON.stringify(preferences));
32
+ } catch {
33
+ // Ignore localStorage errors
34
+ }
35
+ }, [preferences, storageKey]);
36
+
37
+ // Get ordered and filtered columns based on preferences
38
+ const orderedColumns = React.useMemo(() => {
39
+ const columnMap = new Map(columns.map((col) => [col.id, col]));
40
+ const order = preferences.order.length > 0 ? preferences.order : columns.map((c) => c.id);
41
+
42
+ return order
43
+ .filter((id) => columnMap.has(id))
44
+ .map((id) => {
45
+ const col = columnMap.get(id)!;
46
+ const isVisible = preferences.visibility[id] ?? col.visible ?? true;
47
+ const width = preferences.widths[id] ?? col.width;
48
+ return { ...col, visible: isVisible, width };
49
+ })
50
+ .concat(
51
+ // Add any new columns not in saved order
52
+ columns.filter((col) => !order.includes(col.id)).map((col) => ({
53
+ ...col,
54
+ visible: preferences.visibility[col.id] ?? col.visible ?? true,
55
+ width: preferences.widths[col.id] ?? col.width,
56
+ }))
57
+ );
58
+ }, [columns, preferences]);
59
+
60
+ const visibleColumns = React.useMemo(
61
+ () => orderedColumns.filter((col) => col.visible !== false),
62
+ [orderedColumns]
63
+ );
64
+
65
+ const setColumnOrder = React.useCallback((order: string[]) => {
66
+ setPreferences((prev) => ({ ...prev, order }));
67
+ }, []);
68
+
69
+ const setColumnVisibility = React.useCallback((columnId: string, visible: boolean) => {
70
+ setPreferences((prev) => ({
71
+ ...prev,
72
+ visibility: { ...prev.visibility, [columnId]: visible },
73
+ }));
74
+ }, []);
75
+
76
+ const setColumnWidth = React.useCallback((columnId: string, width: number) => {
77
+ setPreferences((prev) => ({
78
+ ...prev,
79
+ widths: { ...prev.widths, [columnId]: width },
80
+ }));
81
+ }, []);
82
+
83
+ const resetPreferences = React.useCallback(() => {
84
+ setPreferences(DEFAULT_PREFERENCES);
85
+ if (storageKey) {
86
+ try {
87
+ localStorage.removeItem(`column-prefs-${storageKey}`);
88
+ } catch {
89
+ // Ignore
90
+ }
91
+ }
92
+ }, [storageKey]);
93
+
94
+ return {
95
+ orderedColumns,
96
+ visibleColumns,
97
+ preferences,
98
+ setColumnOrder,
99
+ setColumnVisibility,
100
+ setColumnWidth,
101
+ resetPreferences,
102
+ };
103
+ }