@toolpath/ui 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 (146) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +44 -0
  3. package/dist/index.d.ts +1174 -0
  4. package/dist/index.js +5934 -0
  5. package/package.json +68 -0
  6. package/src/attribution/action.tsx +13 -0
  7. package/src/attribution/at.tsx +28 -0
  8. package/src/attribution/attribution.tsx +16 -0
  9. package/src/attribution/by.tsx +21 -0
  10. package/src/attribution/format-time.ts +48 -0
  11. package/src/attribution/index.ts +20 -0
  12. package/src/badge/badge.tsx +38 -0
  13. package/src/badge/index.ts +1 -0
  14. package/src/breadcrumbs/breadcrumbs-context.tsx +26 -0
  15. package/src/breadcrumbs/breadcrumbs.tsx +24 -0
  16. package/src/breadcrumbs/index.ts +12 -0
  17. package/src/breadcrumbs/item.tsx +85 -0
  18. package/src/button/animated-ellipsis.tsx +44 -0
  19. package/src/button/button.tsx +168 -0
  20. package/src/button/index.ts +1 -0
  21. package/src/callout/callout.tsx +202 -0
  22. package/src/callout/index.ts +2 -0
  23. package/src/callout/use-dismissed-callouts.ts +78 -0
  24. package/src/card/card.tsx +19 -0
  25. package/src/card/index.ts +2 -0
  26. package/src/checkbox/checkbox.tsx +76 -0
  27. package/src/checkbox/index.ts +1 -0
  28. package/src/combobox/button.tsx +99 -0
  29. package/src/combobox/combobox-context.tsx +42 -0
  30. package/src/combobox/combobox.tsx +60 -0
  31. package/src/combobox/empty.tsx +21 -0
  32. package/src/combobox/group-label.tsx +20 -0
  33. package/src/combobox/group.tsx +11 -0
  34. package/src/combobox/icon.tsx +11 -0
  35. package/src/combobox/index.tsx +64 -0
  36. package/src/combobox/input.tsx +77 -0
  37. package/src/combobox/item-indicator.tsx +16 -0
  38. package/src/combobox/item.tsx +58 -0
  39. package/src/combobox/list.tsx +19 -0
  40. package/src/combobox/popover.tsx +62 -0
  41. package/src/combobox/trigger.tsx +33 -0
  42. package/src/combobox/value.tsx +12 -0
  43. package/src/common/index.ts +1 -0
  44. package/src/common/svg.tsx +17 -0
  45. package/src/dialog/actions.tsx +17 -0
  46. package/src/dialog/confirm-button.tsx +25 -0
  47. package/src/dialog/dialog-context.tsx +21 -0
  48. package/src/dialog/dialog.tsx +37 -0
  49. package/src/dialog/dismiss-button.tsx +29 -0
  50. package/src/dialog/index.ts +37 -0
  51. package/src/dialog/message.tsx +20 -0
  52. package/src/dialog/provider.tsx +102 -0
  53. package/src/dialog/title.tsx +20 -0
  54. package/src/editable-cell/editable-cell.tsx +307 -0
  55. package/src/editable-cell/index.ts +7 -0
  56. package/src/editable-cell/use-editable-grid.tsx +296 -0
  57. package/src/field/field.tsx +22 -0
  58. package/src/field/index.ts +2 -0
  59. package/src/helpers/cn.ts +6 -0
  60. package/src/helpers/index.ts +1 -0
  61. package/src/icon-button/icon-button.tsx +126 -0
  62. package/src/icon-button/index.ts +2 -0
  63. package/src/index.ts +32 -0
  64. package/src/input/index.ts +1 -0
  65. package/src/input/input.tsx +276 -0
  66. package/src/link/index.ts +4 -0
  67. package/src/link/link-context.tsx +33 -0
  68. package/src/link/link.tsx +24 -0
  69. package/src/menu/divider.tsx +17 -0
  70. package/src/menu/index.ts +40 -0
  71. package/src/menu/item.tsx +149 -0
  72. package/src/menu/menu-context.tsx +24 -0
  73. package/src/menu/menu.tsx +14 -0
  74. package/src/menu/popover.tsx +69 -0
  75. package/src/menu/submenu-trigger.tsx +48 -0
  76. package/src/menu/submenu.tsx +20 -0
  77. package/src/menu/trigger.tsx +43 -0
  78. package/src/notification/index.ts +16 -0
  79. package/src/notification/list.tsx +123 -0
  80. package/src/notification/provider.tsx +75 -0
  81. package/src/page-header/index.ts +2 -0
  82. package/src/page-header/page-header.tsx +14 -0
  83. package/src/pagination/index.ts +1 -0
  84. package/src/pagination/pagination.tsx +136 -0
  85. package/src/panels/collapsed.tsx +32 -0
  86. package/src/panels/group.tsx +27 -0
  87. package/src/panels/index.ts +57 -0
  88. package/src/panels/layout-context.ts +5 -0
  89. package/src/panels/orientation-context.tsx +5 -0
  90. package/src/panels/panel.tsx +95 -0
  91. package/src/panels/separator.tsx +44 -0
  92. package/src/pin/index.ts +1 -0
  93. package/src/pin/pin.tsx +99 -0
  94. package/src/progress-bar/index.ts +1 -0
  95. package/src/progress-bar/progress-bar.tsx +48 -0
  96. package/src/quantity-input/index.ts +1 -0
  97. package/src/quantity-input/quantity-input.tsx +158 -0
  98. package/src/scroll-area/index.ts +1 -0
  99. package/src/scroll-area/scroll-area.tsx +82 -0
  100. package/src/slider/index.ts +1 -0
  101. package/src/slider/slider.tsx +75 -0
  102. package/src/star/index.ts +1 -0
  103. package/src/star/star.tsx +99 -0
  104. package/src/switch/index.ts +1 -0
  105. package/src/switch/switch.tsx +33 -0
  106. package/src/table/body.tsx +15 -0
  107. package/src/table/cell-context.tsx +48 -0
  108. package/src/table/cell.tsx +83 -0
  109. package/src/table/group-empty-state.tsx +28 -0
  110. package/src/table/group-header.tsx +62 -0
  111. package/src/table/header-cell.tsx +158 -0
  112. package/src/table/header-row.tsx +28 -0
  113. package/src/table/index.ts +56 -0
  114. package/src/table/is-group-empty-state.ts +17 -0
  115. package/src/table/is-group-header.ts +5 -0
  116. package/src/table/row-context.tsx +61 -0
  117. package/src/table/row-disabled-context.tsx +7 -0
  118. package/src/table/row.tsx +98 -0
  119. package/src/table/table-context.tsx +127 -0
  120. package/src/table/table-empty.tsx +57 -0
  121. package/src/table/table.tsx +547 -0
  122. package/src/table/tree-icon.tsx +147 -0
  123. package/src/table/use-checkbox.ts +200 -0
  124. package/src/table/use-column-layout.ts +117 -0
  125. package/src/table/use-groups.ts +134 -0
  126. package/src/table/use-keyboard-navigation.ts +274 -0
  127. package/src/table/use-sort-functions.ts +90 -0
  128. package/src/tabs/index.ts +20 -0
  129. package/src/tabs/list.tsx +29 -0
  130. package/src/tabs/tab.tsx +141 -0
  131. package/src/tabs/tabs-context.tsx +38 -0
  132. package/src/tabs/tabs.tsx +27 -0
  133. package/src/textarea/index.ts +1 -0
  134. package/src/textarea/textarea.tsx +88 -0
  135. package/src/toast/index.ts +19 -0
  136. package/src/toast/list.tsx +133 -0
  137. package/src/toast/provider.tsx +36 -0
  138. package/src/toggle/index.ts +17 -0
  139. package/src/toggle/indicator.tsx +27 -0
  140. package/src/toggle/item.tsx +54 -0
  141. package/src/toggle/toggle-context.tsx +68 -0
  142. package/src/toggle/toggle.tsx +124 -0
  143. package/src/toggle/use-indicator.tsx +69 -0
  144. package/src/tooltip/index.ts +1 -0
  145. package/src/tooltip/tooltip.tsx +122 -0
  146. package/theme.css +70 -0
@@ -0,0 +1,547 @@
1
+ import { isNull } from 'lodash-es'
2
+ import React, {
3
+ Children,
4
+ Dispatch,
5
+ ReactElement,
6
+ ReactNode,
7
+ SetStateAction,
8
+ cloneElement,
9
+ isValidElement,
10
+ useCallback,
11
+ useMemo,
12
+ useRef,
13
+ } from 'react'
14
+ import { SelectTypes, useRowSelect } from '@table-library/react-table-library/select'
15
+ import { SortToggleType, useSort } from '@table-library/react-table-library/sort'
16
+ import {
17
+ Table as BaseTable,
18
+ Data,
19
+ Header,
20
+ TableNode,
21
+ } from '@table-library/react-table-library/table'
22
+ import { TreeExpandClickTypes, useTree } from '@table-library/react-table-library/tree'
23
+ import { Action, IdState, State } from '@table-library/react-table-library/types/common'
24
+ import { VirtualizedProps } from '@table-library/react-table-library/types/virtualized'
25
+ import { Virtualized } from '@table-library/react-table-library/virtualized'
26
+ import { Body } from './body'
27
+ import { GroupEmptyState } from './group-empty-state'
28
+ import { GroupHeader } from './group-header'
29
+ import { HeaderRow } from './header-row'
30
+ import { isGroupEmptyState, isGroupEmptyStateSpacer } from './is-group-empty-state'
31
+ import { isGroupHeader } from './is-group-header'
32
+ import { RowConsumer, RowProvider } from './row-context'
33
+ import { EditingCell, TableDensity, TableProvider, useTable } from './table-context'
34
+ import { TableEmpty, TableEmptyProps } from './table-empty'
35
+ import { TreeIcon } from './tree-icon'
36
+ import { useColumnLayout } from './use-column-layout'
37
+ import { useGroups } from './use-groups'
38
+ import { useSortFunctions } from './use-sort-functions'
39
+
40
+ export type TableSelectedRows = IdState | undefined
41
+ export type TableSortDirection = 'asc' | 'desc'
42
+ export type TableSort = number | string | readonly [number | string, TableSortDirection]
43
+ export type TableSortState = {
44
+ sortKey: number | null
45
+ direction: TableSortDirection
46
+ reverse: boolean
47
+ }
48
+ export const ROW_HEIGHT = 33
49
+ export const ROW_HEIGHT_ROOMY = 45
50
+
51
+ export const getRowHeight = (density: TableDensity = 'dense') =>
52
+ density === 'roomy' ? ROW_HEIGHT_ROOMY : ROW_HEIGHT
53
+
54
+ export interface TableProps<T>
55
+ extends Omit<VirtualizedProps<any>, 'tableList' | 'rowHeight' | 'header' | 'body'> {
56
+ data: Array<T>
57
+ id?: string
58
+ density?: TableDensity
59
+ header: ReactNode
60
+ select?: boolean
61
+ multiselect?: boolean
62
+ empty?: ReactElement<TableEmptyProps>
63
+ selectedRows?: any
64
+ setSelectedRows?: Dispatch<SetStateAction<any>>
65
+ editingCell?: EditingCell
66
+ setEditingCell?: Dispatch<SetStateAction<EditingCell | null>>
67
+ /**
68
+ * Initial sort (column index, header key, or tuple with direction).
69
+ * Accepts column index (number), header key (string), or
70
+ * [key, 'asc' | 'desc'].
71
+ */
72
+ defaultSort?: TableSort
73
+ onSortChange?: (sortState: TableSortState) => void
74
+ children: (node: T, index: number) => ReactNode
75
+ className?: string
76
+ grouped?: {
77
+ counts: Array<number>
78
+ header: (index: number) => ReactNode
79
+ emptyState?: (index: number) => ReactNode
80
+ }
81
+ defaultCollapsedGroups?: Array<number>
82
+ tree?: boolean
83
+ /** Enable vertical scroll with hidden scrollbar. Parent must have explicit height. */
84
+ scrollable?: boolean
85
+ /** Render every row instead of using virtualized rows. */
86
+ virtualized?: boolean
87
+ /** Disable the built-in keyboard navigation (arrow keys, escape). Default true. */
88
+ keyboardNavigation?: boolean
89
+ /**
90
+ * Called before selection changes (on row click). Return false to cancel
91
+ * both cursor and selection updates. Useful for dirty state confirmation.
92
+ */
93
+ onBeforeSelectionChange?: (item: T, index: number | null) => boolean
94
+ }
95
+
96
+ export const TableRoot = <T,>({
97
+ data,
98
+ density,
99
+ selectedRows,
100
+ setSelectedRows,
101
+ editingCell,
102
+ setEditingCell,
103
+ defaultSort,
104
+ onSortChange,
105
+ keyboardNavigation,
106
+ onBeforeSelectionChange,
107
+ defaultCollapsedGroups,
108
+ header,
109
+ children,
110
+ tree = false,
111
+ scrollable = false,
112
+ ...props
113
+ }: TableProps<T>) => {
114
+ if (
115
+ (props.select || props.multiselect) &&
116
+ data.some((item) => item === null || typeof item !== 'object' || !('id' in item))
117
+ ) {
118
+ throw new Error(`One or more objects are missing an 'id' property`)
119
+ }
120
+
121
+ if (!isValidElement(header)) {
122
+ throw new Error(`Invalid header element provided to Table component`)
123
+ }
124
+
125
+ if (tree && props.grouped) {
126
+ throw new Error(`Tree and grouped modes cannot be used together`)
127
+ }
128
+
129
+ return (
130
+ <TableProvider
131
+ columns={
132
+ Children.count((header.props as { children?: ReactNode }).children) +
133
+ (props.select || props.multiselect ? 1 : 0)
134
+ }
135
+ density={density}
136
+ select={props.select}
137
+ multiselect={props.multiselect}
138
+ keyboardNavigation={keyboardNavigation}
139
+ selectedRows={selectedRows as IdState}
140
+ setSelectedRows={setSelectedRows as Dispatch<SetStateAction<IdState>>}
141
+ editingCell={editingCell}
142
+ setEditingCell={setEditingCell}
143
+ defaultCollapsedGroups={defaultCollapsedGroups}
144
+ isEmpty={!props.grouped && !data?.length}
145
+ onBeforeSelectionChange={onBeforeSelectionChange}
146
+ >
147
+ <Table
148
+ data={data}
149
+ header={header}
150
+ tree={tree}
151
+ defaultSort={defaultSort}
152
+ onSortChange={onSortChange}
153
+ scrollable={scrollable}
154
+ {...props}
155
+ >
156
+ {children}
157
+ </Table>
158
+ </TableProvider>
159
+ )
160
+ }
161
+
162
+ export const Table = <T,>({
163
+ data,
164
+ select: selectable = false,
165
+ multiselect = false,
166
+ header,
167
+ children,
168
+ id,
169
+ empty,
170
+ grouped,
171
+ tree: isTree = false,
172
+ defaultSort,
173
+ onSortChange,
174
+ scrollable = false,
175
+ virtualized = true,
176
+ ...props
177
+ }: Omit<
178
+ TableProps<T>,
179
+ 'selectedRows' | 'setSelectedRows' | 'isEditingCell' | 'setIsEditingCell'
180
+ >) => {
181
+ if (!isValidElement(header)) {
182
+ throw new Error(`Invalid header element provided to Table component`)
183
+ }
184
+
185
+ const tableRef = useRef(null)
186
+ const containerRef = useRef<HTMLDivElement>(null)
187
+
188
+ const headerElement = header as Parameters<typeof useSortFunctions>[0]
189
+ const sortFunctions = useSortFunctions(headerElement, Boolean(grouped))
190
+ const { handleLayoutChange, columnLayout } = useColumnLayout(headerElement, id)
191
+ const {
192
+ data: groupedData,
193
+ handleScroll,
194
+ currentGroup,
195
+ currentGroupIndex,
196
+ getGroupIndex,
197
+ } = useGroups(data, tableRef, grouped)
198
+
199
+ const { selectedRows, setSelectedRows, isEmpty, density } = useTable()
200
+ // Ref to prevent infinite loop when we modify state - the library may call
201
+ // onChange again when it sees the modified state, so we skip processing if
202
+ // we just set the state ourselves
203
+ const isSettingStateRef = useRef(false)
204
+ // Use ref to access selectedRows in callback without adding it as dependency
205
+ const selectedRowsRef = useRef(selectedRows)
206
+ selectedRowsRef.current = selectedRows
207
+
208
+ const handleSelectChange = useCallback(
209
+ (action: Action, state: State) => {
210
+ // Skip if we just set state ourselves to prevent infinite loop
211
+ if (isSettingStateRef.current) {
212
+ isSettingStateRef.current = false
213
+ return
214
+ }
215
+
216
+ const idState = state as IdState
217
+
218
+ // For additive ctrl/cmd clicks the library reports ids derived from its
219
+ // own internal state, which can desync from the controlled state when
220
+ // updates are batched. Compute the new selection from the previous
221
+ // controlled state plus the toggled id so previously-selected rows are
222
+ // never dropped on a subsequent ctrl/cmd click.
223
+ if (action?.type === 'ADD_BY_ID') {
224
+ const newId = action.payload?.id
225
+ const currentSelectedRows = selectedRowsRef.current
226
+ const existingIds: Array<IdState['ids'][number]> = []
227
+ if (currentSelectedRows?.id != null) {
228
+ existingIds.push(currentSelectedRows.id)
229
+ }
230
+ if (currentSelectedRows?.ids?.length) {
231
+ existingIds.push(...currentSelectedRows.ids)
232
+ }
233
+ if (newId != null) {
234
+ existingIds.push(newId)
235
+ }
236
+ const uniqueIds = Array.from(new Set(existingIds))
237
+ isSettingStateRef.current = true
238
+ setSelectedRows({ id: null, ids: uniqueIds })
239
+ return
240
+ }
241
+
242
+ if (idState?.ids && Array.isArray(idState.ids) && idState.ids.length > 0) {
243
+ // Deduplicate ids array to prevent duplicates from shift-click selection
244
+ const uniqueIds = Array.from(new Set(idState.ids))
245
+ // When using ids for multi-select, id should be null (they're mutually exclusive)
246
+ isSettingStateRef.current = true
247
+ setSelectedRows({ id: null, ids: uniqueIds })
248
+ } else {
249
+ setSelectedRows(idState)
250
+ }
251
+ },
252
+ [setSelectedRows],
253
+ )
254
+
255
+ const theme = useMemo(
256
+ () => ({
257
+ Table: `
258
+ grid-auto-rows: max-content;
259
+ --data-table-library_grid-template-columns: ${columnLayout};
260
+ ${scrollable ? '' : 'overflow: clip;'}
261
+ `,
262
+ Row: `background-color: inherit !important;`,
263
+ HeaderRow: `z-index: 1; background-color: white; .dark & { background-color: rgb(63 63 70); }`,
264
+ Cell: `
265
+ > div:first-of-type > div:first-of-type > div:first-of-type { flex: 1; }
266
+ & button.prefix { margin-right: 0 !important; }
267
+ `,
268
+ HeaderCell: `
269
+ > div:first-of-type > button:first-of-type > div:first-of-type {
270
+ display: flex;
271
+ flex-direction: row;
272
+ align-items: center;
273
+ }
274
+ & .resizer-area {
275
+ cursor: col-resize !important;
276
+ min-width: 8px;
277
+ }
278
+ `,
279
+ }),
280
+ [columnLayout, scrollable],
281
+ )
282
+
283
+ const layout = useMemo(
284
+ () => ({
285
+ onLayoutChange: handleLayoutChange,
286
+ fixedHeader: true,
287
+ isDiv: true,
288
+ custom: true,
289
+ }),
290
+ [handleLayoutChange],
291
+ )
292
+
293
+ const tableNodes = useMemo(() => ({ nodes: groupedData }) as Data<TableNode>, [groupedData])
294
+
295
+ // Map a consumer-friendly sort key (header key or sortKey prop) to the
296
+ // numeric column index react-table-library expects, accounting for the
297
+ // optional leading select column.
298
+ const resolveSortColumnIndex = (
299
+ sortKey: string,
300
+ hasSelectColumn: boolean,
301
+ ): number | undefined => {
302
+ const children = Children.toArray((header.props as { children?: ReactNode }).children)
303
+
304
+ for (let i = 0; i < children.length; i++) {
305
+ const child = children[i]
306
+ if (!isValidElement(child)) {
307
+ continue
308
+ }
309
+
310
+ const childKey = child.key?.toString()
311
+ const childSortKey = (child.props as { sortKey?: string }).sortKey
312
+
313
+ if (childKey === sortKey || childSortKey === sortKey) {
314
+ return hasSelectColumn ? i + 1 : i
315
+ }
316
+ }
317
+
318
+ return undefined
319
+ }
320
+
321
+ // Normalize the default sort into the shape the underlying table library
322
+ // consumes: numeric column index plus a boolean direction flag.
323
+ const normalizeDefaultSort = (
324
+ sort: TableSort | undefined,
325
+ hasSelectColumn: boolean,
326
+ ): { sortKey?: number; direction: TableSortDirection } => {
327
+ if (sort == null) {
328
+ return { direction: 'asc' }
329
+ }
330
+
331
+ const [rawKey, direction] = Array.isArray(sort) ? sort : [sort, 'asc' as TableSortDirection]
332
+
333
+ const normalizedDirection: TableSortDirection = direction === 'desc' ? 'desc' : 'asc'
334
+
335
+ const resolvedSortKey =
336
+ typeof rawKey === 'number' ? rawKey : resolveSortColumnIndex(rawKey, hasSelectColumn)
337
+
338
+ return { sortKey: resolvedSortKey, direction: normalizedDirection }
339
+ }
340
+
341
+ // Memoize tree config to prevent unnecessary re-renders
342
+ const treeOnChange = useMemo(() => ({ onChange: () => {} }), [])
343
+ const treeOptions = useMemo(
344
+ () => ({
345
+ clickType: TreeExpandClickTypes.ButtonClick,
346
+ indentation: 0,
347
+ treeIcon: {
348
+ iconDefault: (item: TableNode) => <TreeIcon item={item} type="IconDefault" />,
349
+ iconRight: (item: TableNode) => <TreeIcon item={item} type="IconRight" />,
350
+ iconDown: (item: TableNode) => <TreeIcon item={item} type="IconDown" />,
351
+ },
352
+ }),
353
+ [],
354
+ )
355
+ const tree = useTree(tableNodes, treeOnChange, treeOptions)
356
+
357
+ // Use ref to access onSortChange without adding it as dependency
358
+ const onSortChangeRef = useRef(onSortChange)
359
+ onSortChangeRef.current = onSortChange
360
+
361
+ const handleSortChange = useCallback((_action: Action, state: State) => {
362
+ const sortState = state as { sortKey: number | null; reverse: boolean }
363
+ const direction: TableSortDirection = sortState.reverse ? 'desc' : 'asc'
364
+ onSortChangeRef.current?.({
365
+ sortKey: sortState.sortKey ?? null,
366
+ reverse: sortState.reverse ?? false,
367
+ direction,
368
+ })
369
+ }, [])
370
+
371
+ // Build initial sort state if defaults are provided
372
+ const { sortKey: initialSortKey, direction: initialDirection } = normalizeDefaultSort(
373
+ defaultSort,
374
+ selectable,
375
+ )
376
+
377
+ const initialSortState = useMemo(
378
+ () =>
379
+ initialSortKey != null
380
+ ? {
381
+ sortKey: initialSortKey,
382
+ reverse: initialDirection === 'desc',
383
+ }
384
+ : undefined,
385
+ [initialSortKey, initialDirection],
386
+ )
387
+
388
+ const sortConfig = useMemo(
389
+ () => ({ state: initialSortState, onChange: handleSortChange }),
390
+ [initialSortState, handleSortChange],
391
+ )
392
+ const sortOptions = useMemo(
393
+ () => ({
394
+ sortFns: !isEmpty ? sortFunctions : [],
395
+ sortToggleType: SortToggleType.AlternateWithReset,
396
+ }),
397
+ [isEmpty, sortFunctions],
398
+ )
399
+ const sort = useSort(tableNodes, sortConfig, sortOptions)
400
+
401
+ const selectConfig = useMemo(
402
+ () => ({ state: selectedRows, onChange: handleSelectChange }),
403
+ [selectedRows, handleSelectChange],
404
+ )
405
+ const selectOptions = useMemo(
406
+ () =>
407
+ !multiselect
408
+ ? {
409
+ rowSelect: SelectTypes.SingleSelect,
410
+ buttonSelect: SelectTypes.SingleSelect,
411
+ }
412
+ : {},
413
+ [multiselect],
414
+ )
415
+ const select = useRowSelect(tableNodes, selectConfig, selectOptions)
416
+
417
+ const handleContainerMouseDown = useCallback(() => {
418
+ // Focus the container so keyboard navigation works
419
+ containerRef.current?.focus()
420
+ }, [])
421
+
422
+ // Scrollable mode: enables internal vertical scroll with hidden scrollbar.
423
+ // Parent must have explicit height (e.g., h-[300px]) for this to work.
424
+ return (
425
+ <div
426
+ ref={containerRef}
427
+ tabIndex={0}
428
+ onMouseDown={handleContainerMouseDown}
429
+ className={`w-full h-full overflow-x-scroll ${scrollable ? 'overflow-y-auto' : ''} hide-scrollbar relative tracking-normal text-gray-500 dark:text-zinc-100 bg-white dark:bg-zinc-950 antialiased outline-none`}
430
+ >
431
+ <BaseTable
432
+ data={tableNodes}
433
+ theme={theme}
434
+ sort={sort}
435
+ layout={layout}
436
+ tree={isTree ? tree : undefined}
437
+ ref={tableRef}
438
+ select={selectable || multiselect ? select : undefined}
439
+ {...props}
440
+ >
441
+ {(tableList: Array<TableNode>) => {
442
+ const indexes = tableList
443
+ .map((item, index) =>
444
+ isGroupHeader(item) || isGroupEmptyState(item) ? null : [index, item.id],
445
+ )
446
+ .filter((item) => !isNull(item)) as Array<[number, number | string]>
447
+
448
+ if (isEmpty) {
449
+ if (empty) {
450
+ // If a custom empty component is provided, inject the header prop when possible.
451
+ if (isValidElement(empty)) {
452
+ return cloneElement(empty, { header })
453
+ }
454
+
455
+ throw new Error('Invalid empty component provided to Table component')
456
+ }
457
+
458
+ return <TableEmpty header={header} />
459
+ }
460
+
461
+ if (isTree || !virtualized) {
462
+ return (
463
+ <Body indexes={indexes} containerRef={containerRef} data={tableList}>
464
+ <Header>
465
+ {header}
466
+ {Boolean(grouped) && (
467
+ <HeaderRow noOffset>
468
+ <GroupHeader inHeader groupIndex={currentGroupIndex ?? 0}>
469
+ {currentGroup}
470
+ </GroupHeader>
471
+ </HeaderRow>
472
+ )}
473
+ </Header>
474
+ {tableList.map((item, index) => (
475
+ <RowProvider
476
+ key={item.id}
477
+ item={item}
478
+ index={index}
479
+ groupIndex={grouped ? getGroupIndex(index) : undefined}
480
+ >
481
+ <RowConsumer>{({ item }) => children(item, index)}</RowConsumer>
482
+ </RowProvider>
483
+ ))}
484
+ </Body>
485
+ )
486
+ }
487
+
488
+ return (
489
+ <Body indexes={indexes} containerRef={containerRef} data={tableList}>
490
+ <Virtualized
491
+ key={density}
492
+ tableList={sort.state.reverse && grouped ? [...tableList].reverse() : tableList}
493
+ rowHeight={getRowHeight(density)}
494
+ header={() => {
495
+ // Header rerenders once per scroll.
496
+ if (grouped) {
497
+ handleScroll()
498
+ }
499
+
500
+ return (
501
+ <Header>
502
+ {header}
503
+ {Boolean(grouped) && (
504
+ <HeaderRow noOffset>
505
+ <GroupHeader inHeader groupIndex={currentGroupIndex ?? 0}>
506
+ {currentGroup}
507
+ </GroupHeader>
508
+ </HeaderRow>
509
+ )}
510
+ </Header>
511
+ )
512
+ }}
513
+ body={(item, index) => {
514
+ if (isGroupEmptyState(item)) {
515
+ if (isGroupEmptyStateSpacer(item)) {
516
+ return null
517
+ }
518
+ const groupIdx = getGroupIndex(index)
519
+ return <GroupEmptyState>{grouped?.emptyState?.(groupIdx)}</GroupEmptyState>
520
+ }
521
+
522
+ if (isGroupHeader(item)) {
523
+ return (
524
+ <GroupHeader groupIndex={getGroupIndex(index)} rowIndex={index}>
525
+ {item as unknown as ReactNode}
526
+ </GroupHeader>
527
+ )
528
+ }
529
+
530
+ return (
531
+ <RowProvider
532
+ item={item}
533
+ index={index}
534
+ groupIndex={grouped ? getGroupIndex(index) : undefined}
535
+ >
536
+ <RowConsumer>{({ item }) => children(item, index)}</RowConsumer>
537
+ </RowProvider>
538
+ )
539
+ }}
540
+ />
541
+ </Body>
542
+ )
543
+ }}
544
+ </BaseTable>
545
+ </div>
546
+ )
547
+ }
@@ -0,0 +1,147 @@
1
+ import React from 'react'
2
+ import { TableNode } from '@table-library/react-table-library/types/table'
3
+ import { cn } from '../helpers'
4
+ import { CaretRightIcon } from '@phosphor-icons/react'
5
+ import { useRow } from './row-context'
6
+ import { useTable } from './table-context'
7
+
8
+ // Reusable connector components
9
+ const LConnector = ({ itemId, index }: { itemId: string | number; index: number }) => (
10
+ <div
11
+ key={`${itemId}-${index}`}
12
+ className="w-2 h-3 border-l border-b border-gray-100 dark:border-zinc-800"
13
+ style={{ marginLeft: '9px' }}
14
+ />
15
+ )
16
+
17
+ const VerticalLine = ({ itemId, index }: { itemId: string | number; index: number }) => (
18
+ <div
19
+ key={`${itemId}-${index}`}
20
+ className="w-1.5 mr-2 ml-1 h-full border-r border-gray-100 dark:border-zinc-800"
21
+ />
22
+ )
23
+
24
+ const TConnector = ({ itemId, index }: { itemId: string | number; index: number }) => (
25
+ <div key={`${itemId}-${index}`} className="flex flex-col items-start">
26
+ <div
27
+ className="w-2 h-3 border-l border-b border-gray-100 dark:border-zinc-800"
28
+ style={{ marginLeft: '9px' }}
29
+ />
30
+ <div
31
+ className="w-2 h-3 border-l border-gray-100 dark:border-zinc-800"
32
+ style={{ marginLeft: '9px' }}
33
+ />
34
+ </div>
35
+ )
36
+
37
+ const VerticalSegment = ({ itemId, index }: { itemId: string | number; index: number }) => (
38
+ <div key={`${itemId}-${index}`} className="flex flex-col items-start">
39
+ <div
40
+ className="w-2 h-3 border-l border-gray-100 dark:border-zinc-800"
41
+ style={{ marginLeft: '9px' }}
42
+ />
43
+ <div
44
+ className="w-2 h-3 border-l border-gray-100 dark:border-zinc-800"
45
+ style={{ marginLeft: '9px' }}
46
+ />
47
+ </div>
48
+ )
49
+
50
+ const EmptySpace = ({ itemId, index }: { itemId: string | number; index: number }) => (
51
+ <div key={`${itemId}-${index}`} className="w-5" />
52
+ )
53
+
54
+ // Walk up the tree to check if each ancestor is last in its parent
55
+ const getAncestorLastFlags = (item: TableNode): boolean[] => {
56
+ const flags: boolean[] = []
57
+ let current: TableNode | undefined = item
58
+
59
+ while (current?.parentNode) {
60
+ const parent = current.parentNode as TableNode
61
+ const isLast = parent.nodes ? parent.nodes[parent.nodes.length - 1].id === current.id : false
62
+ flags.unshift(isLast)
63
+ current = parent
64
+ }
65
+
66
+ return flags
67
+ }
68
+
69
+ interface IndentationProps {
70
+ item: TableNode
71
+ isExpanded?: boolean
72
+ }
73
+
74
+ const Indentation = ({ item, isExpanded = false }: IndentationProps) => {
75
+ const ancestorLastFlags = getAncestorLastFlags(item)
76
+ const treeXLevel = item?.treeXLevel || 0
77
+ const parentNode = item.parentNode as TableNode | undefined
78
+ const isLastInParent = parentNode?.nodes
79
+ ? parentNode.nodes[parentNode.nodes.length - 1].id === item.id
80
+ : false
81
+ const hasChildren = Boolean(item.nodes?.length)
82
+ const showLConnector = isLastInParent && (!isExpanded || !hasChildren)
83
+
84
+ return (
85
+ <div className={`flex flex-row h-6 ${showLConnector ? 'items-start' : 'items-center'}`}>
86
+ {Array.from({ length: treeXLevel }).map((_, index) => {
87
+ const isLastLevel = index === treeXLevel - 1
88
+ const ancestorIsLast = ancestorLastFlags[index] ?? false
89
+ const key = `${item.id}-${index}`
90
+
91
+ // Final level: connector to this item
92
+ if (isLastLevel) {
93
+ if (!hasChildren) {
94
+ return isLastInParent ? (
95
+ <LConnector key={key} itemId={item.id} index={index} />
96
+ ) : (
97
+ <VerticalLine key={key} itemId={item.id} index={index} />
98
+ )
99
+ }
100
+ return showLConnector ? (
101
+ <LConnector key={key} itemId={item.id} index={index} />
102
+ ) : (
103
+ <TConnector key={key} itemId={item.id} index={index} />
104
+ )
105
+ }
106
+
107
+ // Earlier levels: vertical lines for ancestors
108
+ if (ancestorIsLast) {
109
+ return <EmptySpace key={key} itemId={item.id} index={index} />
110
+ }
111
+ return hasChildren ? (
112
+ <VerticalSegment key={key} itemId={item.id} index={index} />
113
+ ) : (
114
+ <VerticalLine key={key} itemId={item.id} index={index} />
115
+ )
116
+ })}
117
+ </div>
118
+ )
119
+ }
120
+
121
+ interface TreeIconProps {
122
+ item: TableNode
123
+ type?: 'IconDefault' | 'IconRight' | 'IconDown'
124
+ }
125
+
126
+ export const TreeIcon = ({ item, type = 'IconDefault' }: TreeIconProps) => {
127
+ const { isHovered, isSelected } = useRow()
128
+ const { density } = useTable()
129
+ const isExpanded = type === 'IconDown'
130
+
131
+ return (
132
+ <div
133
+ className={cn('flex flex-row items-center', density === 'roomy' ? 'h-11' : 'h-8', {
134
+ 'bg-gray-50 dark:bg-zinc-800': isHovered || isSelected,
135
+ })}
136
+ >
137
+ <Indentation item={item} isExpanded={isExpanded} />
138
+ {type === 'IconDefault' && <div className="w-5" />}
139
+ {type === 'IconRight' && (
140
+ <CaretRightIcon className="size-5 text-gray-300 dark:text-zinc-100" />
141
+ )}
142
+ {type === 'IconDown' && (
143
+ <CaretRightIcon className="size-5 text-gray-300 dark:text-zinc-100 rotate-90" />
144
+ )}
145
+ </div>
146
+ )
147
+ }