@jmruthers/pace-core 0.5.106 → 0.5.108

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 (156) hide show
  1. package/dist/{DataTable-BE0OXZKQ.d.ts → DataTable-D5cBRca8.d.ts} +1 -1
  2. package/dist/{DataTable-LWHFLTEW.js → DataTable-WFCHVWTY.js} +3 -3
  3. package/dist/{PublicLoadingSpinner-48ewSMKK.d.ts → PublicLoadingSpinner-DgDWTFqn.d.ts} +4 -2
  4. package/dist/{chunk-QPCAGLUS.js → chunk-4OX5PXHX.js} +5 -2
  5. package/dist/chunk-4OX5PXHX.js.map +1 -0
  6. package/dist/{chunk-IBZBNBTE.js → chunk-B3QX32P5.js} +177 -54
  7. package/dist/chunk-B3QX32P5.js.map +1 -0
  8. package/dist/{chunk-75G3NZWN.js → chunk-IMZGJ2X7.js} +373 -95
  9. package/dist/chunk-IMZGJ2X7.js.map +1 -0
  10. package/dist/{chunk-4BWGRQBG.js → chunk-NFPV7MRN.js} +22 -2
  11. package/dist/chunk-NFPV7MRN.js.map +1 -0
  12. package/dist/components.d.ts +4 -4
  13. package/dist/components.js +3 -3
  14. package/dist/{formatting-BfDeV-ja.d.ts → formatting-BiEv5oEk.d.ts} +32 -2
  15. package/dist/hooks.d.ts +2 -2
  16. package/dist/hooks.js +1 -1
  17. package/dist/index.d.ts +6 -6
  18. package/dist/index.js +4 -4
  19. package/dist/{types-BDg1mAGG.d.ts → types-D4TVpDa1.d.ts} +24 -1
  20. package/dist/{useToast-Bm6TnSK-.d.ts → useToast-DRah6K-g.d.ts} +5 -2
  21. package/dist/utils.d.ts +3 -3
  22. package/dist/utils.js +2 -2
  23. package/docs/api/classes/ColumnFactory.md +1 -1
  24. package/docs/api/classes/ErrorBoundary.md +1 -1
  25. package/docs/api/classes/InvalidScopeError.md +1 -1
  26. package/docs/api/classes/MissingUserContextError.md +1 -1
  27. package/docs/api/classes/OrganisationContextRequiredError.md +1 -1
  28. package/docs/api/classes/PermissionDeniedError.md +1 -1
  29. package/docs/api/classes/PublicErrorBoundary.md +1 -1
  30. package/docs/api/classes/RBACAuditManager.md +1 -1
  31. package/docs/api/classes/RBACCache.md +1 -1
  32. package/docs/api/classes/RBACEngine.md +1 -1
  33. package/docs/api/classes/RBACError.md +1 -1
  34. package/docs/api/classes/RBACNotInitializedError.md +1 -1
  35. package/docs/api/classes/SecureSupabaseClient.md +1 -1
  36. package/docs/api/classes/StorageUtils.md +1 -1
  37. package/docs/api/enums/FileCategory.md +1 -1
  38. package/docs/api/interfaces/AggregateConfig.md +4 -4
  39. package/docs/api/interfaces/ButtonProps.md +1 -1
  40. package/docs/api/interfaces/CardProps.md +1 -1
  41. package/docs/api/interfaces/ColorPalette.md +1 -1
  42. package/docs/api/interfaces/ColorShade.md +1 -1
  43. package/docs/api/interfaces/DataAccessRecord.md +1 -1
  44. package/docs/api/interfaces/DataRecord.md +1 -1
  45. package/docs/api/interfaces/DataTableAction.md +18 -18
  46. package/docs/api/interfaces/DataTableColumn.md +115 -10
  47. package/docs/api/interfaces/DataTableProps.md +38 -38
  48. package/docs/api/interfaces/DataTableToolbarButton.md +7 -7
  49. package/docs/api/interfaces/EmptyStateConfig.md +5 -5
  50. package/docs/api/interfaces/EnhancedNavigationMenuProps.md +1 -1
  51. package/docs/api/interfaces/FileDisplayProps.md +1 -1
  52. package/docs/api/interfaces/FileMetadata.md +1 -1
  53. package/docs/api/interfaces/FileReference.md +1 -1
  54. package/docs/api/interfaces/FileSizeLimits.md +1 -1
  55. package/docs/api/interfaces/FileUploadOptions.md +1 -1
  56. package/docs/api/interfaces/FileUploadProps.md +1 -1
  57. package/docs/api/interfaces/FooterProps.md +1 -1
  58. package/docs/api/interfaces/InactivityWarningModalProps.md +1 -1
  59. package/docs/api/interfaces/InputProps.md +1 -1
  60. package/docs/api/interfaces/LabelProps.md +1 -1
  61. package/docs/api/interfaces/LoginFormProps.md +1 -1
  62. package/docs/api/interfaces/NavigationAccessRecord.md +1 -1
  63. package/docs/api/interfaces/NavigationContextType.md +1 -1
  64. package/docs/api/interfaces/NavigationGuardProps.md +1 -1
  65. package/docs/api/interfaces/NavigationItem.md +1 -1
  66. package/docs/api/interfaces/NavigationMenuProps.md +1 -1
  67. package/docs/api/interfaces/NavigationProviderProps.md +1 -1
  68. package/docs/api/interfaces/Organisation.md +1 -1
  69. package/docs/api/interfaces/OrganisationContextType.md +1 -1
  70. package/docs/api/interfaces/OrganisationMembership.md +1 -1
  71. package/docs/api/interfaces/OrganisationProviderProps.md +1 -1
  72. package/docs/api/interfaces/OrganisationSecurityError.md +1 -1
  73. package/docs/api/interfaces/PaceAppLayoutProps.md +1 -1
  74. package/docs/api/interfaces/PaceLoginPageProps.md +1 -1
  75. package/docs/api/interfaces/PageAccessRecord.md +1 -1
  76. package/docs/api/interfaces/PagePermissionContextType.md +1 -1
  77. package/docs/api/interfaces/PagePermissionGuardProps.md +1 -1
  78. package/docs/api/interfaces/PagePermissionProviderProps.md +1 -1
  79. package/docs/api/interfaces/PaletteData.md +1 -1
  80. package/docs/api/interfaces/PermissionEnforcerProps.md +1 -1
  81. package/docs/api/interfaces/ProtectedRouteProps.md +1 -1
  82. package/docs/api/interfaces/PublicErrorBoundaryProps.md +1 -1
  83. package/docs/api/interfaces/PublicErrorBoundaryState.md +1 -1
  84. package/docs/api/interfaces/PublicLoadingSpinnerProps.md +1 -1
  85. package/docs/api/interfaces/PublicPageFooterProps.md +1 -1
  86. package/docs/api/interfaces/PublicPageHeaderProps.md +1 -1
  87. package/docs/api/interfaces/PublicPageLayoutProps.md +1 -1
  88. package/docs/api/interfaces/RBACConfig.md +1 -1
  89. package/docs/api/interfaces/RBACLogger.md +1 -1
  90. package/docs/api/interfaces/RoleBasedRouterContextType.md +1 -1
  91. package/docs/api/interfaces/RoleBasedRouterProps.md +1 -1
  92. package/docs/api/interfaces/RouteAccessRecord.md +1 -1
  93. package/docs/api/interfaces/RouteConfig.md +1 -1
  94. package/docs/api/interfaces/SecureDataContextType.md +1 -1
  95. package/docs/api/interfaces/SecureDataProviderProps.md +1 -1
  96. package/docs/api/interfaces/StorageConfig.md +1 -1
  97. package/docs/api/interfaces/StorageFileInfo.md +1 -1
  98. package/docs/api/interfaces/StorageFileMetadata.md +1 -1
  99. package/docs/api/interfaces/StorageListOptions.md +1 -1
  100. package/docs/api/interfaces/StorageListResult.md +1 -1
  101. package/docs/api/interfaces/StorageUploadOptions.md +1 -1
  102. package/docs/api/interfaces/StorageUploadResult.md +1 -1
  103. package/docs/api/interfaces/StorageUrlOptions.md +1 -1
  104. package/docs/api/interfaces/StyleImport.md +1 -1
  105. package/docs/api/interfaces/SwitchProps.md +1 -1
  106. package/docs/api/interfaces/ToastActionElement.md +1 -1
  107. package/docs/api/interfaces/ToastProps.md +1 -1
  108. package/docs/api/interfaces/UnifiedAuthContextType.md +1 -1
  109. package/docs/api/interfaces/UnifiedAuthProviderProps.md +1 -1
  110. package/docs/api/interfaces/UseInactivityTrackerOptions.md +1 -1
  111. package/docs/api/interfaces/UseInactivityTrackerReturn.md +1 -1
  112. package/docs/api/interfaces/UsePublicEventOptions.md +1 -1
  113. package/docs/api/interfaces/UsePublicEventReturn.md +1 -1
  114. package/docs/api/interfaces/UsePublicFileDisplayOptions.md +1 -1
  115. package/docs/api/interfaces/UsePublicFileDisplayReturn.md +1 -1
  116. package/docs/api/interfaces/UsePublicRouteParamsReturn.md +1 -1
  117. package/docs/api/interfaces/UseResolvedScopeOptions.md +1 -1
  118. package/docs/api/interfaces/UseResolvedScopeReturn.md +1 -1
  119. package/docs/api/interfaces/UserEventAccess.md +1 -1
  120. package/docs/api/interfaces/UserMenuProps.md +1 -1
  121. package/docs/api/interfaces/UserProfile.md +1 -1
  122. package/docs/api/modules.md +42 -19
  123. package/docs/api-reference/utilities.md +26 -3
  124. package/docs/implementation-guides/data-tables.md +390 -0
  125. package/package.json +1 -1
  126. package/src/components/DataTable/DataTable.tsx +4 -0
  127. package/src/components/DataTable/__tests__/DataTableCore.test.tsx +25 -10
  128. package/src/components/DataTable/components/ColumnFilter.tsx +2 -1
  129. package/src/components/DataTable/components/EditableRow.tsx +179 -16
  130. package/src/components/DataTable/components/FilterRow.tsx +22 -11
  131. package/src/components/DataTable/components/PaginationControls.tsx +1 -1
  132. package/src/components/DataTable/components/UnifiedTableBody.tsx +231 -32
  133. package/src/components/DataTable/types.ts +34 -4
  134. package/src/components/FileDisplay/FileDisplay.test.tsx +184 -201
  135. package/src/components/FileDisplay/FileDisplay.tsx +40 -39
  136. package/src/components/NavigationMenu/NavigationMenu.test.tsx +189 -13
  137. package/src/components/NavigationMenu/NavigationMenu.tsx +142 -35
  138. package/src/components/PaceAppLayout/PaceAppLayout.tsx +79 -10
  139. package/src/components/PublicLayout/__tests__/PublicPageHeader.test.tsx +4 -4
  140. package/src/components/Toast/Toast.tsx +1 -1
  141. package/src/hooks/useEventTheme.test.ts +11 -0
  142. package/src/hooks/useSecureDataAccess.test.ts +22 -5
  143. package/src/hooks/useToast.ts +11 -2
  144. package/src/providers/UnifiedAuthProvider.smoke.test.tsx +67 -3
  145. package/src/providers/__tests__/ProviderLifecycle.test.tsx +72 -4
  146. package/src/services/__tests__/OrganisationService.pagination.test.ts +10 -2
  147. package/src/styles/core.css +11 -0
  148. package/src/utils/__tests__/formatting.unit.test.ts +33 -0
  149. package/src/utils/file-reference.test.ts +44 -5
  150. package/src/utils/formatting.ts +57 -2
  151. package/src/validation/__tests__/passwordSchema.unit.test.ts +3 -3
  152. package/dist/chunk-4BWGRQBG.js.map +0 -1
  153. package/dist/chunk-75G3NZWN.js.map +0 -1
  154. package/dist/chunk-IBZBNBTE.js.map +0 -1
  155. package/dist/chunk-QPCAGLUS.js.map +0 -1
  156. /package/dist/{DataTable-LWHFLTEW.js.map → DataTable-WFCHVWTY.js.map} +0 -0
@@ -10,6 +10,9 @@ import {
10
10
  SelectItem,
11
11
  SelectTrigger,
12
12
  SelectValue,
13
+ SelectGroup,
14
+ SelectLabel,
15
+ SelectSeparator,
13
16
  } from '../../Select/Select';
14
17
  import type { CellValue, DataRecord, DataTableAction, EditableColumnDef } from '../types';
15
18
 
@@ -25,6 +28,170 @@ interface EditableRowProps<TData extends DataRecord> {
25
28
  hierarchical?: boolean;
26
29
  }
27
30
 
31
+ // Component for select fields with searchable and creatable support
32
+ function SelectEditField<TData extends DataRecord>({
33
+ columnDef,
34
+ accessorKey,
35
+ currentValue,
36
+ placeholder,
37
+ onChange,
38
+ className,
39
+ }: {
40
+ columnDef: EditableColumnDef<TData>;
41
+ accessorKey: string;
42
+ currentValue: CellValue;
43
+ placeholder?: string;
44
+ onChange: (value: CellValue) => void;
45
+ className?: string;
46
+ }) {
47
+ // Determine if searchable - explicitly check for true to ensure visible search input appears
48
+ // When selectSearchable is true or undefined, show the visible search input box
49
+ // When selectSearchable is false, hide the search input (type-to-search still works via SelectContent internals)
50
+ const isSearchable = columnDef.selectSearchable !== false;
51
+ const isCreatable = columnDef.creatable === true;
52
+ const selectRef = React.useRef<HTMLFormElement>(null);
53
+ const [searchTerm, setSearchTerm] = React.useState('');
54
+ const [isOpen, setIsOpen] = React.useState(false);
55
+ const [showCreateOption, setShowCreateOption] = React.useState(false);
56
+
57
+ // Monitor search input value via DOM events to detect when user types
58
+ React.useEffect(() => {
59
+ if (!isOpen || !isSearchable || !isCreatable || !selectRef.current) return;
60
+
61
+ const searchInput = selectRef.current.querySelector<HTMLInputElement>('[data-testid="select-search-input"]');
62
+ if (!searchInput) return;
63
+
64
+ const handleInput = (e: Event) => {
65
+ const target = e.target as HTMLInputElement;
66
+ const currentSearch = target.value;
67
+ setSearchTerm(currentSearch);
68
+
69
+ // Check if search doesn't match any option (including items in groups)
70
+ if (currentSearch.trim()) {
71
+ const searchLower = currentSearch.toLowerCase().trim();
72
+
73
+ // Helper to check if an option matches
74
+ // Use explicit union type instead of typeof to avoid Babel parsing issues
75
+ type FieldOption =
76
+ | { value: string | number; label: string }
77
+ | { type: 'group'; label: string; items: Array<{ value: string | number; label: string }> }
78
+ | { type: 'separator' };
79
+
80
+ const checkMatch = (opt: FieldOption): boolean => {
81
+ // Simple option
82
+ if ('value' in opt && !('type' in opt)) {
83
+ return opt.label.toLowerCase().includes(searchLower);
84
+ }
85
+ // Group - check items within the group
86
+ if ('type' in opt && opt.type === 'group') {
87
+ return (opt as { type: 'group'; label: string; items: Array<{ value: string | number; label: string }> }).items.some((item: { value: string | number; label: string }) => item.label.toLowerCase().includes(searchLower));
88
+ }
89
+ // Separator - doesn't match
90
+ return false;
91
+ };
92
+
93
+ const hasMatch = (columnDef.fieldOptions || []).some(checkMatch);
94
+ setShowCreateOption(!hasMatch);
95
+ } else {
96
+ setShowCreateOption(false);
97
+ }
98
+ };
99
+
100
+ searchInput.addEventListener('input', handleInput);
101
+
102
+ return () => {
103
+ searchInput.removeEventListener('input', handleInput);
104
+ };
105
+ }, [isOpen, isSearchable, isCreatable, columnDef.fieldOptions]);
106
+
107
+ const handleCreateNew = React.useCallback(async () => {
108
+ if (!isCreatable || !columnDef.onCreateNew || !searchTerm.trim()) return;
109
+
110
+ try {
111
+ const newValue = await columnDef.onCreateNew(searchTerm.trim());
112
+ onChange(newValue);
113
+ setSearchTerm('');
114
+ setShowCreateOption(false);
115
+ } catch (error) {
116
+ console.error('Error creating new item:', error);
117
+ }
118
+ }, [isCreatable, columnDef.onCreateNew, searchTerm, onChange]);
119
+
120
+ return (
121
+ <Select
122
+ ref={selectRef}
123
+ value={String(currentValue)}
124
+ onValueChange={(newValue) => {
125
+ if (newValue.startsWith('__create_new__')) {
126
+ handleCreateNew();
127
+ } else {
128
+ onChange(newValue as CellValue);
129
+ }
130
+ }}
131
+ onOpenChange={(open) => {
132
+ setIsOpen(open);
133
+ if (!open) {
134
+ setSearchTerm('');
135
+ setShowCreateOption(false);
136
+ }
137
+ }}
138
+ >
139
+ <SelectTrigger className={className || "w-full h-7"}>
140
+ <SelectValue placeholder={placeholder || `Select ${columnDef.header || 'option'}...`} />
141
+ </SelectTrigger>
142
+ <SelectContent
143
+ searchable={Boolean(isSearchable)}
144
+ searchPlaceholder={`Search ${columnDef.header || 'options'}...`}
145
+ maxHeight={columnDef.selectMaxHeight}
146
+ className={columnDef.selectContentClassName}
147
+ style={columnDef.selectContentStyle}
148
+ >
149
+ {columnDef.fieldOptions?.map((option, index) => {
150
+ // Simple option item
151
+ if ('value' in option && !('type' in option)) {
152
+ return (
153
+ <SelectItem key={`${option.value}-${index}`} value={String(option.value)}>
154
+ {option.label}
155
+ </SelectItem>
156
+ );
157
+ }
158
+
159
+ // Separator
160
+ if ('type' in option && option.type === 'separator') {
161
+ return <SelectSeparator key={`separator-${index}`} />;
162
+ }
163
+
164
+ // Group with label
165
+ if ('type' in option && option.type === 'group') {
166
+ const groupOption = option as { type: 'group'; label: string; items: Array<{ value: string | number; label: string }> };
167
+ return (
168
+ <SelectGroup key={`group-${groupOption.label}-${index}`}>
169
+ <SelectLabel>{groupOption.label}</SelectLabel>
170
+ {groupOption.items.map((item: { value: string | number; label: string }) => (
171
+ <SelectItem key={`${item.value}-${index}`} value={String(item.value)}>
172
+ {item.label}
173
+ </SelectItem>
174
+ ))}
175
+ </SelectGroup>
176
+ );
177
+ }
178
+
179
+ return null;
180
+ })}
181
+ {showCreateOption && isCreatable && searchTerm.trim() && (
182
+ <SelectItem
183
+ key="__create_new__"
184
+ value={`__create_new__${searchTerm}`}
185
+ className="bg-main-100 font-medium border-t border-main-200"
186
+ >
187
+ Create "{searchTerm}"
188
+ </SelectItem>
189
+ )}
190
+ </SelectContent>
191
+ </Select>
192
+ );
193
+ }
194
+
28
195
  const renderEditField = <TData extends DataRecord>(
29
196
  column: Column<TData, unknown>,
30
197
  value: CellValue,
@@ -44,21 +211,14 @@ const renderEditField = <TData extends DataRecord>(
44
211
  const currentValue = editingData[accessorKey] ?? value ?? '';
45
212
 
46
213
  return (
47
- <Select
48
- value={String(currentValue)}
49
- onValueChange={(newValue) => onChange({ [accessorKey]: newValue as CellValue })}
50
- >
51
- <SelectTrigger className="w-full h-7">
52
- <SelectValue placeholder={`Select ${column.id}`} />
53
- </SelectTrigger>
54
- <SelectContent>
55
- {columnDef.fieldOptions.map(option => (
56
- <SelectItem key={option.value} value={String(option.value)}>
57
- {option.label}
58
- </SelectItem>
59
- ))}
60
- </SelectContent>
61
- </Select>
214
+ <SelectEditField
215
+ columnDef={columnDef}
216
+ accessorKey={accessorKey}
217
+ currentValue={currentValue}
218
+ placeholder={placeholder}
219
+ onChange={(newValue) => onChange({ [accessorKey]: newValue })}
220
+ className="w-full h-7"
221
+ />
62
222
  );
63
223
  }
64
224
 
@@ -75,13 +235,16 @@ const renderEditField = <TData extends DataRecord>(
75
235
  }
76
236
 
77
237
  if (columnDef.fieldType === 'number') {
238
+ // Hide spinner arrows by default for number, currency, and percentage fields
239
+ // Only show spinners if explicitly set to false
240
+ const hideSpinners = columnDef.hideNumberSpinners !== false; // Default to true
78
241
  return (
79
242
  <Input
80
243
  ref={inputRef}
81
244
  type="number"
82
245
  value={String(value ?? '')}
83
246
  onChange={(e) => onChange(e.target.value as unknown as CellValue)}
84
- className="w-full h-7"
247
+ className={`w-full h-7 ${hideSpinners ? 'datatable-number-no-spinners' : ''}`}
85
248
  />
86
249
  );
87
250
  }
@@ -12,7 +12,7 @@ export function FilterRow<TData>({ table, visibleColumns }: FilterRowProps<TData
12
12
  const { columnFilters } = getState();
13
13
 
14
14
  // Get unique values for select filters
15
- const getColumnOptions = (columnId: string) => {
15
+ const getColumnOptions = React.useCallback((columnId: string) => {
16
16
  const column = table.getColumn(columnId);
17
17
  if (!column) return [];
18
18
 
@@ -40,44 +40,55 @@ export function FilterRow<TData>({ table, visibleColumns }: FilterRowProps<TData
40
40
  return Array.from(uniqueValues)
41
41
  .sort()
42
42
  .map((value) => ({ value, label: value }));
43
- };
43
+ }, [table]);
44
44
 
45
45
  // Determine filter type based on column data
46
- const getFilterType = (columnId: string) => {
46
+ // IMPORTANT: Explicit filterType always takes priority - auto-detection only runs if filterType is not set
47
+ const getFilterType = React.useCallback((columnId: string) => {
47
48
  const column = table.getColumn(columnId);
48
49
  if (!column) return 'text';
49
50
 
50
51
  const columnDef = column.columnDef as any;
51
52
 
52
- // Check if column has explicit filter type configuration
53
- if (columnDef.filterType) {
54
- return columnDef.filterType;
53
+ // PRIORITY 1: Check if column has explicit filter type configuration
54
+ // This MUST be checked first and must respect any explicit value, including 'text'
55
+ // Use explicit !== undefined && !== null check to ensure 'text' is not treated as falsy
56
+ // This prevents auto-detection from overriding explicit filterType settings
57
+ const explicitFilterType = columnDef.filterType;
58
+ if (explicitFilterType !== undefined && explicitFilterType !== null && explicitFilterType !== '') {
59
+ // Explicit filterType set - return it immediately (no auto-detection)
60
+ // This ensures filterType: 'text' is always respected, even for columns with ≤10 unique values
61
+ return explicitFilterType as 'text' | 'select' | 'number' | 'date';
55
62
  }
56
63
 
57
- // Auto-detect select filter if filterSelectOptions is provided
64
+ // Only proceed with auto-detection if filterType was NOT explicitly set
65
+
66
+ // PRIORITY 2: Auto-detect select filter if filterSelectOptions is explicitly provided
58
67
  if (columnDef.filterSelectOptions && Array.isArray(columnDef.filterSelectOptions)) {
59
68
  return 'select';
60
69
  }
61
70
 
62
- // Check if it's a date column
71
+ // PRIORITY 3: Check if it's a date column (by column ID pattern)
63
72
  if (columnId.toLowerCase().includes('date') || columnId.toLowerCase().includes('time')) {
64
73
  return 'date';
65
74
  }
66
75
 
67
- // Check if it's a number column
76
+ // PRIORITY 4: Check if it's a number column (by data type)
68
77
  const firstValue = table.getRowModel().rows[0]?.getValue(columnId);
69
78
  if (typeof firstValue === 'number') {
70
79
  return 'number';
71
80
  }
72
81
 
73
- // Check if it has limited unique values (good for select)
82
+ // PRIORITY 5: Auto-detect select filter if limited unique values (≤10)
83
+ // Only runs if filterType was NOT explicitly set (checked above)
74
84
  const uniqueValues = getColumnOptions(columnId);
75
85
  if (uniqueValues.length <= 10 && uniqueValues.length > 1) {
76
86
  return 'select';
77
87
  }
78
88
 
89
+ // Default to text filter
79
90
  return 'text';
80
- };
91
+ }, [table, getColumnOptions]);
81
92
 
82
93
  return (
83
94
  <tr className="border-b bg-sec-50/50">
@@ -259,7 +259,7 @@ export function EnhancedPaginationControls<TData extends DataRecord>({
259
259
  max={pageCount}
260
260
  value={jumpToPage}
261
261
  onChange={(e) => setJumpToPage(e.target.value)}
262
- className="w-16 h-6 px-2 border rounded text-xs"
262
+ className="w-16 h-6 px-2 border rounded text-xs datatable-number-no-spinners"
263
263
  placeholder="1"
264
264
  />
265
265
  <Button type="submit" size="sm" variant="outline" className="h-6 px-2 text-xs">
@@ -21,7 +21,7 @@ import { ActionButtons } from './ActionButtons';
21
21
  import { EditableRow } from './EditableRow';
22
22
  import { getTableCellClasses, getTableHeadClasses, getTableRowClasses } from '../styles';
23
23
  import { Input } from '../../Input/Input';
24
- import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../Select/Select';
24
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, SelectGroup, SelectLabel, SelectSeparator } from '../../Select/Select';
25
25
  import type {
26
26
  AggregateConfig,
27
27
  DataRecord,
@@ -120,6 +120,168 @@ interface UnifiedTableBodyProps<TData extends DataRecord> {
120
120
  };
121
121
  }
122
122
 
123
+ // Component for select fields with searchable and creatable support
124
+ function SelectEditField<TData extends DataRecord>({
125
+ columnDef,
126
+ accessorKey,
127
+ currentValue,
128
+ placeholder,
129
+ onChange,
130
+ }: {
131
+ columnDef: EditableColumnDef<TData>;
132
+ accessorKey: string;
133
+ currentValue: CellValue;
134
+ placeholder?: string;
135
+ onChange: (value: CellValue) => void;
136
+ }) {
137
+ // Determine if searchable - explicitly check for true to ensure visible search input appears
138
+ // When selectSearchable is true or undefined, show the visible search input box
139
+ // When selectSearchable is false, hide the search input (type-to-search still works via SelectContent internals)
140
+ const isSearchable = columnDef.selectSearchable !== false;
141
+ const isCreatable = columnDef.creatable === true;
142
+ const selectRef = React.useRef<HTMLFormElement>(null);
143
+ const [searchTerm, setSearchTerm] = React.useState('');
144
+ const [isOpen, setIsOpen] = React.useState(false);
145
+ const [showCreateOption, setShowCreateOption] = React.useState(false);
146
+
147
+ // Monitor search input value via DOM events to detect when user types
148
+ React.useEffect(() => {
149
+ if (!isOpen || !isSearchable || !isCreatable || !selectRef.current) return;
150
+
151
+ const searchInput = selectRef.current.querySelector<HTMLInputElement>('[data-testid="select-search-input"]');
152
+ if (!searchInput) return;
153
+
154
+ const handleInput = (e: Event) => {
155
+ const target = e.target as HTMLInputElement;
156
+ const currentSearch = target.value;
157
+ setSearchTerm(currentSearch);
158
+
159
+ // Check if search doesn't match any option (including items in groups)
160
+ if (currentSearch.trim()) {
161
+ const searchLower = currentSearch.toLowerCase().trim();
162
+
163
+ // Helper to check if an option matches
164
+ // Use explicit union type instead of typeof to avoid Babel parsing issues
165
+ type FieldOption =
166
+ | { value: string | number; label: string }
167
+ | { type: 'group'; label: string; items: Array<{ value: string | number; label: string }> }
168
+ | { type: 'separator' };
169
+
170
+ const checkMatch = (opt: FieldOption): boolean => {
171
+ // Simple option
172
+ if ('value' in opt && !('type' in opt)) {
173
+ return opt.label.toLowerCase().includes(searchLower);
174
+ }
175
+ // Group - check items within the group
176
+ if ('type' in opt && opt.type === 'group') {
177
+ return (opt as { type: 'group'; label: string; items: Array<{ value: string | number; label: string }> }).items.some((item: { value: string | number; label: string }) => item.label.toLowerCase().includes(searchLower));
178
+ }
179
+ // Separator - doesn't match
180
+ return false;
181
+ };
182
+
183
+ const hasMatch = (columnDef.fieldOptions || []).some(checkMatch);
184
+ setShowCreateOption(!hasMatch);
185
+ } else {
186
+ setShowCreateOption(false);
187
+ }
188
+ };
189
+
190
+ searchInput.addEventListener('input', handleInput);
191
+
192
+ return () => {
193
+ searchInput.removeEventListener('input', handleInput);
194
+ };
195
+ }, [isOpen, isSearchable, isCreatable, columnDef.fieldOptions]);
196
+
197
+ const handleCreateNew = React.useCallback(async () => {
198
+ if (!isCreatable || !columnDef.onCreateNew || !searchTerm.trim()) return;
199
+
200
+ try {
201
+ const newValue = await columnDef.onCreateNew(searchTerm.trim());
202
+ onChange(newValue);
203
+ setSearchTerm('');
204
+ setShowCreateOption(false);
205
+ } catch (error) {
206
+ console.error('Error creating new item:', error);
207
+ }
208
+ }, [isCreatable, columnDef.onCreateNew, searchTerm, onChange]);
209
+
210
+ return (
211
+ <Select
212
+ ref={selectRef}
213
+ value={String(currentValue)}
214
+ onValueChange={(newValue) => {
215
+ if (newValue.startsWith('__create_new__')) {
216
+ handleCreateNew();
217
+ } else {
218
+ onChange(newValue as CellValue);
219
+ }
220
+ }}
221
+ onOpenChange={(open) => {
222
+ setIsOpen(open);
223
+ if (!open) {
224
+ setSearchTerm('');
225
+ setShowCreateOption(false);
226
+ }
227
+ }}
228
+ >
229
+ <SelectTrigger className="h-8">
230
+ <SelectValue placeholder={placeholder || `Select ${columnDef.header || 'option'}...`} />
231
+ </SelectTrigger>
232
+ <SelectContent
233
+ searchable={Boolean(isSearchable)}
234
+ searchPlaceholder={`Search ${columnDef.header || 'options'}...`}
235
+ maxHeight={columnDef.selectMaxHeight}
236
+ className={columnDef.selectContentClassName}
237
+ style={columnDef.selectContentStyle}
238
+ >
239
+ {columnDef.fieldOptions?.map((option, index) => {
240
+ // Simple option item
241
+ if ('value' in option && !('type' in option)) {
242
+ return (
243
+ <SelectItem key={`${option.value}-${index}`} value={String(option.value)}>
244
+ {option.label}
245
+ </SelectItem>
246
+ );
247
+ }
248
+
249
+ // Separator
250
+ if ('type' in option && option.type === 'separator') {
251
+ return <SelectSeparator key={`separator-${index}`} />;
252
+ }
253
+
254
+ // Group with label
255
+ if ('type' in option && option.type === 'group') {
256
+ const groupOption = option as { type: 'group'; label: string; items: Array<{ value: string | number; label: string }> };
257
+ return (
258
+ <SelectGroup key={`group-${groupOption.label}-${index}`}>
259
+ <SelectLabel>{groupOption.label}</SelectLabel>
260
+ {groupOption.items.map((item: { value: string | number; label: string }) => (
261
+ <SelectItem key={`${item.value}-${index}`} value={String(item.value)}>
262
+ {item.label}
263
+ </SelectItem>
264
+ ))}
265
+ </SelectGroup>
266
+ );
267
+ }
268
+
269
+ return null;
270
+ })}
271
+ {showCreateOption && isCreatable && searchTerm.trim() && (
272
+ <SelectItem
273
+ key="__create_new__"
274
+ value={`__create_new__${searchTerm}`}
275
+ className="bg-main-100 font-medium border-t border-main-200"
276
+ >
277
+ Create "{searchTerm}"
278
+ </SelectItem>
279
+ )}
280
+ </SelectContent>
281
+ </Select>
282
+ );
283
+ }
284
+
123
285
  // Helper function to render the appropriate input type based on column configuration
124
286
  const renderEditField = <TData extends DataRecord>(
125
287
  column: Column<TData, unknown>,
@@ -143,33 +305,29 @@ const renderEditField = <TData extends DataRecord>(
143
305
  const currentValue = editingData[accessorKey] ?? value ?? '';
144
306
 
145
307
  return (
146
- <Select
147
- value={String(currentValue)}
148
- onValueChange={(newValue) => onChange({ [accessorKey]: newValue as CellValue })}
149
- >
150
- <SelectTrigger className="h-8">
151
- <SelectValue placeholder={placeholder || `Select ${columnDef.header || column.id}...`} />
152
- </SelectTrigger>
153
- <SelectContent>
154
- {columnDef.fieldOptions.map(option => (
155
- <SelectItem key={option.value} value={String(option.value)}>
156
- {option.label}
157
- </SelectItem>
158
- ))}
159
- </SelectContent>
160
- </Select>
308
+ <SelectEditField
309
+ columnDef={columnDef}
310
+ accessorKey={accessorKey}
311
+ currentValue={currentValue}
312
+ placeholder={placeholder}
313
+ onChange={(newValue) => onChange({ [accessorKey]: newValue })}
314
+ />
161
315
  );
162
316
  }
163
317
 
164
- // Check for number type
318
+ // Check for number type (applies to number, currency, and percentage fields)
165
319
  if (columnDef.fieldType === 'number') {
320
+ // Hide spinner arrows by default for all number-related fields
321
+ // Currency and percentage columns use fieldType: 'number' with formatting in cell renderer
322
+ // Only show spinners if explicitly set to false
323
+ const hideSpinners = columnDef.hideNumberSpinners !== false; // Default to true
166
324
  return (
167
325
  <Input
168
326
  type="number"
169
327
  value={String(value ?? '')}
170
328
  onChange={(e) => onChange(e.target.value as unknown as CellValue)}
171
329
  placeholder={placeholder || `Enter ${columnDef.header || column.id}...`}
172
- className="h-8"
330
+ className={`h-8 ${hideSpinners ? 'datatable-number-no-spinners' : ''}`}
173
331
  />
174
332
  );
175
333
  }
@@ -709,24 +867,65 @@ export function UnifiedTableBody<TData extends Record<string, any>>({
709
867
  ? header.column.getIsVisible()
710
868
  : true;
711
869
  })
712
- ?.filter(header => header.column.id !== 'actions' && header.column.id !== 'select')
713
- ?.map((header) => (
714
- <td
715
- key={header.column.id}
716
- className={getTableCellClasses({
717
- isCompact: true,
718
- className: "px-3 py-2"
719
- })}
720
- >
721
- {renderEditField(header.column, creationData[header.column.id], (value) => {
870
+ ?.filter(header => header.column.id !== 'actions') // Only exclude actions column
871
+ ?.map((header) => {
872
+ // Handle select column separately - render empty checkbox cell for alignment
873
+ if (header.column.id === 'select') {
874
+ return (
875
+ <td
876
+ key={header.column.id}
877
+ className={getTableCellClasses({
878
+ isCompact: true,
879
+ className: "px-3 py-2"
880
+ })}
881
+ >
882
+ {/* Empty cell for selection checkbox to maintain alignment */}
883
+ </td>
884
+ );
885
+ }
886
+
887
+ // Render edit fields for data columns
888
+ // Determine the correct key to use for creationData
889
+ // Priority: editAccessorKey > accessorKey > column.id
890
+ const columnDef = header.column.columnDef as EditableColumnDef<TData>;
891
+ const dataKey = columnDef.editAccessorKey || columnDef.accessorKey || header.column.id;
892
+
893
+ // Always render a cell to maintain alignment - renderEditField always returns something
894
+ const editField = renderEditField(
895
+ header.column,
896
+ creationData[dataKey] ?? creationData[header.column.id] ?? '',
897
+ (value) => {
722
898
  if (typeof value === 'object' && value !== null && !Array.isArray(value) && !(value instanceof Date)) {
723
899
  onCreationDataChange({ ...creationData, ...(value as Record<string, CellValue>) });
724
900
  } else {
725
- onCreationDataChange({ ...creationData, [header.column.id]: value as CellValue });
901
+ // Use the determined dataKey for consistent data access
902
+ onCreationDataChange({ ...creationData, [dataKey]: value as CellValue });
726
903
  }
727
- }, creationData)}
728
- </td>
729
- ))}
904
+ },
905
+ creationData
906
+ );
907
+
908
+ return (
909
+ <td
910
+ key={header.column.id}
911
+ className={getTableCellClasses({
912
+ isCompact: true,
913
+ className: "px-3 py-2"
914
+ })}
915
+ >
916
+ {editField || (
917
+ // Fallback: render a text input if renderEditField somehow returns nothing
918
+ <Input
919
+ type="text"
920
+ value={String(creationData[dataKey] ?? creationData[header.column.id] ?? '')}
921
+ onChange={(e) => onCreationDataChange({ ...creationData, [dataKey]: e.target.value as CellValue })}
922
+ placeholder={`Enter ${columnDef.header || header.column.id}...`}
923
+ className="h-8"
924
+ />
925
+ )}
926
+ </td>
927
+ );
928
+ })}
730
929
  <td
731
930
  className={getTableCellClasses({
732
931
  isCompact: true,
@@ -292,8 +292,26 @@ export interface DataTableColumn<TData extends DataRecord = DataRecord> extends
292
292
  memoizedCell?: React.ComponentType<{ row: TData }>;
293
293
  /** Field type for editing (text, select, date, etc.) */
294
294
  fieldType?: 'text' | 'select' | 'date' | 'number' | 'boolean';
295
- /** Options for select fields */
296
- fieldOptions?: Array<{ value: string | number; label: string }>;
295
+ /** Options for select fields - can be simple items or grouped with labels and separators */
296
+ fieldOptions?: Array<
297
+ | { value: string | number; label: string } // Simple option
298
+ | { type: 'group'; label: string; items: Array<{ value: string | number; label: string }> } // Group with label
299
+ | { type: 'separator' } // Visual separator
300
+ >;
301
+ /** Enable keyboard search/filtering in select dropdowns within editable columns (default: true). When fieldType is 'select', this controls dropdown searchability, not global search. */
302
+ selectSearchable?: boolean;
303
+ /** Enable creating new items in select dropdowns (default: false) */
304
+ creatable?: boolean;
305
+ /** Callback to create a new item when user types non-matching text in select dropdown */
306
+ onCreateNew?: (inputValue: string) => Promise<string | number> | string | number;
307
+ /** Maximum height for select dropdown content (default: "20rem") */
308
+ selectMaxHeight?: string;
309
+ /** Custom className for select content dropdown */
310
+ selectContentClassName?: string;
311
+ /** Custom style for select content dropdown */
312
+ selectContentStyle?: React.CSSProperties;
313
+ /** Hide spinner arrows on number input fields (default: true for DataTable) */
314
+ hideNumberSpinners?: boolean;
297
315
  /** Filter type for column filtering (text, select, number, date) */
298
316
  filterType?: 'text' | 'select' | 'number' | 'date';
299
317
  /** Options for select filters (alternative to fieldOptions) */
@@ -320,8 +338,20 @@ export interface EditableColumnDef<TData extends DataRecord = DataRecord> extend
320
338
  editable?: boolean;
321
339
  /** Field type used to determine edit control */
322
340
  fieldType?: 'text' | 'select' | 'date' | 'number' | 'boolean';
323
- /** Options for select based editors */
324
- fieldOptions?: Array<{ value: string | number; label: string }>;
341
+ /** Options for select based editors - can be simple items or grouped with labels and separators */
342
+ fieldOptions?: Array<
343
+ | { value: string | number; label: string } // Simple option
344
+ | { type: 'group'; label: string; items: Array<{ value: string | number; label: string }> } // Group with label
345
+ | { type: 'separator' } // Visual separator
346
+ >;
347
+ /** Enable keyboard search/filtering in select dropdowns (inherits from DataTableColumn.selectSearchable) */
348
+ selectSearchable?: boolean;
349
+ /** Enable creating new items in select dropdowns */
350
+ creatable?: boolean;
351
+ /** Callback to create a new item when user types non-matching text */
352
+ onCreateNew?: (inputValue: string) => Promise<string | number> | string | number;
353
+ /** Hide spinner arrows on number input fields */
354
+ hideNumberSpinners?: boolean;
325
355
  }
326
356
 
327
357
  /**