@dxos/react-ui-table 0.4.8-main.c67d72c → 0.4.8-main.cd60bd2

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 (31) hide show
  1. package/dist/lib/browser/index.mjs +275 -191
  2. package/dist/lib/browser/index.mjs.map +4 -4
  3. package/dist/lib/browser/meta.json +1 -1
  4. package/dist/types/src/components/Cell/Cell.d.ts.map +1 -1
  5. package/dist/types/src/components/ColumnMenu.d.ts.map +1 -1
  6. package/dist/types/src/components/ColumnMenu.stories.d.ts +10 -1
  7. package/dist/types/src/components/ColumnMenu.stories.d.ts.map +1 -1
  8. package/dist/types/src/components/SortButton.d.ts +8 -0
  9. package/dist/types/src/components/SortButton.d.ts.map +1 -0
  10. package/dist/types/src/components/Table/Table.d.ts +2 -0
  11. package/dist/types/src/components/Table/Table.d.ts.map +1 -1
  12. package/dist/types/src/components/Table/Table.stories.d.ts.map +1 -1
  13. package/dist/types/src/components/Table/TableBody.d.ts.map +1 -1
  14. package/dist/types/src/components/Table/TableHead.d.ts.map +1 -1
  15. package/dist/types/src/components/Table/props.d.ts +1 -0
  16. package/dist/types/src/components/Table/props.d.ts.map +1 -1
  17. package/dist/types/src/helpers.d.ts.map +1 -1
  18. package/dist/types/src/theme.d.ts +1 -1
  19. package/dist/types/src/theme.d.ts.map +1 -1
  20. package/package.json +15 -15
  21. package/src/components/Cell/Cell.tsx +4 -1
  22. package/src/components/ColumnMenu.stories.tsx +13 -6
  23. package/src/components/ColumnMenu.tsx +13 -4
  24. package/src/components/SortButton.tsx +34 -0
  25. package/src/components/Table/Table.stories.tsx +51 -62
  26. package/src/components/Table/Table.tsx +65 -41
  27. package/src/components/Table/TableBody.tsx +1 -0
  28. package/src/components/Table/TableHead.tsx +4 -2
  29. package/src/components/Table/props.ts +1 -0
  30. package/src/helpers.tsx +61 -45
  31. package/src/theme.ts +3 -1
@@ -4,6 +4,7 @@
4
4
  import { useControllableState } from '@radix-ui/react-use-controllable-state';
5
5
  import {
6
6
  getCoreRowModel,
7
+ getSortedRowModel,
7
8
  useReactTable,
8
9
  type ColumnSizingInfoState,
9
10
  type GroupingState,
@@ -12,12 +13,13 @@ import {
12
13
  type OnChangeFn,
13
14
  type RowSelectionState,
14
15
  getGroupedRowModel,
16
+ getExpandedRowModel,
15
17
  } from '@tanstack/react-table';
16
18
  import { useVirtualizer, type VirtualizerOptions } from '@tanstack/react-virtual';
17
19
  import React, { Fragment, useCallback, useEffect, useState } from 'react';
18
20
 
19
- import { debounce } from '@dxos/async';
20
21
  import { log } from '@dxos/log';
22
+ import { useDefaultValue, useOnTransition } from '@dxos/react-ui';
21
23
 
22
24
  import { TableBody } from './TableBody';
23
25
  import { TableProvider as UntypedTableProvider, type TypedTableProvider, useTableContext } from './TableContext';
@@ -29,8 +31,6 @@ import { groupTh, tableRoot } from '../../theme';
29
31
  export const Table = <TData extends RowData>(props: TableProps<TData>) => {
30
32
  const {
31
33
  role,
32
- data = [],
33
- columns = [],
34
34
  onColumnResize,
35
35
  columnVisibility,
36
36
  header = true,
@@ -38,18 +38,22 @@ export const Table = <TData extends RowData>(props: TableProps<TData>) => {
38
38
  debug,
39
39
  onDataSelectionChange,
40
40
  getScrollElement,
41
+ pinLastRow,
41
42
  } = props;
42
43
 
43
- const TableProvider = UntypedTableProvider as TypedTableProvider<TData>;
44
+ const columns = useDefaultValue(props.columns, []);
45
+ const data = useDefaultValue(props.data, []);
44
46
 
45
- //
46
- // Column resizing
47
- //
47
+ const TableProvider = UntypedTableProvider as TypedTableProvider<TData>;
48
48
 
49
+ const [columnsInitialised, setColumnsInitialised] = useState(false);
49
50
  const [columnSizing, setColumnSizing] = useState<ColumnSizingState>({});
50
51
 
51
52
  useEffect(() => {
52
- // Set initial state.
53
+ if (columnsInitialised) {
54
+ return;
55
+ }
56
+
53
57
  setColumnSizing(
54
58
  columns
55
59
  .filter((column) => !!column.size && (column as any).prop !== undefined)
@@ -58,20 +62,15 @@ export const Table = <TData extends RowData>(props: TableProps<TData>) => {
58
62
  return state;
59
63
  }, {}),
60
64
  );
61
- }, [columns]);
62
65
 
63
- const [columnSizingInfo, setColumnSizingInfo] = useState<ColumnSizingInfoState>({} as ColumnSizingInfoState);
66
+ setColumnsInitialised(true);
67
+ }, [columns, setColumnSizing]);
64
68
 
65
- const onColumnResizeDebounced = onColumnResize && debounce<ColumnSizingState>(onColumnResize, 1_000);
66
- useEffect(() => {
67
- if (columnSizingInfo.columnSizingStart?.length === 0) {
68
- onColumnResizeDebounced?.(table.getState().columnSizing);
69
- }
70
- }, [columnSizingInfo]);
69
+ const [columnSizingInfo, setColumnSizingInfo] = useState<ColumnSizingInfoState>({} as ColumnSizingInfoState);
71
70
 
72
- //
73
- // Row selection
74
- //
71
+ // Notify on column resize.
72
+ const notifyColumnResize = useCallback(() => onColumnResize?.(columnSizing), [onColumnResize, columnSizing]);
73
+ useOnTransition(columnSizingInfo.isResizingColumn, (v) => typeof v === 'string', false, notifyColumnResize);
75
74
 
76
75
  const [rowSelection = {}, setRowSelection] = useControllableState({
77
76
  prop: props.rowSelection,
@@ -79,10 +78,6 @@ export const Table = <TData extends RowData>(props: TableProps<TData>) => {
79
78
  defaultProp: props.defaultRowSelection,
80
79
  });
81
80
 
82
- useEffect(() => {
83
- onDataSelectionChange?.(Object.keys(rowSelection).map((id) => table.getRowModel().rowsById[id].original));
84
- }, [onDataSelectionChange, rowSelection]);
85
-
86
81
  // TODO(thure): Does @tanstack/react-table really need this intervention? It did seem necessary to enforce single-selection...
87
82
  const handleRowSelectionChange = useCallback<OnChangeFn<RowSelectionState>>(
88
83
  (updaterOrValue) => {
@@ -96,15 +91,11 @@ export const Table = <TData extends RowData>(props: TableProps<TData>) => {
96
91
  setRowSelection({});
97
92
  }
98
93
  },
99
- [rowSelection, setRowSelection],
94
+ [rowsSelectable, setRowSelection, rowSelection],
100
95
  );
101
96
 
102
- //
103
- // Row grouping
104
- //
105
-
106
97
  const [grouping, handleGroupingChange] = useState<GroupingState>(props.grouping ?? []);
107
- useEffect(() => handleGroupingChange(props.grouping ?? []), [props.grouping]);
98
+ useEffect(() => handleGroupingChange(props.grouping ?? []), [handleGroupingChange, props.grouping]);
108
99
 
109
100
  const table = useReactTable({
110
101
  // Data
@@ -135,23 +126,41 @@ export const Table = <TData extends RowData>(props: TableProps<TData>) => {
135
126
 
136
127
  // Rows
137
128
  getCoreRowModel: getCoreRowModel(),
129
+ getSortedRowModel: getSortedRowModel(),
130
+ getExpandedRowModel: getExpandedRowModel(),
138
131
 
139
132
  // Grouping
140
133
  getGroupedRowModel: grouping.length > 1 ? getGroupedRowModel() : undefined,
141
134
  onGroupingChange: handleGroupingChange,
142
135
 
143
- // Selection
144
- ...(rowsSelectable === 'multi'
145
- ? { enableMultiRowSelection: true }
146
- : rowsSelectable
147
- ? { enableRowSelection: true }
148
- : {}),
136
+ enableMultiRowSelection: rowsSelectable === 'multi' ? true : undefined,
137
+ enableRowSelection: rowsSelectable === true ? true : undefined,
138
+
149
139
  onRowSelectionChange: handleRowSelectionChange,
150
140
 
141
+ enableSorting: true,
142
+ enableColumnPinning: true,
143
+
151
144
  // Debug
152
145
  debugTable: debug,
153
146
  });
154
147
 
148
+ useEffect(() => {
149
+ onDataSelectionChange?.(Object.keys(rowSelection).map((id) => table.getRowModel().rowsById[id].original));
150
+ }, [onDataSelectionChange, rowSelection, table]);
151
+
152
+ useEffect(() => {
153
+ if (!pinLastRow) {
154
+ return;
155
+ }
156
+
157
+ // Clear row pinning
158
+ table.resetRowPinning();
159
+
160
+ const rows = table.getRowModel().rows;
161
+ rows[rows.length - 1].pin('bottom');
162
+ }, [pinLastRow, table, data]);
163
+
155
164
  // Create additional expansion column if all columns have fixed width.
156
165
  const expand = false; // columns.map((column) => column.size).filter(Boolean).length === columns?.length;
157
166
 
@@ -178,7 +187,11 @@ export const Table = <TData extends RowData>(props: TableProps<TData>) => {
178
187
  const TableImpl = <TData extends RowData>(props: TableProps<TData>) => {
179
188
  const { debug, classNames, getScrollElement, role, footer, grouping, fullWidth } = props;
180
189
  const { table } = useTableContext<TData>('TableImpl');
181
- const { rows } = table.getRowModel();
190
+
191
+ const centerRows = table.getCenterRows();
192
+ const bottomRows = table.getBottomRows();
193
+
194
+ const rows = [...centerRows, ...bottomRows];
182
195
 
183
196
  if (debug) {
184
197
  return (
@@ -188,6 +201,8 @@ const TableImpl = <TData extends RowData>(props: TableProps<TData>) => {
188
201
  );
189
202
  }
190
203
 
204
+ const isResizingColumn = table.getState().columnSizingInfo.isResizingColumn;
205
+
191
206
  return (
192
207
  <table
193
208
  role={role}
@@ -198,7 +213,11 @@ const TableImpl = <TData extends RowData>(props: TableProps<TData>) => {
198
213
 
199
214
  {grouping?.length !== 0 ? (
200
215
  getScrollElement ? (
201
- <VirtualizedTableContent getScrollElement={getScrollElement} />
216
+ isResizingColumn ? (
217
+ <MemoizedVirtualisedTableContent getScrollElement={getScrollElement} />
218
+ ) : (
219
+ <VirtualizedTableContent getScrollElement={getScrollElement} />
220
+ )
202
221
  ) : (
203
222
  <TableBody rows={rows} />
204
223
  )
@@ -214,10 +233,12 @@ const TableImpl = <TData extends RowData>(props: TableProps<TData>) => {
214
233
  const VirtualizedTableContent = ({
215
234
  getScrollElement,
216
235
  }: Pick<VirtualizerOptions<Element, Element>, 'getScrollElement'>) => {
217
- const {
218
- table: { getRowModel },
219
- } = useTableContext('VirtualizedTableContent');
220
- const rows = getRowModel().rows;
236
+ const { table } = useTableContext('VirtualizedTableContent');
237
+
238
+ const centerRows = table.getCenterRows();
239
+ const pinnedRows = table.getBottomRows();
240
+
241
+ const rows = [...centerRows, ...pinnedRows];
221
242
 
222
243
  const { getTotalSize, getVirtualItems } = useVirtualizer({
223
244
  getScrollElement,
@@ -225,6 +246,7 @@ const VirtualizedTableContent = ({
225
246
  overscan: 4,
226
247
  estimateSize: () => 33,
227
248
  });
249
+
228
250
  const virtualRows = getVirtualItems();
229
251
  const totalSize = getTotalSize();
230
252
 
@@ -252,6 +274,8 @@ const VirtualizedTableContent = ({
252
274
  );
253
275
  };
254
276
 
277
+ export const MemoizedVirtualisedTableContent = React.memo(VirtualizedTableContent) as typeof VirtualizedTableContent;
278
+
255
279
  const GroupedTableContent = () => {
256
280
  const {
257
281
  table: { getGroupedRowModel, getHeaderGroups, getState },
@@ -19,6 +19,7 @@ type TableBodyProps<TData extends RowData> = {
19
19
  const TableBody = <TData extends RowData>({ rows }: TableBodyProps<TData>) => {
20
20
  const { table, keyAccessor, currentDatum, debug, expand, isGrid, onDatumClick } =
21
21
  useTableContext<TData>(TABLE_BODY_NAME);
22
+
22
23
  const rowSelection = table.getState().rowSelection;
23
24
  const domAttributes = useArrowNavigationGroup({ axis: 'grid' });
24
25
  const canBeCurrent = !isGrid && !!onDatumClick;
@@ -34,6 +34,8 @@ const TableHead = <TData extends RowData>(_props: TableHeadProps) => {
34
34
 
35
35
  {headerGroup.headers.map((header) => {
36
36
  const isResizing = header.column.getIsResizing();
37
+ const resizeHandler = header.getResizeHandler();
38
+
37
39
  return (
38
40
  <th
39
41
  key={header.id}
@@ -56,8 +58,8 @@ const TableHead = <TData extends RowData>(_props: TableHeadProps) => {
56
58
  style={{
57
59
  transform: `translateX(${isResizing ? state.columnSizingInfo.deltaOffset : 0}px)`,
58
60
  }}
59
- onMouseDown={header.getResizeHandler()}
60
- onTouchStart={header.getResizeHandler()}
61
+ onMouseDown={resizeHandler}
62
+ onTouchStart={resizeHandler}
61
63
  >
62
64
  <div className={mx(theadResizeThumb(tableContext))} />
63
65
  </div>
@@ -52,6 +52,7 @@ export type TableProps<
52
52
  onDataSelectionChange: (dataSelection: TData[]) => void;
53
53
  // `table` element props
54
54
  classNames: ClassNameValue;
55
+ pinLastRow: boolean;
55
56
  } & Pick<VirtualizerOptions<ScrollElement, ItemElement>, 'getScrollElement'>
56
57
  >;
57
58
 
package/src/helpers.tsx CHANGED
@@ -95,8 +95,11 @@ const ComboboxBuilderCell = <TData extends RowData>(cellContext: CellContext<TDa
95
95
  };
96
96
 
97
97
  const StringBuilderCell = <TData extends RowData>(cellContext: CellContext<TData, string>) => {
98
- const inputRef = useRef<HTMLInputElement>(null);
99
- const { onUpdate } = cellContext.column.columnDef.meta as ColumnMeta<TData, string>;
98
+ const ref = useRef<HTMLInputElement>(null);
99
+ const { findPrevFocusable } = useFocusFinders();
100
+
101
+ const { onUpdate, cell } = cellContext.column.columnDef.meta as ColumnMeta<TData, string>;
102
+
100
103
  // https://tanstack.com/table/v8/docs/examples/react/editable-data
101
104
  const initialValue = cellContext.getValue();
102
105
  const [value, setValue] = useState(() => {
@@ -109,32 +112,34 @@ const StringBuilderCell = <TData extends RowData>(cellContext: CellContext<TData
109
112
  });
110
113
 
111
114
  const handleSave = () => {
112
- if (value === initialValue) {
113
- return;
114
- }
115
+ ref.current && document.activeElement === ref.current && findPrevFocusable(ref.current)?.focus();
115
116
 
116
- onUpdate?.(cellContext.row.original, cellContext.column.id, value);
117
+ if (value !== initialValue) {
118
+ onUpdate?.(cellContext.row.original, cellContext.column.id, value);
119
+ }
117
120
  };
118
121
 
119
- const handleCancel = () => {
120
- setValue(initialValue);
121
- };
122
+ const handleCancel = () => setValue(initialValue);
122
123
 
123
- // Check if first input column of last row.
124
- const rows = cellContext.table.getRowModel().flatRows;
124
+ let placeholder = false;
125
125
  const columns = cellContext.table.getVisibleFlatColumns();
126
- const placeholder = cellContext.row.index === rows.length - 1 && columns[0].id === cellContext.column.id;
126
+
127
+ // Calling getRowModel is expensive, so only do it when we're a cell in the first position
128
+ if (columns[0].id === cellContext.column.id) {
129
+ const rowCount = cellContext.table.getRowModel().rows.length;
130
+ placeholder = cellContext.row.index === rowCount - 1 && columns[0].id === cellContext.column.id;
131
+ }
127
132
 
128
133
  // TODO(burdon): Don't render inputs unless mouse over (Show ellipsis when div)?
129
134
  return (
130
135
  <Input.Root>
131
136
  <Input.TextInput
132
- ref={inputRef}
137
+ ref={ref}
133
138
  variant='subdued'
134
139
  placeholder={placeholder ? 'Add row...' : undefined}
135
- classNames={['is-full', textPadding]}
140
+ classNames={['is-full', textPadding, cell?.classNames]}
136
141
  value={value ?? ''}
137
- onBlur={() => handleSave()}
142
+ onBlur={handleSave}
138
143
  onChange={(event) => setValue(event.target.value)}
139
144
  onKeyDown={(event) => (event.key === 'Enter' && handleSave()) || (event.key === 'Escape' && handleCancel())}
140
145
  />
@@ -164,17 +169,20 @@ const NumberBuilderCell = <TData extends RowData>(cellContext: CellContext<TData
164
169
  (!document.activeElement || document.activeElement !== ref.current) && setText(displayNumber(value, digits));
165
170
  }, [digits, value]);
166
171
 
167
- const handleEdit = () => {
168
- if (onUpdate) {
169
- setText(value !== undefined ? String(value) : '');
170
- }
171
- };
172
+ const handleEdit = () => setText(value !== undefined ? String(value) : '');
172
173
 
173
174
  // TODO(burdon): Property is encoded as float (e.g., 6.1 => 6.099)
174
175
  const handleSave = () => {
175
176
  const nextValue = parseNumber(text);
176
- onUpdate?.(cellContext.row.original, cellContext.column.id, isNaN(nextValue) ? undefined : nextValue);
177
- setText(displayNumber(nextValue, digits));
177
+
178
+ if (!isNaN(nextValue)) {
179
+ onUpdate?.(cellContext.row.original, cellContext.column.id, nextValue);
180
+ setText(displayNumber(nextValue, digits));
181
+ } else {
182
+ // Reset the text to display the original value without updating the model.
183
+ setText(displayNumber(value, digits));
184
+ }
185
+
178
186
  // Shift focus only if input is focused on save (e.g. on `Enter`).
179
187
  ref.current && document.activeElement === ref.current && findPrevFocusable(ref.current)?.focus();
180
188
  };
@@ -210,20 +218,23 @@ const RowSelectorBuilderCell = <TData extends RowData>(cellContext: CellContext<
210
218
  const checked = row.getCanSelect()
211
219
  ? row.getIsSelected()
212
220
  : row.getCanSelectSubRows() && (row.getIsSomeSelected() ? 'indeterminate' : row.getIsAllSubRowsSelected());
221
+
213
222
  return (
214
- <Input.Root>
215
- <Input.Checkbox
216
- size={4}
217
- classNames={['mli-auto', cell?.classNames]}
218
- checked={checked}
219
- onCheckedChange={(event) => {
220
- if (row.getCanSelect()) {
221
- row.getToggleSelectedHandler()(event);
222
- }
223
- }}
224
- disabled={!(row.getCanSelect() || row.getCanSelectSubRows())}
225
- />
226
- </Input.Root>
223
+ <div className='flex justify-center w-full h-full' role='presentation'>
224
+ <Input.Root>
225
+ <Input.Checkbox
226
+ size={4}
227
+ classNames={[cell?.classNames]}
228
+ checked={checked}
229
+ onCheckedChange={(event) => {
230
+ if (row.getCanSelect()) {
231
+ row.getToggleSelectedHandler()(event);
232
+ }
233
+ }}
234
+ disabled={!(row.getCanSelect() || row.getCanSelectSubRows())}
235
+ />
236
+ </Input.Root>
237
+ </div>
227
238
  );
228
239
  };
229
240
 
@@ -422,21 +433,22 @@ export class ColumnBuilder<TData extends RowData> {
422
433
  return {
423
434
  id,
424
435
  size: 32,
425
- minSize: 32,
426
- maxSize: 32,
436
+ enableResizing: false,
427
437
  meta: { onUpdate, cell: { classNames } },
428
438
  header: ({ table }) => {
429
439
  const { rowsSelectable } = useTableContext('HELPER_SELECT_ROW_HEADER_CELL');
430
440
  const checked = table.getIsSomeRowsSelected() ? 'indeterminate' : table.getIsAllRowsSelected();
431
441
  return rowsSelectable === 'multi' ? (
432
- <Input.Root>
433
- <Input.Checkbox
434
- size={4}
435
- classNames={['mli-auto', classNames]}
436
- checked={checked}
437
- onCheckedChange={() => table.toggleAllRowsSelected()}
438
- />
439
- </Input.Root>
442
+ <div className='flex justify-center w-full h-full' role='presentation'>
443
+ <Input.Root>
444
+ <Input.Checkbox
445
+ size={4}
446
+ classNames={['mli-auto', classNames]}
447
+ checked={checked}
448
+ onCheckedChange={() => table.toggleAllRowsSelected()}
449
+ />
450
+ </Input.Root>
451
+ </div>
440
452
  ) : null;
441
453
  },
442
454
  cell: RowSelectorBuilderCell,
@@ -453,6 +465,8 @@ export class ColumnBuilder<TData extends RowData> {
453
465
  size: 50,
454
466
  minSize: 50,
455
467
  header: (column) => label ?? column.header.id,
468
+ // TODO(Zan): Implement sort algorithm
469
+ enableSorting: true,
456
470
  cell: SwitchBuilderCell,
457
471
  ...props,
458
472
  meta: { ...props.meta, onUpdate, cell: { ...props.meta?.cell, classNames: [textPadding, classNames] } },
@@ -467,6 +481,8 @@ export class ColumnBuilder<TData extends RowData> {
467
481
  const IconOff = off?.Icon ?? X;
468
482
  return {
469
483
  size: size ?? 32,
484
+ // TODO(Zan): Implement sort algorithm
485
+ enableSorting: false,
470
486
  header: (column) => <div className={'justify-center'}>{label ?? column.header.id}</div>,
471
487
  cell: (cell) => {
472
488
  const value = cell.getValue();
package/src/theme.ts CHANGED
@@ -23,7 +23,8 @@ export const flushPadding = 'pli-0 plb-0';
23
23
  export const textPadding = 'pli-2 plb-0.5';
24
24
  export const headPadding = 'pli-2 plb-1.5';
25
25
 
26
- export const gridCellFocusRing = 'focus:z-[11] focus:outline outline-2 outline-primary-500 dark:outline-primary-400';
26
+ export const gridCellFocusRing =
27
+ 'focus-within:z-[11] focus-within:outline outline-2 outline-primary-500 dark:outline-primary-400';
27
28
 
28
29
  //
29
30
  // table
@@ -89,6 +90,7 @@ export const tbodyTr: ComponentFunction<TbodyTrStyleProps> = ({ canBeCurrent },
89
90
 
90
91
  export const tdRoot: ComponentFunction<TbodyStyleProps> = ({ border, isGrid }, ...etc) =>
91
92
  mx('relative', flushPadding, border && 'border', border && groupBorder, isGrid && gridCellFocusRing, ...etc);
93
+
92
94
  export const tdContent: ComponentFunction<CellContext<any, any>> = (_props, ...etc) => mx(...etc);
93
95
 
94
96
  //