@spaced-out/ui-design-system 0.4.12 → 0.4.13

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.
@@ -1,19 +1,25 @@
1
1
  // @flow strict
2
2
 
3
3
  import * as React from 'react';
4
+ // $FlowFixMe[untyped-import]
5
+ import {useVirtualizer} from '@tanstack/react-virtual';
4
6
  import get from 'lodash/get';
5
7
  import xor from 'lodash/xor';
6
8
 
7
9
  import {useWindowSize} from '../../hooks/useWindowSize';
8
- import {sizeFluid} from '../../styles/variables/_size';
10
+ import {size48, sizeFluid} from '../../styles/variables/_size';
11
+ import {spaceNone} from '../../styles/variables/_space';
9
12
  import {classify} from '../../utils/classify';
10
13
  import type {ClassNameComponent} from '../../utils/makeClassNameComponent';
11
14
  import {makeClassNameComponent} from '../../utils/makeClassNameComponent';
15
+ import {CircularLoader} from '../CircularLoader';
12
16
 
17
+ import type {TableRow} from './DefaultRow';
13
18
  import {DefaultRow, EmptyRow} from './DefaultRow';
19
+ import type {GenericHeaderItems} from './DefaultTableHeader';
14
20
  import {DefaultTableHeader} from './DefaultTableHeader';
15
21
  import type {SortDirection} from './hooks';
16
- import type {GenericObject, TableProps} from './Table';
22
+ import type {ClassNames, GenericObject, TableProps} from './Table';
17
23
 
18
24
  import css from './Table.module.css';
19
25
 
@@ -26,6 +32,189 @@ export const BasicTable: ClassNameComponent<'table'> = makeClassNameComponent(
26
32
  export const BasicTableBody: ClassNameComponent<'tbody'> =
27
33
  makeClassNameComponent(css.defaultTableBody, 'tbody');
28
34
 
35
+ function useTableWidth(ref): number | void {
36
+ const {width} = useWindowSize();
37
+ const [tableWidth, setTableWidth] = React.useState();
38
+
39
+ React.useEffect(() => {
40
+ if (ref.current) {
41
+ setTableWidth(ref.current.offsetWidth);
42
+ }
43
+ }, [width]);
44
+
45
+ return tableWidth;
46
+ }
47
+
48
+ function useMappedKeys<Data: GenericObject>(
49
+ entries: Data[],
50
+ rowKeys: ?(string[]),
51
+ idName: string,
52
+ ): string[] {
53
+ return React.useMemo(
54
+ () => rowKeys ?? entries.map((e) => get(e, idName)),
55
+ [entries, idName, rowKeys],
56
+ );
57
+ }
58
+
59
+ function useHeaderCheckboxHandler<Data: GenericObject>(
60
+ selectedKeys: ?(string[]),
61
+ entries: Data[],
62
+ idName: string,
63
+ onSelect?: (string[]) => mixed,
64
+ ) {
65
+ if (!selectedKeys) {
66
+ return undefined;
67
+ }
68
+
69
+ return ({checked}: {value: string, checked: boolean}) => {
70
+ const allIds = entries.map((row) => get(row, idName));
71
+ const newSelection = checked ? allIds : [];
72
+ onSelect?.(newSelection);
73
+ };
74
+ }
75
+
76
+ type TableWrapperProps<T, U> = {
77
+ tableRef?: {|current: null | HTMLDivElement|},
78
+ classNames?: ClassNames,
79
+ className?: string,
80
+ borderRadius?: string,
81
+ tableHeaderClassName?: string,
82
+ children?: React.Node,
83
+ wrapperStyle?: {[string]: ?string | number},
84
+ isLoading?: boolean,
85
+ emptyText?: React.Node,
86
+ showHeader?: boolean,
87
+ stickyHeader?: boolean,
88
+ sortable?: boolean,
89
+ disabled?: boolean,
90
+ entriesLength?: number,
91
+ selectedKeys?: ?(string[]),
92
+ headers: GenericHeaderItems<T, U>,
93
+ sortKey?: string,
94
+ sortDirection?: SortDirection,
95
+ handleSortClick?: (sortKey: $Keys<T>) => mixed,
96
+ handleHeaderCheckboxClick?: ({value: string, checked: boolean}) => mixed,
97
+ };
98
+
99
+ type RowType<T, U> = {
100
+ TableRow?: TableRow<T, U>,
101
+ data: T,
102
+ headers: GenericHeaderItems<T, U>,
103
+ extras?: U,
104
+ sortedKeys?: string[],
105
+ selected?: boolean,
106
+ disabled?: boolean,
107
+ keyId: string,
108
+ classNames?: ClassNames,
109
+ onSelect?: ({value: string, checked: boolean}) => mixed,
110
+ };
111
+
112
+ function RowRenderer<Data: GenericObject, Extras: GenericObject>({
113
+ TableRow,
114
+ data,
115
+ headers,
116
+ extras,
117
+ sortedKeys,
118
+ selected,
119
+ disabled,
120
+ classNames,
121
+ keyId,
122
+ onSelect,
123
+ }: RowType<Data, Extras>): React.Node {
124
+ return TableRow ? (
125
+ <TableRow
126
+ key={keyId}
127
+ data={data}
128
+ headers={headers}
129
+ // extras and rowKeys are both 'optional'
130
+ extras={extras}
131
+ sortedKeys={sortedKeys}
132
+ selected={selected}
133
+ disabled={disabled}
134
+ />
135
+ ) : (
136
+ <DefaultRow
137
+ key={keyId}
138
+ data={data}
139
+ extras={extras}
140
+ headers={headers}
141
+ selected={selected}
142
+ onSelect={onSelect}
143
+ disabled={disabled}
144
+ classNames={{
145
+ tableRow: classNames?.tableRow,
146
+ checkbox: classNames?.checkbox,
147
+ }}
148
+ />
149
+ );
150
+ }
151
+
152
+ function TableWrapper<Data: GenericObject, Extras: GenericObject>({
153
+ tableRef,
154
+ classNames,
155
+ className,
156
+ tableHeaderClassName,
157
+ children,
158
+ wrapperStyle,
159
+ isLoading = false,
160
+ emptyText,
161
+ showHeader = true,
162
+ stickyHeader = false,
163
+ sortable,
164
+ disabled = false,
165
+ entriesLength = 0,
166
+ selectedKeys,
167
+ headers,
168
+ sortKey,
169
+ sortDirection,
170
+ handleSortClick,
171
+ handleHeaderCheckboxClick,
172
+ }: TableWrapperProps<Data, Extras>): React.Node {
173
+ return (
174
+ <div
175
+ className={classify(css.tableContainer, classNames?.wrapper)}
176
+ data-id={'table-wrap'}
177
+ ref={tableRef}
178
+ style={wrapperStyle}
179
+ >
180
+ <BasicTable
181
+ data-id="basic-table"
182
+ className={classify(
183
+ className,
184
+ {
185
+ [css.fullHeightTable]: isLoading || (!entriesLength && !!emptyText),
186
+ },
187
+ classNames?.table,
188
+ )}
189
+ >
190
+ {showHeader && (
191
+ <DefaultTableHeader
192
+ className={classify(tableHeaderClassName, classNames?.tableHeader)}
193
+ sortable={sortable}
194
+ columns={headers}
195
+ handleSortClick={handleSortClick}
196
+ sortKey={sortKey}
197
+ sortDirection={sortDirection}
198
+ disabled={disabled}
199
+ handleCheckboxClick={handleHeaderCheckboxClick}
200
+ stickyHeader={stickyHeader}
201
+ checked={
202
+ selectedKeys == null || selectedKeys.length === 0
203
+ ? 'false'
204
+ : selectedKeys.length < entriesLength
205
+ ? 'mixed'
206
+ : 'true'
207
+ }
208
+ />
209
+ )}
210
+ <BasicTableBody className={classNames?.tableBody}>
211
+ {children}
212
+ </BasicTableBody>
213
+ </BasicTable>
214
+ </div>
215
+ );
216
+ }
217
+
29
218
  /**
30
219
  * A Static Default Table.
31
220
  *
@@ -73,140 +262,315 @@ export function StaticTable<Data: GenericObject, Extras: GenericObject>(props: {
73
262
 
74
263
  // this is a fallback and honestly probably doesn't need the
75
264
  // memo'ing
76
- const mappedKeys = React.useMemo(
77
- () => rowKeys ?? entries.map((e) => get(e, idName)),
78
- [entries, idName, rowKeys],
265
+ const tableRef = React.useRef(null);
266
+ const mappedKeys = useMappedKeys(entries, rowKeys, idName);
267
+ const tableWidth = useTableWidth(tableRef);
268
+
269
+ /**
270
+ * this function is also used to decide weather to show checkbox in header or not. so it's value is undefined in case selectedKeys is not there.
271
+ */
272
+ const handleHeaderCheckboxClick = useHeaderCheckboxHandler(
273
+ selectedKeys,
274
+ entries,
275
+ idName,
276
+ onSelect,
79
277
  );
80
278
 
279
+ return (
280
+ <TableWrapper
281
+ tableRef={tableRef}
282
+ wrapperStyle={{
283
+ '--border-radius': borderRadius,
284
+ '--table-width': tableWidth ? `${tableWidth}px` : sizeFluid,
285
+ }}
286
+ classNames={classNames}
287
+ className={className}
288
+ tableHeaderClassName={tableHeaderClassName}
289
+ borderRadius={borderRadius}
290
+ isLoading={isLoading}
291
+ emptyText={emptyText}
292
+ showHeader={showHeader}
293
+ stickyHeader={stickyHeader}
294
+ sortable={sortable}
295
+ disabled={false}
296
+ entriesLength={entries.length}
297
+ selectedKeys={selectedKeys}
298
+ headers={headers}
299
+ sortKey={sortKey}
300
+ sortDirection={sortDirection}
301
+ handleSortClick={handleSortClick}
302
+ handleHeaderCheckboxClick={handleHeaderCheckboxClick}
303
+ >
304
+ {isLoading || !entries.length ? (
305
+ <EmptyRow
306
+ isLoading={isLoading}
307
+ emptyText={emptyText}
308
+ headersLength={
309
+ handleHeaderCheckboxClick ? headers.length + 1 : headers.length
310
+ }
311
+ customLoader={customLoader}
312
+ />
313
+ ) : (
314
+ mappedKeys.map((key) => {
315
+ const data = entries.find((e) => get(e, idName) === key);
316
+ if (data == null) {
317
+ return null;
318
+ }
319
+ const selected =
320
+ selectedKeys && Array.isArray(selectedKeys)
321
+ ? selectedKeys.includes(get(data, idName))
322
+ : undefined;
323
+ const isRowDisabled =
324
+ disabledKeys && Array.isArray(disabledKeys)
325
+ ? disabledKeys.includes(get(data, idName))
326
+ : false;
327
+ return (
328
+ <RowRenderer
329
+ TableRow={TableRow}
330
+ keyId={key}
331
+ data={data}
332
+ headers={headers}
333
+ extras={extras}
334
+ sortedKeys={rowKeys ?? mappedKeys}
335
+ selected={selected}
336
+ disabled={disabled || isRowDisabled}
337
+ classNames={classNames}
338
+ onSelect={
339
+ selectedKeys != null
340
+ ? (_v) => onSelect?.(xor(selectedKeys ?? [], [key]))
341
+ : undefined
342
+ }
343
+ />
344
+ );
345
+ })
346
+ )}
347
+ </TableWrapper>
348
+ );
349
+ }
350
+
351
+ export function StaticTableVirtualized<
352
+ Data: GenericObject,
353
+ Extras: GenericObject,
354
+ >(props: {
355
+ ...TableProps<Data, Extras>,
356
+ handleSortClick?: (sortKey: string) => mixed,
357
+ sortKey?: string,
358
+ sortDirection?: SortDirection,
359
+ rowKeys?: string[],
360
+ }): React.Node {
361
+ const {
362
+ classNames,
363
+ className,
364
+ TableRow,
365
+ entries,
366
+ extras,
367
+ rowKeys,
368
+ headers,
369
+ showHeader = true,
370
+ tableHeaderClassName,
371
+ sortable,
372
+ // eslint-disable-next-line unused-imports/no-unused-vars
373
+ defaultSortKey,
374
+ // eslint-disable-next-line unused-imports/no-unused-vars
375
+ defaultSortDirection = 'original',
376
+ // eslint-disable-next-line unused-imports/no-unused-vars
377
+ onSort,
378
+ handleSortClick,
379
+ sortKey,
380
+ sortDirection,
381
+ selectedKeys,
382
+ disabledKeys = [],
383
+ onSelect,
384
+ isLoading,
385
+ idName = 'id',
386
+ emptyText,
387
+ disabled,
388
+ customLoader,
389
+ borderRadius,
390
+ stickyHeader,
391
+ virtualizationOptions,
392
+ } = props;
393
+
394
+ const {
395
+ rowsCount,
396
+ rowHeight = size48,
397
+ onEndReached,
398
+ isEndLoading = false,
399
+ isAllDataFetched = false,
400
+ } = virtualizationOptions ?? {};
401
+
81
402
  const tableRef = React.useRef(null);
82
- const {width} = useWindowSize();
83
- const [tableWidth, setTableWidth] = React.useState();
403
+
404
+ // this is a fallback and honestly probably doesn't need the
405
+ // memo'ing
406
+ const mappedKeys = useMappedKeys(entries, rowKeys, idName);
407
+ const tableWidth = useTableWidth(tableRef);
408
+
409
+ const virtualizer = useVirtualizer({
410
+ count: entries.length,
411
+ getScrollElement: () => tableRef.current,
412
+ estimateSize: () => parseInt(rowHeight),
413
+ getItemKey: (index) => entries[index][idName],
414
+ overscan: 1,
415
+ });
416
+
417
+ const currRows = virtualizer.getVirtualItems();
418
+
419
+ const hasTriggeredRef = React.useRef(false);
84
420
 
85
421
  React.useEffect(() => {
86
- if (tableRef.current) {
87
- setTableWidth(tableRef.current.offsetWidth);
422
+ if (!tableRef.current || !onEndReached || isAllDataFetched) {
423
+ return;
88
424
  }
89
- }, [width]);
425
+
426
+ const scrollElement = tableRef.current;
427
+
428
+ const handleScroll = () => {
429
+ const {scrollTop, scrollHeight, clientHeight} = scrollElement;
430
+
431
+ const isAtEnd = scrollTop + clientHeight >= scrollHeight - 100; // buffer
432
+
433
+ if (isAtEnd) {
434
+ if (!hasTriggeredRef.current) {
435
+ hasTriggeredRef.current = true;
436
+ onEndReached();
437
+ }
438
+ } else {
439
+ hasTriggeredRef.current = false; // reset when scrolling up
440
+ }
441
+ };
442
+
443
+ scrollElement.addEventListener('scroll', handleScroll);
444
+
445
+ return () => {
446
+ scrollElement.removeEventListener('scroll', handleScroll);
447
+ };
448
+ }, [onEndReached, isAllDataFetched]);
90
449
 
91
450
  /**
92
451
  * this function is also used to decide weather to show checkbox in header or not. so it's value is undefined in case selectedKeys is not there.
93
452
  */
94
453
 
95
- const handleHeaderCheckboxClick = selectedKeys
96
- ? ({checked}: {value: string, checked: boolean}) => {
97
- let selectedRowIds = [];
98
- if (selectedKeys) {
99
- if (checked === true) {
100
- selectedRowIds = entries.map((singleRowObj) =>
101
- get(singleRowObj, idName),
102
- );
103
- }
104
- onSelect?.(selectedRowIds);
105
- }
106
- }
107
- : undefined;
108
- return (
109
- <div
110
- className={classify(css.tableContainer, classNames?.wrapper)}
111
- data-id="table-wrap"
112
- ref={tableRef}
454
+ const handleHeaderCheckboxClick = useHeaderCheckboxHandler(
455
+ selectedKeys,
456
+ entries,
457
+ idName,
458
+ onSelect,
459
+ );
460
+
461
+ const VirtualizedStartRow = () => (
462
+ <tr
113
463
  style={{
464
+ height: virtualizer.getVirtualItems()[0]?.start ?? spaceNone,
465
+ width: sizeFluid,
466
+ }}
467
+ >
468
+ <td
469
+ colSpan={headers.length + (handleHeaderCheckboxClick ? 1 : 0)}
470
+ style={{padding: spaceNone, border: 'none', height: '100%'}}
471
+ />
472
+ </tr>
473
+ );
474
+
475
+ const VirtualizedEndRow = () => (
476
+ <tr
477
+ style={{
478
+ height:
479
+ virtualizer.getTotalSize() -
480
+ (virtualizer.getVirtualItems().at(-1)?.end ?? spaceNone),
481
+ }}
482
+ aria-hidden={true}
483
+ >
484
+ <td
485
+ colSpan={headers.length + (handleHeaderCheckboxClick ? 1 : 0)}
486
+ style={{padding: spaceNone, border: 'none', height: '100%'}}
487
+ />
488
+ </tr>
489
+ );
490
+
491
+ return (
492
+ <TableWrapper
493
+ tableRef={tableRef}
494
+ wrapperStyle={{
114
495
  '--border-radius': borderRadius,
115
496
  '--table-width': tableWidth ? `${tableWidth}px` : sizeFluid,
497
+ height: (rowsCount + 1) * parseInt(rowHeight),
498
+ overflowY: 'auto',
116
499
  }}
500
+ classNames={classNames}
501
+ className={className}
502
+ tableHeaderClassName={tableHeaderClassName}
503
+ borderRadius={borderRadius}
504
+ isLoading={isLoading}
505
+ emptyText={emptyText}
506
+ showHeader={showHeader}
507
+ stickyHeader={stickyHeader}
508
+ sortable={sortable}
509
+ disabled={false}
510
+ entriesLength={entries.length}
511
+ selectedKeys={selectedKeys}
512
+ headers={headers}
513
+ sortKey={sortKey}
514
+ sortDirection={sortDirection}
515
+ handleSortClick={handleSortClick}
516
+ handleHeaderCheckboxClick={handleHeaderCheckboxClick}
117
517
  >
118
- <BasicTable
119
- data-id="basic-table"
120
- className={classify(
121
- className,
122
- {
123
- [css.fullHeightTable]:
124
- isLoading || (!entries.length && !!emptyText),
125
- },
126
- classNames?.table,
127
- )}
128
- >
129
- {showHeader && (
130
- <DefaultTableHeader
131
- className={classify(tableHeaderClassName, classNames?.tableHeader)}
132
- sortable={sortable}
133
- columns={headers}
134
- handleSortClick={handleSortClick}
135
- sortKey={sortKey}
136
- sortDirection={sortDirection}
137
- disabled={disabled}
138
- handleCheckboxClick={handleHeaderCheckboxClick}
139
- stickyHeader={stickyHeader}
140
- checked={
141
- selectedKeys == null || selectedKeys.length === 0
142
- ? 'false'
143
- : selectedKeys.length < entries.length
144
- ? 'mixed'
145
- : 'true'
146
- }
147
- />
148
- )}
518
+ <VirtualizedStartRow />
519
+ {isLoading || !entries.length ? (
520
+ <EmptyRow
521
+ isLoading={isLoading}
522
+ emptyText={emptyText}
523
+ headersLength={
524
+ handleHeaderCheckboxClick ? headers.length + 1 : headers.length
525
+ }
526
+ customLoader={customLoader}
527
+ />
528
+ ) : (
529
+ currRows.map((virtualRow) => {
530
+ const key = virtualRow.key;
531
+ const data = entries[virtualRow.index];
532
+ if (data == null) {
533
+ return null;
534
+ }
535
+ const selected =
536
+ selectedKeys && Array.isArray(selectedKeys)
537
+ ? selectedKeys.includes(get(data, idName))
538
+ : undefined;
539
+ const isRowDisabled =
540
+ disabledKeys && Array.isArray(disabledKeys)
541
+ ? disabledKeys.includes(get(data, idName))
542
+ : false;
149
543
 
150
- <BasicTableBody className={classNames?.tableBody}>
151
- {isLoading || !entries.length ? (
152
- <EmptyRow
153
- isLoading={isLoading}
154
- emptyText={emptyText}
155
- headersLength={
156
- handleHeaderCheckboxClick ? headers.length + 1 : headers.length
544
+ return (
545
+ <RowRenderer
546
+ TableRow={TableRow}
547
+ keyId={key}
548
+ data={data}
549
+ headers={headers}
550
+ extras={extras}
551
+ sortedKeys={rowKeys ?? mappedKeys}
552
+ selected={selected}
553
+ disabled={disabled || isRowDisabled}
554
+ classNames={classNames}
555
+ onSelect={
556
+ selectedKeys != null
557
+ ? (_v) => onSelect?.(xor(selectedKeys ?? [], [key]))
558
+ : undefined
157
559
  }
158
- customLoader={customLoader}
159
560
  />
160
- ) : (
161
- mappedKeys.map((key) => {
162
- const data = entries.find((e) => get(e, idName) === key);
163
- if (data == null) {
164
- return null;
165
- }
166
- (data: Data);
167
- const selected =
168
- selectedKeys && Array.isArray(selectedKeys)
169
- ? selectedKeys.includes(get(data, idName))
170
- : undefined;
171
- const isRowDisabled =
172
- disabledKeys && Array.isArray(disabledKeys)
173
- ? disabledKeys.includes(get(data, idName))
174
- : false;
175
-
176
- return TableRow ? (
177
- <TableRow
178
- key={key}
179
- data={data}
180
- headers={headers}
181
- // extras and rowKeys are both 'optional'
182
- extras={extras}
183
- sortedKeys={rowKeys ?? mappedKeys}
184
- selected={selected}
185
- disabled={disabled || isRowDisabled}
186
- />
187
- ) : (
188
- <DefaultRow
189
- key={key}
190
- data={data}
191
- extras={extras}
192
- headers={headers}
193
- selected={selected}
194
- onSelect={
195
- selectedKeys != null
196
- ? (_v) => onSelect?.(xor(selectedKeys ?? [], [key]))
197
- : undefined
198
- }
199
- disabled={disabled || isRowDisabled}
200
- classNames={{
201
- tableRow: classNames?.tableRow,
202
- checkbox: classNames?.checkbox,
203
- }}
204
- />
205
- );
206
- })
207
- )}
208
- </BasicTableBody>
209
- </BasicTable>
210
- </div>
561
+ );
562
+ })
563
+ )}
564
+ <VirtualizedEndRow />
565
+ {isEndLoading && (
566
+ <tr>
567
+ <td colSpan={headers.length + (handleHeaderCheckboxClick ? 1 : 0)}>
568
+ <div className={css.fetchMoreLoaderContainer}>
569
+ <CircularLoader />
570
+ </div>
571
+ </td>
572
+ </tr>
573
+ )}
574
+ </TableWrapper>
211
575
  );
212
576
  }