torch-glare 2.1.1 → 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 (62) hide show
  1. package/apps/lib/components/BadgeField.tsx +2 -2
  2. package/apps/lib/components/DataViews/ARCHITECTURE.md +439 -0
  3. package/apps/lib/components/DataViews/DataViewsConfigPanel.tsx +416 -0
  4. package/apps/lib/components/DataViews/DataViewsHeader.tsx +126 -0
  5. package/apps/lib/components/DataViews/DataViewsLayout.tsx +300 -0
  6. package/apps/lib/components/DataViews/FilterPanel.tsx +324 -0
  7. package/apps/lib/components/DataViews/InboxView.tsx +514 -0
  8. package/apps/lib/components/DataViews/KanbanView.tsx +242 -0
  9. package/apps/lib/components/DataViews/PanelControls.tsx +80 -0
  10. package/apps/lib/components/DataViews/SettingsPanel.tsx +285 -0
  11. package/apps/lib/components/DataViews/TableView.tsx +232 -0
  12. package/apps/lib/components/DataViews/TreeView.tsx +363 -0
  13. package/apps/lib/components/DataViews/badgeAdapter.ts +45 -0
  14. package/apps/lib/components/DataViews/fieldRenderers.tsx +334 -0
  15. package/apps/lib/components/DataViews/filters/DateRangePopover.tsx +113 -0
  16. package/apps/lib/components/DataViews/filters/PresetChips.tsx +45 -0
  17. package/apps/lib/components/DataViews/filters/RangeSliderWithInputs.tsx +154 -0
  18. package/apps/lib/components/DataViews/index.ts +30 -0
  19. package/apps/lib/components/DataViews/tree/TreeDrawer.tsx +54 -0
  20. package/apps/lib/components/DataViews/tree/TreeSidebar.tsx +77 -0
  21. package/apps/lib/components/DataViews/types.ts +177 -0
  22. package/apps/lib/components/TreeFolder/TreeFolder.tsx +387 -0
  23. package/apps/lib/components/TreeFolder/TreeFolderBreadcrumb.tsx +80 -0
  24. package/apps/lib/components/TreeFolder/TreeFolderRow.tsx +235 -0
  25. package/apps/lib/components/TreeFolder/TreeFolderStyles.tsx +60 -0
  26. package/apps/lib/components/TreeFolder/icons.tsx +63 -0
  27. package/apps/lib/components/TreeFolder/index.ts +17 -0
  28. package/apps/lib/components/TreeFolder/treeFolderUtils.ts +114 -0
  29. package/apps/lib/components/TreeFolder/types.ts +68 -0
  30. package/apps/lib/components/TreeFolder/useTreeFolderDnD.ts +261 -0
  31. package/apps/lib/hooks/useDataViewsState.ts +169 -0
  32. package/apps/lib/hooks/useIsMobile.ts +21 -0
  33. package/apps/lib/utils/dataViews/columnUtils.ts +130 -0
  34. package/apps/lib/utils/dataViews/fieldUtils.ts +198 -0
  35. package/apps/lib/utils/dataViews/nestedDataUtils.tsx +364 -0
  36. package/apps/lib/utils/dataViews/pathUtils.ts +132 -0
  37. package/apps/lib/utils/dataViews/rangeUtils.ts +225 -0
  38. package/apps/lib/utils/dataViews/treeUtils.ts +403 -0
  39. package/dist/bin/index.js +3 -3
  40. package/dist/bin/index.js.map +1 -1
  41. package/dist/src/commands/add.d.ts.map +1 -1
  42. package/dist/src/commands/add.js +29 -6
  43. package/dist/src/commands/add.js.map +1 -1
  44. package/dist/src/commands/utils.d.ts.map +1 -1
  45. package/dist/src/commands/utils.js +22 -2
  46. package/dist/src/commands/utils.js.map +1 -1
  47. package/dist/src/shared/copyComponentsRecursively.d.ts.map +1 -1
  48. package/dist/src/shared/copyComponentsRecursively.js +8 -1
  49. package/dist/src/shared/copyComponentsRecursively.js.map +1 -1
  50. package/dist/src/shared/getDependenciesAndInstallNestedComponents.d.ts +18 -4
  51. package/dist/src/shared/getDependenciesAndInstallNestedComponents.d.ts.map +1 -1
  52. package/dist/src/shared/getDependenciesAndInstallNestedComponents.js +110 -40
  53. package/dist/src/shared/getDependenciesAndInstallNestedComponents.js.map +1 -1
  54. package/docs/components/form-stepper.md +244 -0
  55. package/docs/components/stepper.md +215 -0
  56. package/docs/components/timeline.md +248 -0
  57. package/package.json +6 -6
  58. package/apps/lib/components/Charts-dev.tsx +0 -365
  59. package/apps/lib/components/Command-dev.tsx +0 -151
  60. package/apps/lib/components/IosDatePicker-dev.tsx +0 -341
  61. /package/docs/components/{labeled-checkbox.md → labeled-check-box.md} +0 -0
  62. /package/docs/components/{tree-dropdown.md → tree-drop-down.md} +0 -0
@@ -0,0 +1,54 @@
1
+ "use client"
2
+
3
+ import { Drawer } from "vaul"
4
+ import { Menu, X } from "lucide-react"
5
+ import type { ReactNode } from "react"
6
+
7
+ type Props = {
8
+ open: boolean
9
+ onOpenChange: (open: boolean) => void
10
+ children: ReactNode
11
+ }
12
+
13
+ export function TreeDrawer({ open, onOpenChange, children }: Props) {
14
+ return (
15
+ <Drawer.Root open={open} onOpenChange={onOpenChange} direction="left">
16
+ <Drawer.Portal>
17
+ <Drawer.Overlay className="fixed inset-0 bg-black/40 z-40" />
18
+ <Drawer.Content
19
+ className="fixed inset-y-0 left-0 z-50 w-72 bg-background-presentation-body-overlay-primary border-r border-border-presentation-global-primary outline-none flex flex-col"
20
+ >
21
+ <div className="flex items-center justify-between p-3 border-b border-border-presentation-global-primary">
22
+ <Drawer.Title className="text-sm font-semibold text-content-presentation-global-primary">
23
+ Tree
24
+ </Drawer.Title>
25
+ <button
26
+ type="button"
27
+ onClick={() => onOpenChange(false)}
28
+ aria-label="Close tree"
29
+ className="p-1 rounded text-content-presentation-global-tertiary hover:text-content-presentation-global-primary"
30
+ >
31
+ <X className="w-4 h-4" />
32
+ </button>
33
+ </div>
34
+ <div className="flex-1 overflow-y-auto p-2">
35
+ {children}
36
+ </div>
37
+ </Drawer.Content>
38
+ </Drawer.Portal>
39
+ </Drawer.Root>
40
+ )
41
+ }
42
+
43
+ export function TreeDrawerTrigger({ onClick }: { onClick: () => void }) {
44
+ return (
45
+ <button
46
+ type="button"
47
+ onClick={onClick}
48
+ aria-label="Open tree"
49
+ className="inline-flex items-center justify-center h-8 w-8 rounded-md border border-border-presentation-global-primary text-content-presentation-global-secondary hover:text-content-presentation-global-primary"
50
+ >
51
+ <Menu className="w-4 h-4" />
52
+ </button>
53
+ )
54
+ }
@@ -0,0 +1,77 @@
1
+ "use client"
2
+
3
+ import { useMemo } from "react"
4
+ import type { FieldConfig } from "../types"
5
+ import type { TreeNode } from "../../../utils/dataViews/treeUtils"
6
+ import { getByPath } from "../../../utils/dataViews/pathUtils"
7
+ import { TreeFolder } from "../../TreeFolder"
8
+ import type {
9
+ TreeFolderMoveArgs,
10
+ TreeFolderNode,
11
+ } from "../../TreeFolder"
12
+
13
+ type Props = {
14
+ roots: TreeNode[]
15
+ expanded: Set<string>
16
+ selectedId: string | null
17
+ labelField: FieldConfig
18
+ dndEnabled: boolean
19
+ onToggle: (id: string) => void
20
+ onSelect: (id: string) => void
21
+ onMove?: (args: TreeFolderMoveArgs) => void
22
+ }
23
+
24
+ function toFolderNode(node: TreeNode, labelField: FieldConfig): TreeFolderNode {
25
+ const labelValue = getByPath(node.record, labelField.path)
26
+ const name = labelValue == null ? node.id : String(labelValue)
27
+ const children = node.children.length
28
+ ? node.children.map((c) => toFolderNode(c, labelField))
29
+ : undefined
30
+ return {
31
+ id: node.id,
32
+ name,
33
+ type: children ? "folder" : "file",
34
+ data: node.record,
35
+ children,
36
+ }
37
+ }
38
+
39
+ export function TreeSidebar({
40
+ roots,
41
+ expanded,
42
+ selectedId,
43
+ labelField,
44
+ dndEnabled,
45
+ onToggle,
46
+ onSelect,
47
+ onMove,
48
+ }: Props) {
49
+ const folderData = useMemo(
50
+ () => roots.map((r) => toFolderNode(r, labelField)),
51
+ [roots, labelField],
52
+ )
53
+
54
+ const expandedIds = useMemo(() => Array.from(expanded), [expanded])
55
+
56
+ return (
57
+ <TreeFolder
58
+ data={folderData}
59
+ selectedId={selectedId}
60
+ onSelectionChange={(id) => {
61
+ if (id) onSelect(id)
62
+ }}
63
+ expandedIds={expandedIds}
64
+ onExpandedChange={(next) => {
65
+ const before = new Set(expandedIds)
66
+ const after = new Set(next)
67
+ for (const id of after) if (!before.has(id)) onToggle(id)
68
+ for (const id of before) if (!after.has(id)) onToggle(id)
69
+ }}
70
+ dndEnabled={dndEnabled}
71
+ onMove={onMove}
72
+ showHeader={false}
73
+ showBreadcrumb={true}
74
+ highlightAncestors={true}
75
+ />
76
+ )
77
+ }
@@ -0,0 +1,177 @@
1
+ import type React from "react"
2
+
3
+ export type ViewType = "table" | "kanban" | "inbox" | "tree"
4
+
5
+ export type TreeConfig = {
6
+ childrenField?: string
7
+ parentField?: string
8
+ idField?: string
9
+ orderField?: string
10
+ nodeLabel?: string
11
+ defaultExpanded?: "all" | "roots" | "none"
12
+ defaultRightPane?: "table" | "details"
13
+ dndEnabled?: boolean
14
+ }
15
+
16
+ export type ViewVisibility = {
17
+ table?: boolean
18
+ kanban?: boolean
19
+ inbox?: boolean
20
+ tree?: boolean
21
+ }
22
+
23
+ export type DynamicRecord = Record<string, any>
24
+
25
+ export type DynamicColumnConfig = {
26
+ id: string
27
+ label: string
28
+ visible: boolean
29
+ order: number
30
+ type?: "text" | "number" | "date" | "badge" | "array" | "boolean"
31
+ render?: (value: any, row: DynamicRecord) => React.ReactNode
32
+ }
33
+
34
+ export type DynamicFilterConfig = {
35
+ id: string
36
+ label?: string
37
+ enabled?: boolean
38
+ order?: number
39
+ options?: string[] | { label: string; value: string }[]
40
+ render?: (value: string, isSelected: boolean) => React.ReactNode
41
+ onChange?: (selectedValues: string[]) => void
42
+ }
43
+
44
+ export type NumericRangeFilter = { kind: "number"; min?: number; max?: number }
45
+ export type DateRangeFilter = { kind: "date"; from?: string; to?: string }
46
+ export type RangeFilter = NumericRangeFilter | DateRangeFilter
47
+ export type FilterValue = string[] | RangeFilter
48
+ export type FilterState = Record<string, FilterValue>
49
+
50
+ export type FieldPreset =
51
+ | { label: string; min?: number; max?: number }
52
+ | { label: string; from?: string; to?: string }
53
+
54
+ export type BadgeVariant =
55
+ | "green"
56
+ | "greenLight"
57
+ | "cocktailGreen"
58
+ | "yellow"
59
+ | "redOrange"
60
+ | "redLight"
61
+ | "rose"
62
+ | "purple"
63
+ | "bluePurple"
64
+ | "blue"
65
+ | "navy"
66
+ | "gray"
67
+ | "highlight"
68
+
69
+ export type FieldType =
70
+ | "text"
71
+ | "number"
72
+ | "date"
73
+ | "boolean"
74
+ | "hidden"
75
+ | "enum-badge"
76
+ | "badge-array"
77
+ | "currency"
78
+ | "number-format"
79
+ | "progress-bar"
80
+ | "star-rating"
81
+ | "icon-text"
82
+ | "two-line"
83
+ | "avatar"
84
+ | "link"
85
+ | "image"
86
+ | "date-format"
87
+
88
+ export type CurrencyOptions = {
89
+ symbol?: string
90
+ locale?: string
91
+ decimals?: number
92
+ code?: string
93
+ }
94
+
95
+ export type FieldConfig = {
96
+ path: string
97
+ label?: string
98
+ type?: FieldType
99
+ visible?: boolean
100
+ order?: number
101
+
102
+ variants?: Record<string, BadgeVariant>
103
+ defaultVariant?: BadgeVariant
104
+
105
+ variant?: BadgeVariant
106
+ limit?: number
107
+
108
+ currency?: string | CurrencyOptions
109
+ format?: Intl.NumberFormatOptions
110
+
111
+ thresholds?: [number, number]
112
+
113
+ max?: number
114
+
115
+ icon?: string
116
+ iconPosition?: "before" | "after"
117
+
118
+ secondaryPath?: string
119
+
120
+ linkType?: "mailto" | "tel" | "url"
121
+
122
+ fallbackPath?: string
123
+
124
+ dateFormat?: string | Intl.DateTimeFormatOptions
125
+
126
+ trueLabel?: string
127
+ falseLabel?: string
128
+ trueVariant?: BadgeVariant
129
+ falseVariant?: BadgeVariant
130
+
131
+ filterable?: boolean
132
+ filterLabel?: string
133
+ filterOptions?: string[] | { label: string; value: string }[]
134
+ presets?: FieldPreset[]
135
+ rangeMin?: number
136
+ rangeMax?: number
137
+ rangeStep?: number
138
+ onFilterChange?: (value: FilterValue) => void
139
+
140
+ render?: (value: any, row: DynamicRecord) => React.ReactNode
141
+ }
142
+
143
+ export type InboxConfig = {
144
+ starredField?: string | null
145
+ readField?: string | null
146
+ attachmentField?: string | null
147
+ priorityField?: string | null
148
+ titlePath?: string
149
+ previewPath?: string
150
+ }
151
+
152
+ export type ColumnConfig = {
153
+ id: string
154
+ label: string
155
+ visible: boolean
156
+ order: number
157
+ }
158
+
159
+ export type ViewConfig = {
160
+ defaultView: ViewType
161
+ tableColumns: ColumnConfig[]
162
+ kanbanGroupBy: string
163
+ showFilters: boolean
164
+ showPreviewPane: boolean
165
+ sortBy: string
166
+ sortOrder: "asc" | "desc"
167
+ }
168
+
169
+ export const defaultConfig: ViewConfig = {
170
+ defaultView: "table",
171
+ tableColumns: [],
172
+ kanbanGroupBy: "",
173
+ showFilters: true,
174
+ showPreviewPane: true,
175
+ sortBy: "",
176
+ sortOrder: "desc",
177
+ }
@@ -0,0 +1,387 @@
1
+ "use client"
2
+
3
+ import {
4
+ forwardRef,
5
+ useCallback,
6
+ useImperativeHandle,
7
+ useMemo,
8
+ useRef,
9
+ useState,
10
+ type ReactNode,
11
+ } from "react"
12
+ import { cn } from "../../utils/cn"
13
+ import type { Themes } from "../../utils/types"
14
+ import { TreeFolderBreadcrumb } from "./TreeFolderBreadcrumb"
15
+ import { TreeFolderRow } from "./TreeFolderRow"
16
+ import { TreeFolderStyles } from "./TreeFolderStyles"
17
+ import {
18
+ applyMove,
19
+ descendantIds,
20
+ findNode,
21
+ findPath,
22
+ toBreadcrumb,
23
+ } from "./treeFolderUtils"
24
+ import type {
25
+ TreeFolderIconResolver,
26
+ TreeFolderMoveArgs,
27
+ TreeFolderNode,
28
+ TreeFolderVisibleRow,
29
+ } from "./types"
30
+ import { useTreeFolderDnD } from "./useTreeFolderDnD"
31
+
32
+ export type TreeFolderProps = {
33
+ data: TreeFolderNode[]
34
+ selectedId?: string | null
35
+ defaultSelectedId?: string | null
36
+ onSelectionChange?: (id: string | null) => void
37
+
38
+ expandedIds?: string[]
39
+ defaultExpanded?: "all" | "roots" | "none" | string[]
40
+ onExpandedChange?: (ids: string[]) => void
41
+
42
+ dndEnabled?: boolean
43
+ onMove?: (args: TreeFolderMoveArgs) => void
44
+ onDataChange?: (next: TreeFolderNode[]) => void
45
+
46
+ iconFor?: TreeFolderIconResolver
47
+
48
+ title?: string
49
+ showBreadcrumb?: boolean
50
+ showHeader?: boolean
51
+ highlightAncestors?: boolean
52
+ highlightSubtree?: boolean
53
+ headerAccessory?: ReactNode
54
+ emptyState?: ReactNode
55
+
56
+ rowHeight?: number
57
+ indent?: number
58
+ /** Optional. If set, the row strip will be at least this wide (in px) — useful
59
+ * when you want the band to extend across a wider canvas regardless of content. */
60
+ contentMinWidth?: number
61
+
62
+ className?: string
63
+ theme?: Themes
64
+ }
65
+
66
+ export type TreeFolderHandle = {
67
+ selectId: (id: string | null) => void
68
+ expandAll: () => void
69
+ collapseAll: () => void
70
+ scrollToId: (id: string) => void
71
+ }
72
+
73
+ const ROW_HEIGHT_DEFAULT = 28
74
+ const INDENT_DEFAULT = 14
75
+
76
+ function collectIds(nodes: TreeFolderNode[], out: string[] = []): string[] {
77
+ for (const n of nodes) {
78
+ out.push(n.id)
79
+ if (n.children) collectIds(n.children, out)
80
+ }
81
+ return out
82
+ }
83
+
84
+ function rootIds(nodes: TreeFolderNode[]): string[] {
85
+ return nodes.map((n) => n.id)
86
+ }
87
+
88
+ function flattenVisible(
89
+ data: TreeFolderNode[],
90
+ expanded: Set<string>,
91
+ ): TreeFolderVisibleRow[] {
92
+ const out: TreeFolderVisibleRow[] = []
93
+ const walk = (
94
+ nodes: TreeFolderNode[],
95
+ level: number,
96
+ parentId: string | null,
97
+ ) => {
98
+ nodes.forEach((node, idx) => {
99
+ const isInternal = !!(node.children && node.children.length > 0)
100
+ const isOpen = isInternal && expanded.has(node.id)
101
+ out.push({ node, level, parentId, childIndex: idx, isOpen, isInternal })
102
+ if (isOpen && node.children) walk(node.children, level + 1, node.id)
103
+ })
104
+ }
105
+ walk(data, 0, null)
106
+ return out
107
+ }
108
+
109
+ export const TreeFolder = forwardRef<TreeFolderHandle, TreeFolderProps>(
110
+ function TreeFolder(props, ref) {
111
+ const {
112
+ data,
113
+ selectedId: controlledSelected,
114
+ defaultSelectedId = null,
115
+ onSelectionChange,
116
+ expandedIds: controlledExpanded,
117
+ defaultExpanded = "roots",
118
+ onExpandedChange,
119
+ dndEnabled = true,
120
+ onMove,
121
+ onDataChange,
122
+ iconFor,
123
+ title = "Layers",
124
+ showBreadcrumb = true,
125
+ showHeader = true,
126
+ highlightAncestors = true,
127
+ highlightSubtree = true,
128
+ headerAccessory,
129
+ emptyState,
130
+ rowHeight = ROW_HEIGHT_DEFAULT,
131
+ indent = INDENT_DEFAULT,
132
+ contentMinWidth,
133
+ className,
134
+ theme,
135
+ } = props
136
+
137
+ const scrollRef = useRef<HTMLDivElement | null>(null)
138
+
139
+ // ---- Selection state ----
140
+ const [internalSelected, setInternalSelected] = useState<string | null>(
141
+ defaultSelectedId,
142
+ )
143
+ const selectedId =
144
+ controlledSelected !== undefined ? controlledSelected : internalSelected
145
+ const isSelectionControlled = controlledSelected !== undefined
146
+
147
+ // ---- Expansion state ----
148
+ const [internalExpanded, setInternalExpanded] = useState<string[]>(() => {
149
+ if (Array.isArray(defaultExpanded)) return defaultExpanded
150
+ if (defaultExpanded === "all") return collectIds(data)
151
+ if (defaultExpanded === "none") return []
152
+ return rootIds(data)
153
+ })
154
+ const expandedIds = controlledExpanded ?? internalExpanded
155
+ const isExpansionControlled = controlledExpanded !== undefined
156
+ const expandedSet = useMemo(() => new Set(expandedIds), [expandedIds])
157
+
158
+ // ---- Visible rows ----
159
+ const visibleRows = useMemo(
160
+ () => flattenVisible(data, expandedSet),
161
+ [data, expandedSet],
162
+ )
163
+ const rowsById = useMemo(() => {
164
+ const m = new Map<string, TreeFolderVisibleRow>()
165
+ for (const r of visibleRows) m.set(r.node.id, r)
166
+ return m
167
+ }, [visibleRows])
168
+
169
+ // ---- Ancestor + subtree highlight ----
170
+ const ancestorPath = useMemo(
171
+ () => (selectedId ? findPath(data, selectedId) : null),
172
+ [data, selectedId],
173
+ )
174
+ const ancestorIdSet = useMemo(
175
+ () => new Set((ancestorPath ?? []).slice(0, -1).map((n) => n.id)),
176
+ [ancestorPath],
177
+ )
178
+ const isAncestorFn = useCallback(
179
+ (id: string) => highlightAncestors && ancestorIdSet.has(id),
180
+ [highlightAncestors, ancestorIdSet],
181
+ )
182
+
183
+ const descendantIdSet = useMemo(() => {
184
+ if (!highlightSubtree || !selectedId) return new Set<string>()
185
+ const node = findNode(data, selectedId)
186
+ if (!node) return new Set<string>()
187
+ return descendantIds(node, false)
188
+ }, [data, selectedId, highlightSubtree])
189
+ const isDescendantOfSelected = useCallback(
190
+ (id: string) => descendantIdSet.has(id),
191
+ [descendantIdSet],
192
+ )
193
+
194
+ const breadcrumbItems = useMemo(
195
+ () => (ancestorPath ? toBreadcrumb(ancestorPath) : []),
196
+ [ancestorPath],
197
+ )
198
+
199
+ // ---- Handlers ----
200
+ const handleSelect = useCallback(
201
+ (id: string | null) => {
202
+ if (!isSelectionControlled) setInternalSelected(id)
203
+ onSelectionChange?.(id)
204
+ },
205
+ [isSelectionControlled, onSelectionChange],
206
+ )
207
+
208
+ const handleToggle = useCallback(
209
+ (id: string) => {
210
+ const next = expandedIds.includes(id)
211
+ ? expandedIds.filter((x) => x !== id)
212
+ : [...expandedIds, id]
213
+ if (!isExpansionControlled) setInternalExpanded(next)
214
+ onExpandedChange?.(next)
215
+ },
216
+ [expandedIds, isExpansionControlled, onExpandedChange],
217
+ )
218
+
219
+ const handleMoveInternal = useCallback(
220
+ (args: TreeFolderMoveArgs) => {
221
+ onMove?.(args)
222
+ if (onDataChange) onDataChange(applyMove(data, args))
223
+ },
224
+ [data, onMove, onDataChange],
225
+ )
226
+
227
+ const scrollToId = useCallback((id: string) => {
228
+ const el = scrollRef.current?.querySelector<HTMLElement>(
229
+ `[data-row-id="${CSS.escape(id)}"]`,
230
+ )
231
+ el?.scrollIntoView({ block: "nearest", behavior: "smooth" })
232
+ }, [])
233
+
234
+ // ---- DnD ----
235
+ const { dragIds, dropTarget, getRowDragHandlers } = useTreeFolderDnD({
236
+ data,
237
+ rowsById,
238
+ scrollContainerRef: scrollRef,
239
+ enabled: dndEnabled,
240
+ onMove: handleMoveInternal,
241
+ canDrop: ({ parentId }) => {
242
+ // Honor per-node `droppable: false` on the resolved parent.
243
+ if (parentId == null) return true
244
+ const target = findNode(data, parentId)
245
+ if (!target) return false
246
+ if (target.disabled || target.droppable === false) return false
247
+ return true
248
+ },
249
+ })
250
+ const dragIdSet = useMemo(() => new Set(dragIds), [dragIds])
251
+
252
+ // ---- Imperative handle ----
253
+ useImperativeHandle(
254
+ ref,
255
+ () => ({
256
+ selectId: (id: string | null) => {
257
+ if (!isSelectionControlled) setInternalSelected(id)
258
+ onSelectionChange?.(id)
259
+ if (id) scrollToId(id)
260
+ },
261
+ expandAll: () => {
262
+ const all = collectIds(data)
263
+ if (!isExpansionControlled) setInternalExpanded(all)
264
+ onExpandedChange?.(all)
265
+ },
266
+ collapseAll: () => {
267
+ if (!isExpansionControlled) setInternalExpanded([])
268
+ onExpandedChange?.([])
269
+ },
270
+ scrollToId,
271
+ }),
272
+ [
273
+ data,
274
+ isSelectionControlled,
275
+ isExpansionControlled,
276
+ onSelectionChange,
277
+ onExpandedChange,
278
+ scrollToId,
279
+ ],
280
+ )
281
+
282
+ const isEmpty = data.length === 0
283
+ const stripStyle = contentMinWidth
284
+ ? { minWidth: contentMinWidth }
285
+ : undefined
286
+
287
+ return (
288
+ <div
289
+ data-theme={theme}
290
+ className={cn(
291
+ "flex h-full w-full flex-col bg-background-presentation-body-overlay-primary text-content-presentation-global-primary",
292
+ className,
293
+ )}
294
+ >
295
+ {showHeader && (
296
+ <div className="px-3 py-2 border-b border-border-presentation-global-primary flex items-center justify-between gap-2 shrink-0">
297
+ <span className="text-xs font-semibold text-content-presentation-global-secondary uppercase tracking-wide truncate">
298
+ {title}
299
+ </span>
300
+ {headerAccessory}
301
+ </div>
302
+ )}
303
+
304
+ {showBreadcrumb && (
305
+ <div className="border-b border-border-presentation-global-primary shrink-0">
306
+ <TreeFolderBreadcrumb
307
+ items={breadcrumbItems}
308
+ onSelect={(id) => {
309
+ handleSelect(id)
310
+ scrollToId(id)
311
+ }}
312
+ />
313
+ </div>
314
+ )}
315
+
316
+ <TreeFolderStyles />
317
+ <div
318
+ ref={scrollRef}
319
+ role="tree"
320
+ className="tf-scroll flex-1 min-h-0 overflow-auto"
321
+ >
322
+ {isEmpty ? (
323
+ emptyState ?? (
324
+ <div className="text-xs text-content-presentation-global-tertiary p-3">
325
+ Nothing here yet.
326
+ </div>
327
+ )
328
+ ) : (
329
+ <div className="min-w-max" style={stripStyle}>
330
+ {visibleRows.map((row, idx) => {
331
+ const prevRow = visibleRows[idx - 1]
332
+ const nextRow = visibleRows[idx + 1]
333
+ const isSelected = selectedId === row.node.id
334
+ const isDescendant = isDescendantOfSelected(row.node.id) && !isSelected
335
+ const inBand = isSelected || isDescendant
336
+
337
+ // Neighbour-aware band rounding: a row is "in-band" if it's the selected
338
+ // node itself or one of its descendants. The Set lookup handles deep nesting.
339
+ const prevInBand =
340
+ inBand &&
341
+ !!prevRow &&
342
+ (prevRow.node.id === selectedId ||
343
+ descendantIdSet.has(prevRow.node.id))
344
+ const nextInBand =
345
+ inBand &&
346
+ !!nextRow &&
347
+ (nextRow.node.id === selectedId ||
348
+ descendantIdSet.has(nextRow.node.id))
349
+
350
+ const isDragging = dragIdSet.has(row.node.id)
351
+ const isDropTargetInside =
352
+ dropTarget?.rowId === row.node.id && dropTarget.position === "inside"
353
+ const isDropBefore =
354
+ dropTarget?.rowId === row.node.id && dropTarget.position === "before"
355
+ const isDropAfter =
356
+ dropTarget?.rowId === row.node.id && dropTarget.position === "after"
357
+
358
+ return (
359
+ <TreeFolderRow
360
+ key={row.node.id}
361
+ row={row}
362
+ rowHeight={rowHeight}
363
+ indent={indent}
364
+ iconFor={iconFor}
365
+ isSelected={isSelected}
366
+ isAncestor={isAncestorFn(row.node.id) && !isSelected}
367
+ isDescendantOfSelected={isDescendant}
368
+ isPrevInBand={prevInBand}
369
+ isNextInBand={nextInBand}
370
+ isDragging={isDragging}
371
+ isDropTargetInside={isDropTargetInside}
372
+ isDropBefore={isDropBefore}
373
+ isDropAfter={isDropAfter}
374
+ dndEnabled={dndEnabled}
375
+ onSelect={handleSelect}
376
+ onToggle={handleToggle}
377
+ dragHandlers={getRowDragHandlers(row.node.id)}
378
+ />
379
+ )
380
+ })}
381
+ </div>
382
+ )}
383
+ </div>
384
+ </div>
385
+ )
386
+ },
387
+ )