torch-glare 2.1.0 → 2.1.2

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 (65) hide show
  1. package/apps/lib/components/Badge.tsx +34 -137
  2. package/apps/lib/components/BadgeField.tsx +4 -4
  3. package/apps/lib/components/DataViews/ARCHITECTURE.md +439 -0
  4. package/apps/lib/components/DataViews/DataViewsConfigPanel.tsx +416 -0
  5. package/apps/lib/components/DataViews/DataViewsHeader.tsx +126 -0
  6. package/apps/lib/components/DataViews/DataViewsLayout.tsx +300 -0
  7. package/apps/lib/components/DataViews/FilterPanel.tsx +324 -0
  8. package/apps/lib/components/DataViews/InboxView.tsx +514 -0
  9. package/apps/lib/components/DataViews/KanbanView.tsx +242 -0
  10. package/apps/lib/components/DataViews/PanelControls.tsx +80 -0
  11. package/apps/lib/components/DataViews/SettingsPanel.tsx +285 -0
  12. package/apps/lib/components/DataViews/TableView.tsx +232 -0
  13. package/apps/lib/components/DataViews/TreeView.tsx +363 -0
  14. package/apps/lib/components/DataViews/badgeAdapter.ts +45 -0
  15. package/apps/lib/components/DataViews/fieldRenderers.tsx +334 -0
  16. package/apps/lib/components/DataViews/filters/DateRangePopover.tsx +113 -0
  17. package/apps/lib/components/DataViews/filters/PresetChips.tsx +45 -0
  18. package/apps/lib/components/DataViews/filters/RangeSliderWithInputs.tsx +154 -0
  19. package/apps/lib/components/DataViews/index.ts +30 -0
  20. package/apps/lib/components/DataViews/tree/TreeDrawer.tsx +54 -0
  21. package/apps/lib/components/DataViews/tree/TreeSidebar.tsx +77 -0
  22. package/apps/lib/components/DataViews/types.ts +177 -0
  23. package/apps/lib/components/TreeFolder/TreeFolder.tsx +387 -0
  24. package/apps/lib/components/TreeFolder/TreeFolderBreadcrumb.tsx +80 -0
  25. package/apps/lib/components/TreeFolder/TreeFolderRow.tsx +235 -0
  26. package/apps/lib/components/TreeFolder/TreeFolderStyles.tsx +60 -0
  27. package/apps/lib/components/TreeFolder/icons.tsx +63 -0
  28. package/apps/lib/components/TreeFolder/index.ts +17 -0
  29. package/apps/lib/components/TreeFolder/treeFolderUtils.ts +114 -0
  30. package/apps/lib/components/TreeFolder/types.ts +68 -0
  31. package/apps/lib/components/TreeFolder/useTreeFolderDnD.ts +261 -0
  32. package/apps/lib/hooks/useDataViewsState.ts +169 -0
  33. package/apps/lib/hooks/useIsMobile.ts +21 -0
  34. package/apps/lib/utils/dataViews/columnUtils.ts +130 -0
  35. package/apps/lib/utils/dataViews/fieldUtils.ts +198 -0
  36. package/apps/lib/utils/dataViews/nestedDataUtils.tsx +364 -0
  37. package/apps/lib/utils/dataViews/pathUtils.ts +132 -0
  38. package/apps/lib/utils/dataViews/rangeUtils.ts +225 -0
  39. package/apps/lib/utils/dataViews/treeUtils.ts +403 -0
  40. package/dist/bin/index.js +3 -3
  41. package/dist/bin/index.js.map +1 -1
  42. package/dist/src/commands/add.d.ts.map +1 -1
  43. package/dist/src/commands/add.js +29 -6
  44. package/dist/src/commands/add.js.map +1 -1
  45. package/dist/src/commands/utils.d.ts.map +1 -1
  46. package/dist/src/commands/utils.js +22 -2
  47. package/dist/src/commands/utils.js.map +1 -1
  48. package/dist/src/shared/copyComponentsRecursively.d.ts.map +1 -1
  49. package/dist/src/shared/copyComponentsRecursively.js +8 -1
  50. package/dist/src/shared/copyComponentsRecursively.js.map +1 -1
  51. package/dist/src/shared/getDependenciesAndInstallNestedComponents.d.ts +18 -4
  52. package/dist/src/shared/getDependenciesAndInstallNestedComponents.d.ts.map +1 -1
  53. package/dist/src/shared/getDependenciesAndInstallNestedComponents.js +110 -40
  54. package/dist/src/shared/getDependenciesAndInstallNestedComponents.js.map +1 -1
  55. package/docs/components/badge-field.md +21 -21
  56. package/docs/components/badge.md +156 -483
  57. package/docs/components/form-stepper.md +244 -0
  58. package/docs/components/stepper.md +215 -0
  59. package/docs/components/timeline.md +248 -0
  60. package/docs/reference/components.md +8 -7
  61. package/docs/reference/types.md +34 -26
  62. package/docs/tutorials/theming-basics.md +30 -27
  63. package/package.json +1 -1
  64. /package/docs/components/{labeled-checkbox.md → labeled-check-box.md} +0 -0
  65. /package/docs/components/{tree-dropdown.md → tree-drop-down.md} +0 -0
@@ -0,0 +1,261 @@
1
+ "use client"
2
+
3
+ import { useCallback, useEffect, useRef, useState } from "react"
4
+ import { isAncestor } from "./treeFolderUtils"
5
+ import type {
6
+ TreeFolderDropPosition,
7
+ TreeFolderDropTarget,
8
+ TreeFolderMoveArgs,
9
+ TreeFolderNode,
10
+ TreeFolderVisibleRow,
11
+ } from "./types"
12
+
13
+ type RowLookup = Map<string, TreeFolderVisibleRow>
14
+
15
+ const SCROLL_EDGE_PX = 32
16
+ const SCROLL_SPEED_PX_PER_FRAME = 8
17
+ const DRAG_DATA_MIME = "application/x-treefolder-ids"
18
+
19
+ export type UseTreeFolderDnDOptions = {
20
+ data: TreeFolderNode[]
21
+ rowsById: RowLookup
22
+ scrollContainerRef: React.RefObject<HTMLDivElement | null>
23
+ enabled: boolean
24
+ onMove?: (args: TreeFolderMoveArgs) => void
25
+ /**
26
+ * Predicate. Return false to forbid the drop. Defaults to allow except for
27
+ * dropping a node into itself or a descendant.
28
+ */
29
+ canDrop?: (args: {
30
+ dragIds: string[]
31
+ parentId: string | null
32
+ index: number
33
+ }) => boolean
34
+ }
35
+
36
+ export type UseTreeFolderDnDResult = {
37
+ dragIds: string[]
38
+ dropTarget: TreeFolderDropTarget | null
39
+ getRowDragHandlers: (rowId: string) => {
40
+ draggable: boolean
41
+ onDragStart: (e: React.DragEvent<HTMLElement>) => void
42
+ onDragEnd: (e: React.DragEvent<HTMLElement>) => void
43
+ onDragOver: (e: React.DragEvent<HTMLElement>) => void
44
+ onDragLeave: (e: React.DragEvent<HTMLElement>) => void
45
+ onDrop: (e: React.DragEvent<HTMLElement>) => void
46
+ }
47
+ }
48
+
49
+ export function useTreeFolderDnD({
50
+ data,
51
+ rowsById,
52
+ scrollContainerRef,
53
+ enabled,
54
+ onMove,
55
+ canDrop,
56
+ }: UseTreeFolderDnDOptions): UseTreeFolderDnDResult {
57
+ const [dragIds, setDragIds] = useState<string[]>([])
58
+ const [dropTarget, setDropTarget] = useState<TreeFolderDropTarget | null>(null)
59
+
60
+ // Auto-scroll loop while the user is dragging near a container edge.
61
+ const autoScrollDeltaRef = useRef(0)
62
+ const autoScrollRafRef = useRef<number | null>(null)
63
+
64
+ const stopAutoScroll = useCallback(() => {
65
+ autoScrollDeltaRef.current = 0
66
+ if (autoScrollRafRef.current != null) {
67
+ cancelAnimationFrame(autoScrollRafRef.current)
68
+ autoScrollRafRef.current = null
69
+ }
70
+ }, [])
71
+
72
+ const tickAutoScroll = useCallback(() => {
73
+ const el = scrollContainerRef.current
74
+ const delta = autoScrollDeltaRef.current
75
+ if (!el || delta === 0) {
76
+ autoScrollRafRef.current = null
77
+ return
78
+ }
79
+ el.scrollTop += delta
80
+ autoScrollRafRef.current = requestAnimationFrame(tickAutoScroll)
81
+ }, [scrollContainerRef])
82
+
83
+ const maybeAutoScroll = useCallback(
84
+ (clientY: number) => {
85
+ const el = scrollContainerRef.current
86
+ if (!el) return
87
+ const rect = el.getBoundingClientRect()
88
+ let delta = 0
89
+ if (clientY - rect.top < SCROLL_EDGE_PX) delta = -SCROLL_SPEED_PX_PER_FRAME
90
+ else if (rect.bottom - clientY < SCROLL_EDGE_PX) delta = SCROLL_SPEED_PX_PER_FRAME
91
+ autoScrollDeltaRef.current = delta
92
+ if (delta !== 0 && autoScrollRafRef.current == null) {
93
+ autoScrollRafRef.current = requestAnimationFrame(tickAutoScroll)
94
+ } else if (delta === 0) {
95
+ stopAutoScroll()
96
+ }
97
+ },
98
+ [scrollContainerRef, stopAutoScroll, tickAutoScroll],
99
+ )
100
+
101
+ useEffect(() => stopAutoScroll, [stopAutoScroll])
102
+
103
+ const computePosition = useCallback(
104
+ (target: HTMLElement, clientY: number, isInternal: boolean): TreeFolderDropPosition => {
105
+ const rect = target.getBoundingClientRect()
106
+ const offset = clientY - rect.top
107
+ const ratio = offset / rect.height
108
+ // Leaves can't accept "inside" drops — squash that zone into before/after.
109
+ if (!isInternal) return ratio < 0.5 ? "before" : "after"
110
+ if (ratio < 0.25) return "before"
111
+ if (ratio > 0.75) return "after"
112
+ return "inside"
113
+ },
114
+ [],
115
+ )
116
+
117
+ const resolveMoveArgs = useCallback(
118
+ (drag: string[], drop: TreeFolderDropTarget): TreeFolderMoveArgs | null => {
119
+ const targetRow = rowsById.get(drop.rowId)
120
+ if (!targetRow) return null
121
+
122
+ let parentId: string | null
123
+ let index: number
124
+
125
+ if (drop.position === "inside") {
126
+ parentId = targetRow.node.id
127
+ // Drop at the end of the target's children.
128
+ index = targetRow.node.children?.length ?? 0
129
+ } else {
130
+ parentId = targetRow.parentId
131
+ index = drop.position === "before" ? targetRow.childIndex : targetRow.childIndex + 1
132
+ }
133
+
134
+ // Reject: dropping into self or any descendant.
135
+ for (const id of drag) {
136
+ const node = rowsById.get(id)?.node
137
+ if (!node) continue
138
+ if (parentId && (node.id === parentId || isAncestor(node, parentId))) return null
139
+ }
140
+
141
+ if (canDrop && !canDrop({ dragIds: drag, parentId, index })) return null
142
+
143
+ // No-op safety: skipping the equivalent of "drop where you already are".
144
+ if (drag.length === 1) {
145
+ const onlyDrag = rowsById.get(drag[0])
146
+ if (
147
+ onlyDrag &&
148
+ onlyDrag.parentId === parentId &&
149
+ (onlyDrag.childIndex === index || onlyDrag.childIndex + 1 === index)
150
+ ) {
151
+ return null
152
+ }
153
+ }
154
+
155
+ return { dragIds: drag, parentId, index }
156
+ },
157
+ [rowsById, canDrop],
158
+ )
159
+
160
+ const getRowDragHandlers = useCallback(
161
+ (rowId: string) => {
162
+ if (!enabled) {
163
+ return {
164
+ draggable: false,
165
+ onDragStart: () => {},
166
+ onDragEnd: () => {},
167
+ onDragOver: () => {},
168
+ onDragLeave: () => {},
169
+ onDrop: () => {},
170
+ }
171
+ }
172
+ return {
173
+ draggable: true,
174
+ onDragStart: (e: React.DragEvent<HTMLElement>) => {
175
+ const ids = [rowId]
176
+ setDragIds(ids)
177
+ e.dataTransfer.effectAllowed = "move"
178
+ try {
179
+ e.dataTransfer.setData(DRAG_DATA_MIME, ids.join(","))
180
+ // Required for Firefox to initiate the drag at all.
181
+ e.dataTransfer.setData("text/plain", ids.join(","))
182
+ } catch {
183
+ // setData can throw in some sandboxed contexts; ignore.
184
+ }
185
+ },
186
+ onDragEnd: () => {
187
+ setDragIds([])
188
+ setDropTarget(null)
189
+ stopAutoScroll()
190
+ },
191
+ onDragOver: (e: React.DragEvent<HTMLElement>) => {
192
+ if (dragIds.length === 0) return
193
+ // Reject self-drops upfront so the cursor reflects "no drop" outside the tree.
194
+ if (dragIds.includes(rowId)) {
195
+ e.dataTransfer.dropEffect = "none"
196
+ return
197
+ }
198
+ const row = rowsById.get(rowId)
199
+ if (!row) return
200
+ // Reject dropping onto a descendant of any dragged node.
201
+ for (const dragId of dragIds) {
202
+ const dragNode = rowsById.get(dragId)?.node
203
+ if (dragNode && isAncestor(dragNode, rowId)) {
204
+ e.dataTransfer.dropEffect = "none"
205
+ return
206
+ }
207
+ }
208
+ e.preventDefault()
209
+ e.dataTransfer.dropEffect = "move"
210
+ const position = computePosition(e.currentTarget, e.clientY, row.isInternal)
211
+ // Only setState when something actually changed (avoids re-render storms).
212
+ setDropTarget((prev) =>
213
+ prev && prev.rowId === rowId && prev.position === position
214
+ ? prev
215
+ : { rowId, position },
216
+ )
217
+ maybeAutoScroll(e.clientY)
218
+ },
219
+ onDragLeave: () => {
220
+ // No-op: onDragOver on the next row will overwrite dropTarget. Clearing
221
+ // here would flicker the indicator during normal row-to-row hovering.
222
+ },
223
+ onDrop: (e: React.DragEvent<HTMLElement>) => {
224
+ if (dragIds.length === 0) return
225
+ e.preventDefault()
226
+ const row = rowsById.get(rowId)
227
+ if (!row) {
228
+ setDragIds([])
229
+ setDropTarget(null)
230
+ stopAutoScroll()
231
+ return
232
+ }
233
+ const position = computePosition(e.currentTarget, e.clientY, row.isInternal)
234
+ const moveArgs = resolveMoveArgs(dragIds, { rowId, position })
235
+ if (moveArgs) onMove?.(moveArgs)
236
+ setDragIds([])
237
+ setDropTarget(null)
238
+ stopAutoScroll()
239
+ },
240
+ }
241
+ },
242
+ [
243
+ enabled,
244
+ dragIds,
245
+ rowsById,
246
+ computePosition,
247
+ resolveMoveArgs,
248
+ onMove,
249
+ maybeAutoScroll,
250
+ stopAutoScroll,
251
+ ],
252
+ )
253
+
254
+ // If the data tree changes mid-drag, clear stale targets.
255
+ useEffect(() => {
256
+ if (dragIds.length === 0) return
257
+ setDropTarget((prev) => (prev && rowsById.has(prev.rowId) ? prev : null))
258
+ }, [data, rowsById, dragIds.length])
259
+
260
+ return { dragIds, dropTarget, getRowDragHandlers }
261
+ }
@@ -0,0 +1,169 @@
1
+ "use client"
2
+
3
+ import { useEffect, useMemo, useState } from "react"
4
+ import type {
5
+ DynamicRecord,
6
+ DynamicColumnConfig,
7
+ FieldConfig,
8
+ FilterState,
9
+ TreeConfig,
10
+ ViewConfig,
11
+ ViewType,
12
+ ViewVisibility,
13
+ } from "../components/DataViews/types"
14
+ import { defaultConfig } from "../components/DataViews/types"
15
+ import { detectColumns, mergeColumns } from "../utils/dataViews/columnUtils"
16
+ import { detectFields, mergeFields } from "../utils/dataViews/fieldUtils"
17
+ import { autoDetectTreeShape, flattenAll } from "../utils/dataViews/treeUtils"
18
+
19
+ export type UseDataViewsStateOptions = {
20
+ data?: DynamicRecord[]
21
+ fields?: FieldConfig[]
22
+ columns?: Partial<DynamicColumnConfig>[]
23
+ config?: Partial<ViewConfig>
24
+ treeConfig?: TreeConfig
25
+ views?: ViewVisibility
26
+ filterState?: FilterState
27
+ onFilterChange?: (filters: FilterState) => void
28
+ }
29
+
30
+ export function useDataViewsState({
31
+ data,
32
+ fields,
33
+ columns,
34
+ config: initialConfig,
35
+ treeConfig,
36
+ views,
37
+ filterState: externalFilterState,
38
+ onFilterChange,
39
+ }: UseDataViewsStateOptions) {
40
+ const [currentView, setCurrentView] = useState<ViewType>(
41
+ initialConfig?.defaultView || defaultConfig.defaultView,
42
+ )
43
+ const [config, setConfig] = useState<ViewConfig>({ ...defaultConfig, ...initialConfig })
44
+ const [items, setItems] = useState<DynamicRecord[]>(data || [])
45
+ const [internalFilterState, setInternalFilterState] = useState<FilterState>({})
46
+
47
+ useEffect(() => {
48
+ setItems(data || [])
49
+ }, [data])
50
+
51
+ const activeFilterState = externalFilterState ?? internalFilterState
52
+
53
+ const treeShape = useMemo(
54
+ () => autoDetectTreeShape(items, treeConfig ?? {}),
55
+ [items, treeConfig],
56
+ )
57
+ const treeAutoAvailable =
58
+ !!treeConfig || !!treeShape.childrenField || !!treeShape.parentField
59
+
60
+ const enabledViews = useMemo<Record<ViewType, boolean>>(() => {
61
+ const v = views ?? {}
62
+ return {
63
+ table: v.table ?? true,
64
+ kanban: v.kanban ?? true,
65
+ inbox: v.inbox ?? true,
66
+ tree: v.tree ?? treeAutoAvailable,
67
+ }
68
+ }, [views, treeAutoAvailable])
69
+
70
+ useEffect(() => {
71
+ if (!enabledViews[currentView]) {
72
+ const fallback = (Object.entries(enabledViews) as Array<[ViewType, boolean]>)
73
+ .find(([, on]) => on)?.[0]
74
+ if (fallback) setCurrentView(fallback)
75
+ }
76
+ }, [enabledViews, currentView])
77
+
78
+ const flatItems = useMemo<DynamicRecord[]>(() => {
79
+ const cf = treeConfig?.childrenField ?? treeShape.childrenField
80
+ if (!cf) return items
81
+ return flattenAll(items, cf)
82
+ }, [items, treeConfig?.childrenField, treeShape.childrenField])
83
+
84
+ const detectedFields = useMemo<FieldConfig[]>(() => {
85
+ if (!flatItems || flatItems.length === 0) return []
86
+ const detected = detectFields(flatItems)
87
+ return mergeFields(detected, fields)
88
+ }, [flatItems, fields])
89
+
90
+ const resolvedFields = useMemo<FieldConfig[]>(() => {
91
+ if (detectedFields.length === 0) return detectedFields
92
+ const overrides = new Map(config.tableColumns.map((c) => [c.id, c]))
93
+ return detectedFields
94
+ .map((f) => {
95
+ const o = overrides.get(f.path)
96
+ if (!o) return f
97
+ return { ...f, visible: o.visible, order: o.order }
98
+ })
99
+ .sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
100
+ }, [detectedFields, config.tableColumns])
101
+
102
+ const detectedColumns = useMemo<DynamicColumnConfig[]>(() => {
103
+ if (!flatItems || flatItems.length === 0) return []
104
+ const detected = detectColumns(flatItems)
105
+ return mergeColumns(detected, columns)
106
+ }, [flatItems, columns])
107
+
108
+ useEffect(() => {
109
+ if (detectedFields.length === 0) return
110
+ setConfig((prev) => {
111
+ const prevByPath = new Map(prev.tableColumns.map((c) => [c.id, c]))
112
+ const next: typeof prev.tableColumns = []
113
+ let order = 0
114
+ for (const f of detectedFields) {
115
+ if (f.type === "hidden") continue
116
+ const existing = prevByPath.get(f.path)
117
+ next.push({
118
+ id: f.path,
119
+ label: f.label ?? f.path,
120
+ visible: existing?.visible ?? f.visible !== false,
121
+ order: existing?.order ?? order,
122
+ })
123
+ order++
124
+ prevByPath.delete(f.path)
125
+ }
126
+ const stale = Array.from(prevByPath.values())
127
+ const carried = [...next, ...stale].slice(0, 100)
128
+ if (
129
+ carried.length === prev.tableColumns.length &&
130
+ carried.every((c, i) => {
131
+ const p = prev.tableColumns[i]
132
+ return p && p.id === c.id && p.label === c.label && p.visible === c.visible && p.order === c.order
133
+ })
134
+ ) {
135
+ return prev
136
+ }
137
+ return { ...prev, tableColumns: carried }
138
+ })
139
+ }, [detectedFields])
140
+
141
+ const handleConfigChange = (newConfig: Partial<ViewConfig>) => {
142
+ setConfig((prev) => ({ ...prev, ...newConfig }))
143
+ }
144
+
145
+ const handleDataUpdate = (updatedData: DynamicRecord[]) => {
146
+ setItems(updatedData)
147
+ }
148
+
149
+ const handleFilterChange = (newFilters: FilterState) => {
150
+ if (onFilterChange) onFilterChange(newFilters)
151
+ else setInternalFilterState(newFilters)
152
+ }
153
+
154
+ return {
155
+ items,
156
+ flatItems,
157
+ resolvedFields,
158
+ detectedColumns,
159
+ config,
160
+ setConfig: handleConfigChange,
161
+ currentView,
162
+ setCurrentView,
163
+ filterState: activeFilterState,
164
+ setFilterState: handleFilterChange,
165
+ onDataUpdate: handleDataUpdate,
166
+ treeShape,
167
+ enabledViews,
168
+ }
169
+ }
@@ -0,0 +1,21 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+
5
+ const MOBILE_BREAKPOINT = 768
6
+
7
+ export function useIsMobile() {
8
+ const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
9
+
10
+ React.useEffect(() => {
11
+ const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
12
+ const onChange = () => {
13
+ setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
14
+ }
15
+ mql.addEventListener("change", onChange)
16
+ setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
17
+ return () => mql.removeEventListener("change", onChange)
18
+ }, [])
19
+
20
+ return !!isMobile
21
+ }
@@ -0,0 +1,130 @@
1
+ import type { DynamicColumnConfig, DynamicRecord } from "../../components/DataViews/types"
2
+ import { isPlainObject } from "./nestedDataUtils"
3
+
4
+ export function detectColumns(data: DynamicRecord[]): DynamicColumnConfig[] {
5
+ if (!data || data.length === 0) {
6
+ return []
7
+ }
8
+
9
+ const allKeys = new Set<string>()
10
+ data.forEach((item) => {
11
+ Object.keys(item).forEach((key) => allKeys.add(key))
12
+ })
13
+
14
+ const columns: DynamicColumnConfig[] = []
15
+ let order = 0
16
+
17
+ allKeys.forEach((key) => {
18
+ if (key.startsWith("_")) return
19
+
20
+ const firstValue = data.find((item) => item[key] != null)?.[key]
21
+
22
+ if (isPlainObject(firstValue)) return
23
+ if (Array.isArray(firstValue) && firstValue.length > 0 && isPlainObject(firstValue[0])) return
24
+
25
+ const type = inferColumnType(key, firstValue)
26
+
27
+ columns.push({
28
+ id: key,
29
+ label: formatLabel(key),
30
+ visible: true,
31
+ order: order++,
32
+ type,
33
+ })
34
+ })
35
+
36
+ return columns.sort((a, b) => {
37
+ if (a.id === "id") return -1
38
+ if (b.id === "id") return 1
39
+ return a.label.localeCompare(b.label)
40
+ }).map((col, idx) => ({ ...col, order: idx }))
41
+ }
42
+
43
+ function inferColumnType(
44
+ key: string,
45
+ value: any
46
+ ): "text" | "number" | "date" | "badge" | "array" | "boolean" {
47
+ if (key.toLowerCase().includes("date") || key.toLowerCase().includes("time")) {
48
+ return "date"
49
+ }
50
+ if (key.toLowerCase().includes("status") || key.toLowerCase().includes("priority")) {
51
+ return "badge"
52
+ }
53
+ if (key.toLowerCase().includes("tag") || key.toLowerCase().includes("label")) {
54
+ return "array"
55
+ }
56
+
57
+ if (value === null || value === undefined) {
58
+ return "text"
59
+ }
60
+
61
+ if (typeof value === "boolean") {
62
+ return "boolean"
63
+ }
64
+
65
+ if (typeof value === "number") {
66
+ return "number"
67
+ }
68
+
69
+ if (Array.isArray(value)) {
70
+ return "array"
71
+ }
72
+
73
+ if (typeof value === "string") {
74
+ if (!isNaN(Date.parse(value)) && /^\d{4}-\d{2}-\d{2}/.test(value)) {
75
+ return "date"
76
+ }
77
+ return "text"
78
+ }
79
+
80
+ return "text"
81
+ }
82
+
83
+ function formatLabel(key: string): string {
84
+ if (key.includes("_")) {
85
+ return key
86
+ .split("_")
87
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
88
+ .join(" ")
89
+ }
90
+
91
+ return key
92
+ .replace(/([A-Z])/g, " $1")
93
+ .replace(/^./, (str) => str.toUpperCase())
94
+ .trim()
95
+ }
96
+
97
+ export function mergeColumns(
98
+ detected: DynamicColumnConfig[],
99
+ custom?: Partial<DynamicColumnConfig>[]
100
+ ): DynamicColumnConfig[] {
101
+ if (!custom || custom.length === 0) {
102
+ return detected
103
+ }
104
+
105
+ const merged = [...detected]
106
+ const customMap = new Map(custom.map((col) => [col.id, col]))
107
+
108
+ merged.forEach((col, idx) => {
109
+ const customCol = customMap.get(col.id)
110
+ if (customCol) {
111
+ merged[idx] = { ...col, ...customCol }
112
+ customMap.delete(col.id)
113
+ }
114
+ })
115
+
116
+ customMap.forEach((customCol) => {
117
+ if (customCol.id) {
118
+ merged.push({
119
+ id: customCol.id,
120
+ label: customCol.label || formatLabel(customCol.id),
121
+ visible: customCol.visible ?? true,
122
+ order: customCol.order ?? merged.length,
123
+ type: customCol.type || "text",
124
+ render: customCol.render,
125
+ })
126
+ }
127
+ })
128
+
129
+ return merged.sort((a, b) => a.order - b.order)
130
+ }