@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.
- package/LICENSE +21 -0
- package/README.md +44 -0
- package/dist/index.d.ts +1174 -0
- package/dist/index.js +5934 -0
- package/package.json +68 -0
- package/src/attribution/action.tsx +13 -0
- package/src/attribution/at.tsx +28 -0
- package/src/attribution/attribution.tsx +16 -0
- package/src/attribution/by.tsx +21 -0
- package/src/attribution/format-time.ts +48 -0
- package/src/attribution/index.ts +20 -0
- package/src/badge/badge.tsx +38 -0
- package/src/badge/index.ts +1 -0
- package/src/breadcrumbs/breadcrumbs-context.tsx +26 -0
- package/src/breadcrumbs/breadcrumbs.tsx +24 -0
- package/src/breadcrumbs/index.ts +12 -0
- package/src/breadcrumbs/item.tsx +85 -0
- package/src/button/animated-ellipsis.tsx +44 -0
- package/src/button/button.tsx +168 -0
- package/src/button/index.ts +1 -0
- package/src/callout/callout.tsx +202 -0
- package/src/callout/index.ts +2 -0
- package/src/callout/use-dismissed-callouts.ts +78 -0
- package/src/card/card.tsx +19 -0
- package/src/card/index.ts +2 -0
- package/src/checkbox/checkbox.tsx +76 -0
- package/src/checkbox/index.ts +1 -0
- package/src/combobox/button.tsx +99 -0
- package/src/combobox/combobox-context.tsx +42 -0
- package/src/combobox/combobox.tsx +60 -0
- package/src/combobox/empty.tsx +21 -0
- package/src/combobox/group-label.tsx +20 -0
- package/src/combobox/group.tsx +11 -0
- package/src/combobox/icon.tsx +11 -0
- package/src/combobox/index.tsx +64 -0
- package/src/combobox/input.tsx +77 -0
- package/src/combobox/item-indicator.tsx +16 -0
- package/src/combobox/item.tsx +58 -0
- package/src/combobox/list.tsx +19 -0
- package/src/combobox/popover.tsx +62 -0
- package/src/combobox/trigger.tsx +33 -0
- package/src/combobox/value.tsx +12 -0
- package/src/common/index.ts +1 -0
- package/src/common/svg.tsx +17 -0
- package/src/dialog/actions.tsx +17 -0
- package/src/dialog/confirm-button.tsx +25 -0
- package/src/dialog/dialog-context.tsx +21 -0
- package/src/dialog/dialog.tsx +37 -0
- package/src/dialog/dismiss-button.tsx +29 -0
- package/src/dialog/index.ts +37 -0
- package/src/dialog/message.tsx +20 -0
- package/src/dialog/provider.tsx +102 -0
- package/src/dialog/title.tsx +20 -0
- package/src/editable-cell/editable-cell.tsx +307 -0
- package/src/editable-cell/index.ts +7 -0
- package/src/editable-cell/use-editable-grid.tsx +296 -0
- package/src/field/field.tsx +22 -0
- package/src/field/index.ts +2 -0
- package/src/helpers/cn.ts +6 -0
- package/src/helpers/index.ts +1 -0
- package/src/icon-button/icon-button.tsx +126 -0
- package/src/icon-button/index.ts +2 -0
- package/src/index.ts +32 -0
- package/src/input/index.ts +1 -0
- package/src/input/input.tsx +276 -0
- package/src/link/index.ts +4 -0
- package/src/link/link-context.tsx +33 -0
- package/src/link/link.tsx +24 -0
- package/src/menu/divider.tsx +17 -0
- package/src/menu/index.ts +40 -0
- package/src/menu/item.tsx +149 -0
- package/src/menu/menu-context.tsx +24 -0
- package/src/menu/menu.tsx +14 -0
- package/src/menu/popover.tsx +69 -0
- package/src/menu/submenu-trigger.tsx +48 -0
- package/src/menu/submenu.tsx +20 -0
- package/src/menu/trigger.tsx +43 -0
- package/src/notification/index.ts +16 -0
- package/src/notification/list.tsx +123 -0
- package/src/notification/provider.tsx +75 -0
- package/src/page-header/index.ts +2 -0
- package/src/page-header/page-header.tsx +14 -0
- package/src/pagination/index.ts +1 -0
- package/src/pagination/pagination.tsx +136 -0
- package/src/panels/collapsed.tsx +32 -0
- package/src/panels/group.tsx +27 -0
- package/src/panels/index.ts +57 -0
- package/src/panels/layout-context.ts +5 -0
- package/src/panels/orientation-context.tsx +5 -0
- package/src/panels/panel.tsx +95 -0
- package/src/panels/separator.tsx +44 -0
- package/src/pin/index.ts +1 -0
- package/src/pin/pin.tsx +99 -0
- package/src/progress-bar/index.ts +1 -0
- package/src/progress-bar/progress-bar.tsx +48 -0
- package/src/quantity-input/index.ts +1 -0
- package/src/quantity-input/quantity-input.tsx +158 -0
- package/src/scroll-area/index.ts +1 -0
- package/src/scroll-area/scroll-area.tsx +82 -0
- package/src/slider/index.ts +1 -0
- package/src/slider/slider.tsx +75 -0
- package/src/star/index.ts +1 -0
- package/src/star/star.tsx +99 -0
- package/src/switch/index.ts +1 -0
- package/src/switch/switch.tsx +33 -0
- package/src/table/body.tsx +15 -0
- package/src/table/cell-context.tsx +48 -0
- package/src/table/cell.tsx +83 -0
- package/src/table/group-empty-state.tsx +28 -0
- package/src/table/group-header.tsx +62 -0
- package/src/table/header-cell.tsx +158 -0
- package/src/table/header-row.tsx +28 -0
- package/src/table/index.ts +56 -0
- package/src/table/is-group-empty-state.ts +17 -0
- package/src/table/is-group-header.ts +5 -0
- package/src/table/row-context.tsx +61 -0
- package/src/table/row-disabled-context.tsx +7 -0
- package/src/table/row.tsx +98 -0
- package/src/table/table-context.tsx +127 -0
- package/src/table/table-empty.tsx +57 -0
- package/src/table/table.tsx +547 -0
- package/src/table/tree-icon.tsx +147 -0
- package/src/table/use-checkbox.ts +200 -0
- package/src/table/use-column-layout.ts +117 -0
- package/src/table/use-groups.ts +134 -0
- package/src/table/use-keyboard-navigation.ts +274 -0
- package/src/table/use-sort-functions.ts +90 -0
- package/src/tabs/index.ts +20 -0
- package/src/tabs/list.tsx +29 -0
- package/src/tabs/tab.tsx +141 -0
- package/src/tabs/tabs-context.tsx +38 -0
- package/src/tabs/tabs.tsx +27 -0
- package/src/textarea/index.ts +1 -0
- package/src/textarea/textarea.tsx +88 -0
- package/src/toast/index.ts +19 -0
- package/src/toast/list.tsx +133 -0
- package/src/toast/provider.tsx +36 -0
- package/src/toggle/index.ts +17 -0
- package/src/toggle/indicator.tsx +27 -0
- package/src/toggle/item.tsx +54 -0
- package/src/toggle/toggle-context.tsx +68 -0
- package/src/toggle/toggle.tsx +124 -0
- package/src/toggle/use-indicator.tsx +69 -0
- package/src/tooltip/index.ts +1 -0
- package/src/tooltip/tooltip.tsx +122 -0
- 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
|
+
}
|