@dxos/react-ui-table 0.3.3-main.006ca4d

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 (36) hide show
  1. package/LICENSE +8 -0
  2. package/README.md +3 -0
  3. package/dist/lib/browser/index.mjs +844 -0
  4. package/dist/lib/browser/index.mjs.map +7 -0
  5. package/dist/lib/browser/meta.json +1 -0
  6. package/dist/types/src/components/ColumnMenu.d.ts +13 -0
  7. package/dist/types/src/components/ColumnMenu.d.ts.map +1 -0
  8. package/dist/types/src/components/ColumnMenu.stories.d.ts +16 -0
  9. package/dist/types/src/components/ColumnMenu.stories.d.ts.map +1 -0
  10. package/dist/types/src/components/Table.d.ts +29 -0
  11. package/dist/types/src/components/Table.d.ts.map +1 -0
  12. package/dist/types/src/components/Table.stories.d.ts +97 -0
  13. package/dist/types/src/components/Table.stories.d.ts.map +1 -0
  14. package/dist/types/src/components/index.d.ts +3 -0
  15. package/dist/types/src/components/index.d.ts.map +1 -0
  16. package/dist/types/src/helpers.d.ts +81 -0
  17. package/dist/types/src/helpers.d.ts.map +1 -0
  18. package/dist/types/src/index.d.ts +6 -0
  19. package/dist/types/src/index.d.ts.map +1 -0
  20. package/dist/types/src/schema.d.ts +21 -0
  21. package/dist/types/src/schema.d.ts.map +1 -0
  22. package/dist/types/src/theme.d.ts +34 -0
  23. package/dist/types/src/theme.d.ts.map +1 -0
  24. package/dist/types/src/types.d.ts +23 -0
  25. package/dist/types/src/types.d.ts.map +1 -0
  26. package/package.json +56 -0
  27. package/src/components/ColumnMenu.stories.tsx +35 -0
  28. package/src/components/ColumnMenu.tsx +239 -0
  29. package/src/components/Table.stories.tsx +328 -0
  30. package/src/components/Table.tsx +547 -0
  31. package/src/components/index.ts +6 -0
  32. package/src/helpers.tsx +443 -0
  33. package/src/index.ts +9 -0
  34. package/src/schema.ts +39 -0
  35. package/src/theme.ts +70 -0
  36. package/src/types.ts +33 -0
@@ -0,0 +1,547 @@
1
+ //
2
+ // Copyright 2023 DXOS.org
3
+ //
4
+
5
+ import {
6
+ flexRender,
7
+ getCoreRowModel,
8
+ getGroupedRowModel,
9
+ useReactTable,
10
+ type ColumnSizingInfoState,
11
+ type ColumnSizingState,
12
+ type GroupingState,
13
+ type HeaderGroup,
14
+ type Row,
15
+ type RowData,
16
+ type RowSelectionState,
17
+ type TableState,
18
+ type VisibilityState,
19
+ } from '@tanstack/react-table';
20
+ import React, { Fragment, useEffect, useRef, useState } from 'react';
21
+
22
+ import { debounce } from '@dxos/async';
23
+ import { groupBorder, inputSurface, mx } from '@dxos/react-ui-theme';
24
+
25
+ import { defaultTableSlots, type TableSlots } from '../theme';
26
+ import { type TableColumnDef, type KeyValue } from '../types';
27
+
28
+ // TODO(burdon): Sort/filter.
29
+ // TODO(burdon): Drag-and-drop.
30
+ // TODO(burdon): Virtual (e.g., log panel).
31
+
32
+ export type TableSelection<TData extends RowData> = {
33
+ // Controlled if undefined (by default).
34
+ select?: 'single' | 'single-toggle' | 'multiple' | 'multiple-toggle' | undefined;
35
+ selected?: TData[];
36
+ onSelectedChange?: (selected: TData[] | undefined) => void;
37
+ };
38
+
39
+ /**
40
+ * Util to update the selection based on the mode.
41
+ */
42
+ export const updateSelection = (
43
+ selectionState: RowSelectionState,
44
+ id: string,
45
+ selection: TableSelection<any>['select'],
46
+ ): RowSelectionState => {
47
+ switch (selection) {
48
+ case 'single': {
49
+ return {
50
+ [id]: true,
51
+ };
52
+ }
53
+ case 'single-toggle': {
54
+ return selectionState[id]
55
+ ? {}
56
+ : {
57
+ [id]: true,
58
+ };
59
+ }
60
+ case 'multiple': {
61
+ return {
62
+ [id]: true,
63
+ ...selectionState,
64
+ };
65
+ }
66
+ case 'multiple-toggle': {
67
+ if (selectionState[id]) {
68
+ const newSelectionState = { ...selectionState };
69
+ delete newSelectionState[id];
70
+ return newSelectionState;
71
+ } else {
72
+ return {
73
+ [id]: true,
74
+ ...selectionState,
75
+ };
76
+ }
77
+ }
78
+ }
79
+
80
+ return selectionState;
81
+ };
82
+
83
+ export type TableProps<TData extends RowData> = {
84
+ keyAccessor?: KeyValue<TData>;
85
+ data?: TData[];
86
+ columns?: TableColumnDef<TData>[];
87
+ columnVisibility?: VisibilityState;
88
+ onColumnResize?: (state: Record<string, number>) => void;
89
+ grouping?: string[];
90
+ header?: boolean;
91
+ footer?: boolean;
92
+ border?: boolean;
93
+ fullWidth?: boolean;
94
+ pinToBottom?: boolean;
95
+ slots?: TableSlots;
96
+ debug?: boolean;
97
+ } & TableSelection<TData>;
98
+
99
+ export const Table = <TData extends RowData>({ slots = defaultTableSlots, ...props }: TableProps<TData>) => {
100
+ const {
101
+ data = [],
102
+ columns = [],
103
+ onColumnResize,
104
+ columnVisibility,
105
+ header = true,
106
+ footer,
107
+ select,
108
+ selected,
109
+ onSelectedChange,
110
+ fullWidth,
111
+ pinToBottom,
112
+ debug,
113
+ } = props;
114
+
115
+ // Update controlled selection.
116
+ // https://tanstack.com/table/v8/docs/api/features/row-selection
117
+ const [focus, setFocus] = useState<string>();
118
+ const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
119
+ useEffect(() => {
120
+ setRowSelection(
121
+ selected?.reduce((selectionState: RowSelectionState, selected) => {
122
+ const row = table.getRowModel().rows.find((row) => row.original === selected);
123
+ if (row) {
124
+ selectionState[row.id] = true;
125
+ }
126
+
127
+ return selectionState;
128
+ }, {}) ?? {},
129
+ );
130
+ }, [select, selected]);
131
+
132
+ // Resizing.
133
+ const [columnSizingInfo, setColumnSizingInfo] = useState<ColumnSizingInfoState>({} as ColumnSizingInfoState);
134
+ const onColumnResizeDebounced = debounce<ColumnSizingState>((info) => onColumnResize?.(info), 500);
135
+ useEffect(() => {
136
+ if (columnSizingInfo.columnSizingStart?.length === 0) {
137
+ onColumnResizeDebounced(table.getState().columnSizing);
138
+ }
139
+ }, [columnSizingInfo]);
140
+
141
+ const [grouping, setGrouping] = useState<GroupingState>(props.grouping ?? []);
142
+ useEffect(() => setGrouping(props.grouping ?? []), [props.grouping]);
143
+
144
+ //
145
+ // Update table state.
146
+ // https://tanstack.com/table/v8/docs/api/core/table
147
+ //
148
+ const table = useReactTable({
149
+ data,
150
+ columns,
151
+ defaultColumn: {
152
+ size: 400, // Required in order remove default width.
153
+ maxSize: 800,
154
+ },
155
+ getCoreRowModel: getCoreRowModel(),
156
+ meta: {},
157
+
158
+ // TODO(burdon): Pagination.
159
+ // TODO(burdon): Sorting.
160
+ // TODO(burdon): Filtering.
161
+ state: {
162
+ columnVisibility,
163
+ columnSizingInfo,
164
+ rowSelection,
165
+ grouping,
166
+ },
167
+
168
+ // Grouping.
169
+ getGroupedRowModel: getGroupedRowModel(),
170
+ onGroupingChange: setGrouping,
171
+
172
+ // Selection.
173
+ enableRowSelection: select === 'single' || select === 'single-toggle',
174
+ enableMultiRowSelection: select === 'multiple' || select === 'multiple-toggle',
175
+ onRowSelectionChange: (rows) => {
176
+ setRowSelection(rows);
177
+ },
178
+
179
+ // Resize columns.
180
+ // TODO(burdon): Drag to re-order columns.
181
+ columnResizeMode: 'onChange',
182
+ enableColumnResizing: true,
183
+ onColumnSizingInfoChange: setColumnSizingInfo,
184
+
185
+ debugTable: debug,
186
+ });
187
+
188
+ // TODO(burdon): Add flex if not resizable.
189
+ // Create additional expansion column if all columns have fixed width.
190
+ const expand = false; // columns.map((column) => column.size).filter(Boolean).length === columns?.length;
191
+
192
+ // Pin scrollbar to bottom.
193
+ const containerRef = usePinToBottom(data, pinToBottom);
194
+
195
+ const handleSelect = (row: Row<TData>) => {
196
+ if (select) {
197
+ // Uncontrolled.
198
+ setRowSelection((selectionState: RowSelectionState) => {
199
+ const newSelectionState = updateSelection(selectionState, row.id, select);
200
+ if (onSelectedChange) {
201
+ const rows: TData[] = Object.keys(newSelectionState).map((id) => table.getRowModel().rowsById[id].original);
202
+ onSelectedChange(rows);
203
+ }
204
+
205
+ return newSelectionState;
206
+ });
207
+ } else {
208
+ // Controlled.
209
+ onSelectedChange?.([row.original]);
210
+ }
211
+ };
212
+
213
+ // TODO(burdon): Use radix ScrollArea.
214
+ // https://www.radix-ui.com/primitives/docs/components/scroll-area
215
+ return (
216
+ <div ref={containerRef} className={mx('grow overflow-auto', slots?.root?.className)}>
217
+ <table
218
+ // Styles:
219
+ // table-fixed: Prevents fixed sized columns from shrinking.
220
+ className={mx(!fullWidth && 'table-fixed', inputSurface, slots?.table?.className)}
221
+ style={{
222
+ width: fullWidth ? '100%' : table.getTotalSize(),
223
+ }}
224
+ >
225
+ {/* Head */}
226
+ <TableHead
227
+ {...props}
228
+ slots={slots}
229
+ header={header}
230
+ state={table.getState()}
231
+ headers={table.getHeaderGroups()}
232
+ />
233
+
234
+ {/* Rows */}
235
+ {grouping.length === 0 && (
236
+ <TableBody
237
+ {...props}
238
+ slots={slots}
239
+ rowSelection={rowSelection}
240
+ expand={expand}
241
+ focus={focus}
242
+ onFocus={setFocus}
243
+ onSelect={handleSelect}
244
+ rows={table.getRowModel().rows}
245
+ />
246
+ )}
247
+
248
+ {/* Groups */}
249
+ {grouping.length !== 0 &&
250
+ table.getGroupedRowModel().rows.map((row, i) => {
251
+ return (
252
+ <Fragment key={i}>
253
+ {/* TODO(burdon): Customize group header renderer. */}
254
+ <thead>
255
+ <tr>
256
+ {slots?.margin && <th className={mx(slots?.margin?.className)} />}
257
+ {debug && <th />}
258
+ <th
259
+ // TODO(burdon): Calculate row span.
260
+ colSpan={table.getHeaderGroups()[0].headers.length}
261
+ className={mx('text-left', slots?.group?.className)}
262
+ >
263
+ {table.getState().grouping[0]}[{String(row.getGroupingValue(table.getState().grouping[0]))}]
264
+ </th>
265
+ </tr>
266
+ </thead>
267
+
268
+ <TableBody
269
+ {...props}
270
+ slots={slots}
271
+ rowSelection={rowSelection}
272
+ expand={expand}
273
+ focus={focus}
274
+ onFocus={setFocus}
275
+ onSelect={handleSelect}
276
+ rows={row.subRows}
277
+ />
278
+ </Fragment>
279
+ );
280
+ })}
281
+
282
+ {/* Foot */}
283
+ {footer && <TableFoot {...props} slots={slots} footers={table.getFooterGroups()} />}
284
+ </table>
285
+
286
+ {debug && (
287
+ <pre className='font-mono text-xs text-neutral-500 my-4 p-2 ring'>
288
+ <code>{JSON.stringify(table.getState(), undefined, 2)}</code>
289
+ </pre>
290
+ )}
291
+ </div>
292
+ );
293
+ };
294
+
295
+ /**
296
+ * Glue to bottom as rows are added.
297
+ */
298
+ // TODO(burdon): Causes scrollbar to be constantly visible.
299
+ // https://css-tricks.com/books/greatest-css-tricks/pin-scrolling-to-bottom
300
+ const usePinToBottom = <TData extends RowData>(data: TData[], pinToBottom?: boolean) => {
301
+ const containerRef = useRef<HTMLDivElement | null>(null);
302
+ const [stickyScrolling, setStickyScrolling] = useState(true);
303
+ useEffect(() => {
304
+ const container = containerRef.current;
305
+ if (!pinToBottom || !container) {
306
+ return;
307
+ }
308
+
309
+ // TODO(burdon): Set when scrolled to bottom and unset when manually scrolled away.
310
+ const handler = () => {
311
+ const bottom = container.scrollHeight - container.scrollTop - container.clientHeight === 0;
312
+ setStickyScrolling(bottom);
313
+ };
314
+
315
+ container.addEventListener('scroll', handler);
316
+ return () => container.removeEventListener('scroll', handler);
317
+ }, []);
318
+
319
+ useEffect(() => {
320
+ if (!pinToBottom || !containerRef.current || !stickyScrolling) {
321
+ return;
322
+ }
323
+
324
+ containerRef.current?.scroll({ top: containerRef.current.scrollHeight });
325
+ }, [data]);
326
+
327
+ return containerRef;
328
+ };
329
+
330
+ //
331
+ // Head.
332
+ //
333
+
334
+ type TableHeadProps<TData extends RowData> = Partial<TableProps<TData>> & {
335
+ state: TableState;
336
+ headers: HeaderGroup<TData>[];
337
+ expand?: boolean;
338
+ };
339
+
340
+ const TableHead = <TData extends RowData>({
341
+ state,
342
+ headers,
343
+ expand,
344
+ header,
345
+ debug,
346
+ fullWidth,
347
+ border,
348
+ slots,
349
+ }: TableHeadProps<TData>) => {
350
+ return (
351
+ <thead className={mx(header ? ['sticky top-0 z-10'] : 'collapse')}>
352
+ {headers.map((headerGroup) => {
353
+ return (
354
+ // Group element to hover resize handles.
355
+ <tr key={headerGroup.id} className='font-light group'>
356
+ {slots?.margin && <th className={mx(slots?.margin?.className)} />}
357
+
358
+ {/* TODO(burdon): Calc. width. */}
359
+ {debug && (
360
+ <th className='text-left' style={{ width: 32 }}>
361
+ #
362
+ </th>
363
+ )}
364
+
365
+ {headerGroup.headers.map((header) => {
366
+ return (
367
+ <th
368
+ key={header.id}
369
+ style={{
370
+ // Don't set width if fullWidth and no explicit size.
371
+ width: fullWidth && header.column.columnDef.meta?.expand ? undefined : header.getSize(),
372
+ }}
373
+ // Relative for resize handle.
374
+ // TODO(burdon): Border scrolls with main content.
375
+ // https://stackoverflow.com/questions/50361698/border-style-do-not-work-with-sticky-position-element
376
+ className={mx(
377
+ 'relative text-left',
378
+ border && groupBorder,
379
+ slots?.header?.className,
380
+ header.column.columnDef.meta?.slots?.header?.className,
381
+ )}
382
+ >
383
+ {!header || header.isPlaceholder
384
+ ? null
385
+ : flexRender(header.column.columnDef.header, header.getContext())}
386
+
387
+ {/*
388
+ * Resize handle.
389
+ * https://codesandbox.io/p/sandbox/github/tanstack/table/tree/main/examples/react/column-sizing
390
+ */}
391
+ {header.column.columnDef.meta?.resizable && (
392
+ <div
393
+ className={mx(
394
+ 'absolute top-0 pl-1 h-full z-[10] w-[7px] -right-[5px]',
395
+ 'cursor-col-resize select-none touch-none opacity-20 hover:opacity-100',
396
+ header.column.getIsResizing() && 'hidden',
397
+ )}
398
+ style={{
399
+ transform: header.column.getIsResizing()
400
+ ? `translateX(${state.columnSizingInfo.deltaOffset}px)`
401
+ : undefined,
402
+ }}
403
+ onMouseDown={header.getResizeHandler()}
404
+ onTouchStart={header.getResizeHandler()}
405
+ >
406
+ <div className='flex group-hover:bg-neutral-700 -ml-[2px] w-[1px] h-full' />
407
+ </div>
408
+ )}
409
+ </th>
410
+ );
411
+ })}
412
+ {expand && <th />}
413
+ {slots?.margin && <th className={mx(slots?.margin?.className)} />}
414
+ </tr>
415
+ );
416
+ })}
417
+ </thead>
418
+ );
419
+ };
420
+
421
+ //
422
+ // Body.
423
+ //
424
+
425
+ type TableBodyProps<TData extends RowData> = Partial<TableProps<TData>> & {
426
+ rows: Row<TData>[];
427
+ rowSelection: RowSelectionState;
428
+ expand?: boolean;
429
+ focus?: string;
430
+ onFocus?: (id?: string) => void;
431
+ onSelect?: (row: Row<TData>) => void;
432
+ };
433
+
434
+ const TableBody = <TData extends RowData>({
435
+ keyAccessor,
436
+ rows,
437
+ rowSelection,
438
+ focus,
439
+ onFocus,
440
+ onSelect,
441
+ debug,
442
+ border,
443
+ expand,
444
+ slots,
445
+ }: TableBodyProps<TData>) => {
446
+ return (
447
+ <tbody>
448
+ {rows.map((row) => {
449
+ return (
450
+ <tr
451
+ key={keyAccessor ? keyAccessor(row.original) : row.id}
452
+ onClick={() => onSelect?.(row)}
453
+ role='button' // TODO(burdon): ???
454
+ className={mx(
455
+ 'group',
456
+ rowSelection[row.id] && slots?.selected?.className,
457
+ focus === row.id && slots?.focus?.className,
458
+ slots?.row?.className,
459
+ )}
460
+ >
461
+ {/* TODO(burdon): Dummy button for focus (don't alter geometry). */}
462
+ {slots?.margin && (
463
+ <td className={mx(slots?.margin?.className)}>
464
+ <button
465
+ role='button'
466
+ style={{ width: 1, height: 1 }}
467
+ className='focus:outline-none'
468
+ onFocus={() => onFocus?.(keyAccessor ? keyAccessor(row.original) : row.id)}
469
+ onBlur={() => onFocus?.(undefined)}
470
+ onKeyDown={(event) => {
471
+ // TODO(burdon): Move focus.
472
+ switch (event.key) {
473
+ case 'ArrowUp': {
474
+ break;
475
+ }
476
+ case 'ArrowDown': {
477
+ break;
478
+ }
479
+ }
480
+ }}
481
+ />
482
+ </td>
483
+ )}
484
+
485
+ {debug && <td>{row.id}</td>}
486
+
487
+ {row.getVisibleCells().map((cell) => {
488
+ // TODO(burdon): Allow class override from column.
489
+ return (
490
+ <td
491
+ key={cell.id}
492
+ className={mx(border && groupBorder, cell.column.columnDef.meta?.slots?.cell?.className)}
493
+ >
494
+ {flexRender(cell.column.columnDef.cell, { className: 'px-2', ...cell.getContext() })}
495
+ </td>
496
+ );
497
+ })}
498
+
499
+ {expand && <td />}
500
+ {slots?.margin && <td className={mx(slots?.margin?.className)} />}
501
+ </tr>
502
+ );
503
+ })}
504
+ </tbody>
505
+ );
506
+ };
507
+
508
+ //
509
+ // Footer
510
+ //
511
+
512
+ type TableFootProps<TData extends RowData> = Partial<TableProps<TData>> & {
513
+ footers: HeaderGroup<TData>[];
514
+ expand?: boolean;
515
+ };
516
+
517
+ const TableFoot = <TData extends RowData>({ footers, expand, slots, debug, border }: TableFootProps<TData>) => {
518
+ return (
519
+ <tfoot className={mx('sticky bottom-0 z-[10]', slots?.footer?.className)}>
520
+ {footers.map((footerGroup) => (
521
+ <tr key={footerGroup.id} className='font-thin'>
522
+ {slots?.margin && <th className={mx(slots?.margin?.className)} />}
523
+ {debug && <th />}
524
+
525
+ {footerGroup.headers.map((footer) => {
526
+ return (
527
+ <th
528
+ key={footer.id}
529
+ className={mx(
530
+ border && groupBorder,
531
+ 'text-left',
532
+ slots?.footer?.className,
533
+ footer.column.columnDef.meta?.slots?.footer?.className,
534
+ )}
535
+ >
536
+ {footer.isPlaceholder ? null : flexRender(footer.column.columnDef.footer, footer.getContext())}
537
+ </th>
538
+ );
539
+ })}
540
+
541
+ {expand && <th />}
542
+ {slots?.margin && <th className={mx(slots?.margin?.className)} />}
543
+ </tr>
544
+ ))}
545
+ </tfoot>
546
+ );
547
+ };
@@ -0,0 +1,6 @@
1
+ //
2
+ // Copyright 2022 DXOS.org
3
+ //
4
+
5
+ export * from './ColumnMenu';
6
+ export * from './Table';