aplance-ui 1.2.0 → 1.2.2

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.
@@ -12,12 +12,12 @@ import {
12
12
  type Updater,
13
13
  } from "@tanstack/react-table";
14
14
  import {
15
- useCallback,
16
- useEffect,
17
- useLayoutEffect,
18
- useMemo,
19
- useRef,
20
- useState,
15
+ useCallback,
16
+ useEffect,
17
+ useLayoutEffect,
18
+ useMemo,
19
+ useRef,
20
+ useState,
21
21
  type CSSProperties,
22
22
  type DragEvent as ReactDragEvent,
23
23
  type ReactNode,
@@ -74,13 +74,15 @@ const EMPTY_FILTER_VALUES: Record<string, string> = {};
74
74
  const ICON_SIZE = 15;
75
75
  const SORT_ICON_SIZE = 13;
76
76
  const ICON_STROKE = 1.6;
77
- const EMPTY_COLUMN_EXTRA_WIDTHS: Record<string, number> = {};
78
- // Grace period after `isLoading` flips false before committing to the empty
79
- // state. Eliminates the flash when consumers set isLoading=false a render
80
- // before populating rows, without making genuinely-empty results feel sluggish.
81
- const EMPTY_STATE_GRACE_MS = 150;
82
- const useIsomorphicLayoutEffect =
83
- typeof window === "undefined" ? useEffect : useLayoutEffect;
77
+ const EMPTY_COLUMN_EXTRA_WIDTHS: Record<string, number> = {};
78
+ // Grace period after `isLoading` flips false before committing to the empty
79
+ // state. Eliminates the flash when consumers set isLoading=false a render
80
+ // before populating rows, without making genuinely-empty results feel sluggish.
81
+ const EMPTY_STATE_GRACE_MS = 150;
82
+
83
+ const MIN_SKELETON_ROWS = 1;
84
+ const useIsomorphicLayoutEffect =
85
+ typeof window === "undefined" ? useEffect : useLayoutEffect;
84
86
 
85
87
  export const DataTable = <TData extends { id: string }>({
86
88
  data,
@@ -106,6 +108,7 @@ export const DataTable = <TData extends { id: string }>({
106
108
  rowLabel = "row",
107
109
 
108
110
  rowsSelectable = false,
111
+ getRowCanSelect,
109
112
  rowSelection = {},
110
113
  onRowSelectionChange,
111
114
 
@@ -220,7 +223,7 @@ export const DataTable = <TData extends { id: string }>({
220
223
  const table = useReactTable<TData>({
221
224
  data,
222
225
  columns,
223
- enableRowSelection: rowsSelectable,
226
+ enableRowSelection: rowsSelectable ? (getRowCanSelect ?? true) : false,
224
227
  enableColumnResizing,
225
228
  enableColumnPinning,
226
229
  enableGrouping,
@@ -342,9 +345,11 @@ export const DataTable = <TData extends { id: string }>({
342
345
  // ── Sync header/body scrolling ──────────────────────────────────────────────
343
346
  // The header lives in its own `overflow:hidden` container; we drive its
344
347
  // scrollLeft from the body's scrollLeft on every scroll event so pinned
345
- // columns stay perfectly aligned with their body counterparts.
346
- const headerRef = useRef<HTMLDivElement>(null);
347
- const bodyRef = useRef<HTMLDivElement>(null);
348
+ // columns stay perfectly aligned with their body counterparts.
349
+ const headerRef = useRef<HTMLDivElement>(null);
350
+ const bodyRef = useRef<HTMLDivElement>(null);
351
+ const bodyTableRef = useRef<HTMLTableElement>(null);
352
+ const skeletonRowRef = useRef<HTMLTableRowElement>(null);
348
353
  const rows = table.getRowModel().rows;
349
354
  const headerCount = table.getFlatHeaders().length;
350
355
  const totalWidth = table.getTotalSize();
@@ -352,19 +357,38 @@ export const DataTable = <TData extends { id: string }>({
352
357
  const tableColumnPinning = tableState.columnPinning;
353
358
  const tableColumnSizing = tableState.columnSizing;
354
359
  const tableColumnOrder = tableState.columnOrder;
355
- const tableColumnVisibility = tableState.columnVisibility;
356
- const tableGrouping = tableState.grouping;
357
- const [bodyViewportWidth, setBodyViewportWidth] = useState(0);
358
- const bodyViewportWidthRef = useRef(0);
359
-
360
- const commitBodyViewportWidth = useCallback((nextWidth: number) => {
361
- if (bodyViewportWidthRef.current === nextWidth) return;
362
- bodyViewportWidthRef.current = nextWidth;
363
- setBodyViewportWidth(nextWidth);
364
- }, []);
365
-
366
- const syncHeaderMetrics = useCallback(() => {
367
- const body = bodyRef.current;
360
+ const tableColumnVisibility = tableState.columnVisibility;
361
+ const tableGrouping = tableState.grouping;
362
+ const [bodyViewportWidth, setBodyViewportWidth] = useState(0);
363
+ const bodyViewportWidthRef = useRef(0);
364
+ // Body wrap height tracked separately so the loading skeleton can render
365
+ // exactly the count of rows that fits the visible body. Avoids the
366
+ // "skeleton overflows + body wrap scrolls" feel during cold load.
367
+ const [bodyViewportHeight, setBodyViewportHeight] = useState(0);
368
+ const bodyViewportHeightRef = useRef(0);
369
+ const [skeletonRowHeight, setSkeletonRowHeight] = useState(0);
370
+ const skeletonRowHeightRef = useRef(0);
371
+
372
+ const commitBodyViewportWidth = useCallback((nextWidth: number) => {
373
+ if (bodyViewportWidthRef.current === nextWidth) return;
374
+ bodyViewportWidthRef.current = nextWidth;
375
+ setBodyViewportWidth(nextWidth);
376
+ }, []);
377
+
378
+ const commitBodyViewportHeight = useCallback((nextHeight: number) => {
379
+ if (bodyViewportHeightRef.current === nextHeight) return;
380
+ bodyViewportHeightRef.current = nextHeight;
381
+ setBodyViewportHeight(nextHeight);
382
+ }, []);
383
+
384
+ const commitSkeletonRowHeight = useCallback((nextHeight: number) => {
385
+ if (nextHeight <= 0 || skeletonRowHeightRef.current === nextHeight) return;
386
+ skeletonRowHeightRef.current = nextHeight;
387
+ setSkeletonRowHeight(nextHeight);
388
+ }, []);
389
+
390
+ const syncHeaderMetrics = useCallback(() => {
391
+ const body = bodyRef.current;
368
392
  const header = headerRef.current;
369
393
  if (!body || !header) return;
370
394
 
@@ -379,38 +403,40 @@ export const DataTable = <TData extends { id: string }>({
379
403
  );
380
404
  header.parentElement?.setAttribute("data-has-scroll", hasScroll.toString());
381
405
 
382
- // Re-sync horizontal scroll position after width changes so pinned
383
- // columns stay perfectly aligned with their body counterparts.
384
- header.scrollLeft = body.scrollLeft;
385
- commitBodyViewportWidth(body.clientWidth);
386
- }, [commitBodyViewportWidth]);
387
-
388
- useIsomorphicLayoutEffect(() => {
389
- syncHeaderMetrics();
390
- }, [
391
- rows.length,
406
+ // Re-sync horizontal scroll position after width changes so pinned
407
+ // columns stay perfectly aligned with their body counterparts.
408
+ header.scrollLeft = body.scrollLeft;
409
+ commitBodyViewportWidth(body.clientWidth);
410
+ commitBodyViewportHeight(body.clientHeight);
411
+ }, [commitBodyViewportWidth, commitBodyViewportHeight]);
412
+
413
+ useIsomorphicLayoutEffect(() => {
414
+ syncHeaderMetrics();
415
+ }, [
416
+ rows.length,
392
417
  headerCount,
393
418
  totalWidth,
394
419
  tableColumnPinning,
395
420
  tableColumnSizing,
396
421
  tableColumnOrder,
397
422
  tableColumnVisibility,
398
- tableGrouping,
399
- syncHeaderMetrics,
400
- ]);
401
-
402
- useIsomorphicLayoutEffect(() => {
403
- const body = bodyRef.current;
404
- if (!body) return;
405
- if (typeof ResizeObserver === "undefined") return;
406
-
407
- const observer = new ResizeObserver(syncHeaderMetrics);
408
- observer.observe(body);
423
+ tableGrouping,
424
+ syncHeaderMetrics,
425
+ ]);
426
+
427
+ useIsomorphicLayoutEffect(() => {
428
+ const body = bodyRef.current;
429
+ if (!body) return;
430
+ if (typeof ResizeObserver === "undefined") return;
431
+
432
+ const observer = new ResizeObserver(syncHeaderMetrics);
433
+ observer.observe(body);
434
+ if (bodyTableRef.current) observer.observe(bodyTableRef.current);
409
435
 
410
436
  return () => {
411
437
  observer.disconnect();
412
438
  };
413
- }, [syncHeaderMetrics]);
439
+ }, [syncHeaderMetrics, rows.length, isLoading]);
414
440
 
415
441
  useEffect(() => {
416
442
  const body = bodyRef.current;
@@ -439,7 +465,7 @@ export const DataTable = <TData extends { id: string }>({
439
465
  }, []);
440
466
 
441
467
  // ── Derived UI state ────────────────────────────────────────────────────────
442
- // Defer the empty state by a tick after a loading→idle transition so the
468
+ // Defer the empty state by a tick after a loading-to-idle transition so the
443
469
  // loader doesn't flicker into "no results" for one paint before rows land.
444
470
  // Genuinely empty results (no in-flight load) commit immediately on mount.
445
471
  const isRowsEmpty = rows.length === 0;
@@ -467,7 +493,7 @@ export const DataTable = <TData extends { id: string }>({
467
493
  }, [isLoading, isRowsEmpty]);
468
494
 
469
495
  // Single tab-like discriminator drives the body - loader, empty, or content.
470
- // While loading OR while bridging the loading→empty grace window, the
496
+ // While loading OR while bridging the loading-to-empty grace window, the
471
497
  // loader stays visible to avoid the brief "no results" flash.
472
498
  const view: "loader" | "empty" | "content" =
473
499
  isLoading || (isRowsEmpty && !showEmptyState)
@@ -476,15 +502,43 @@ export const DataTable = <TData extends { id: string }>({
476
502
  ? "empty"
477
503
  : "content";
478
504
 
505
+ useIsomorphicLayoutEffect(() => {
506
+ if (view !== "loader") return;
507
+ const row = skeletonRowRef.current;
508
+ if (!row) return;
509
+
510
+ const measureRow = () => {
511
+ commitSkeletonRowHeight(Math.ceil(row.getBoundingClientRect().height));
512
+ };
513
+
514
+ measureRow();
515
+ if (typeof ResizeObserver === "undefined") return;
516
+
517
+ const observer = new ResizeObserver(measureRow);
518
+ observer.observe(row);
519
+
520
+ return () => {
521
+ observer.disconnect();
522
+ };
523
+ }, [commitSkeletonRowHeight, view]);
524
+
479
525
  const selectedCount = rowsSelectable
480
526
  ? table.getSelectedRowModel().flatRows.length
481
527
  : 0;
482
- const headerGroups = table.getHeaderGroups();
483
- const visibleLeafColumns = table.getVisibleLeafColumns();
484
-
485
- const columnExtraWidths = useMemo<Record<string, number>>(() => {
486
- const extraSpace = Math.max(0, bodyViewportWidth - totalWidth);
487
- if (extraSpace <= 0) return EMPTY_COLUMN_EXTRA_WIDTHS;
528
+ const headerGroups = table.getHeaderGroups();
529
+ const visibleLeafColumns = table.getVisibleLeafColumns();
530
+
531
+ const skeletonRowCount =
532
+ bodyViewportHeight > 0 && skeletonRowHeight > 0
533
+ ? Math.max(
534
+ MIN_SKELETON_ROWS,
535
+ Math.floor(bodyViewportHeight / skeletonRowHeight),
536
+ )
537
+ : MIN_SKELETON_ROWS;
538
+
539
+ const columnExtraWidths = useMemo<Record<string, number>>(() => {
540
+ const extraSpace = Math.max(0, bodyViewportWidth - totalWidth);
541
+ if (extraSpace <= 0) return EMPTY_COLUMN_EXTRA_WIDTHS;
488
542
 
489
543
  // Never add responsive width onto pinned columns because tanstack computes
490
544
  // sticky offsets from base sizes; changing pinned visual width would desync
@@ -507,17 +561,27 @@ export const DataTable = <TData extends { id: string }>({
507
561
  remainder -= 1;
508
562
  }
509
563
  }
510
-
511
- return next;
512
- }, [bodyViewportWidth, totalWidth, visibleLeafColumns]);
513
-
514
- // The table always fills its container while preserving configured widths as
515
- // a minimum. Any extra viewport space is distributed across visible center
516
- // columns, keeping pinned offsets stable and avoiding an empty right gutter.
517
- const tableSizingStyle: CSSProperties = useMemo(
518
- () => ({ width: "100%", minWidth: `${totalWidth}px` }),
519
- [totalWidth],
520
- );
564
+
565
+ return next;
566
+ }, [bodyViewportWidth, totalWidth, visibleLeafColumns]);
567
+
568
+ const tableWidth = Math.max(totalWidth, bodyViewportWidth);
569
+
570
+ // Match the table element width to the measured column widths. This keeps
571
+ // header, body, and skeleton rows on the same sizing contract during resize.
572
+ const tableSizingStyle: CSSProperties = useMemo(
573
+ () => ({ width: `${tableWidth}px`, minWidth: `${totalWidth}px` }),
574
+ [tableWidth, totalWidth],
575
+ );
576
+
577
+ const bodySkeletonStyle: CSSProperties = {
578
+ height: "var(--spacing-9)",
579
+ };
580
+
581
+ const selectSkeletonStyle: CSSProperties = {
582
+ width: "var(--data-table-selection-control-size, var(--spacing-9))",
583
+ height: "var(--data-table-selection-control-size, var(--spacing-9))",
584
+ };
521
585
 
522
586
  const tableClass = clsx(
523
587
  s.table,
@@ -580,60 +644,77 @@ export const DataTable = <TData extends { id: string }>({
580
644
  {/* Scrollable body - only this element scrolls. The body is a
581
645
  single switch over `view`: at most one branch ever renders, the
582
646
  other two are unmounted entirely. */}
583
- <div
584
- className={s.table_body_wrap}
585
- style={{ pointerEvents: isLoading ? "none" : undefined }}
586
- ref={bodyRef}
587
- >
588
- {view === "loader" && (
589
- <table className={tableClass} style={tableSizingStyle}>
590
- <tbody>
591
- {Array.from({ length: pagination?.pageSize || 10 }).map(
592
- (_, i) => (
593
- <tr key={`skeleton-${i}`}>
594
- {visibleLeafColumns.map((col) => {
595
- const isPinned = col.getIsPinned();
596
- const isLastPinnedCol = isPinned && isLastPinned(col);
597
- const isSelectColumn = col.id === SELECT_COLUMN_ID;
598
- const baseStyle = getColumnStyle(
599
- col,
600
- col.getSize(),
601
- columnExtraWidths[col.id] ?? 0,
602
- );
603
-
604
- return (
605
- <td
606
- key={col.id}
607
- className={clsx(
608
- isSelectColumn && s.col_select,
609
- isPinned === "left" && s.col_pinned_left,
610
- isPinned === "right" && s.col_pinned_right,
611
- isLastPinnedCol && s.col_pinned_last,
612
- )}
613
- style={baseStyle}
647
+ <div
648
+ className={s.table_body_wrap}
649
+ style={{ pointerEvents: isLoading ? "none" : undefined }}
650
+ ref={bodyRef}
651
+ >
652
+ {view === "loader" && (
653
+ <table className={tableClass} style={tableSizingStyle}>
654
+ <tbody>
655
+ {Array.from({ length: skeletonRowCount }).map(
656
+ (_, rowIndex) => (
657
+ <tr
658
+ key={`skeleton-${rowIndex}`}
659
+ ref={rowIndex === 0 ? skeletonRowRef : undefined}
660
+ >
661
+ {visibleLeafColumns.map((column) => {
662
+ const isPinned = column.getIsPinned();
663
+ const isLastPinnedCol =
664
+ isPinned && isLastPinned(column);
665
+ const isSelectColumn =
666
+ column.id === SELECT_COLUMN_ID;
667
+
668
+ return (
669
+ <td
670
+ key={column.id}
671
+ className={clsx(
672
+ isSelectColumn && s.col_select,
673
+ isPinned === "left" && s.col_pinned_left,
674
+ isPinned === "right" && s.col_pinned_right,
675
+ isLastPinnedCol && s.col_pinned_last,
676
+ )}
677
+ style={getColumnStyle(
678
+ column,
679
+ column.getSize(),
680
+ columnExtraWidths[column.id] ?? 0,
681
+ )}
614
682
  >
615
683
  <div className={s.td_inner}>
616
684
  <Loader
617
685
  variant="skeleton"
618
- style={{ height: "var(--spacing-9)" }}
686
+ className={
687
+ isSelectColumn
688
+ ? s.loading_select_skeleton
689
+ : undefined
690
+ }
691
+ style={
692
+ isSelectColumn
693
+ ? selectSkeletonStyle
694
+ : bodySkeletonStyle
695
+ }
619
696
  />
620
697
  </div>
621
698
  </td>
622
699
  );
623
- })}
624
- </tr>
625
- ),
626
- )}
627
- </tbody>
628
- </table>
629
- )}
700
+ })}
701
+ </tr>
702
+ ),
703
+ )}
704
+ </tbody>
705
+ </table>
706
+ )}
630
707
  {view === "empty" && (
631
708
  <div className={s.state_overlay}>
632
709
  <p className={s.empty_msg}>{emptyMessage}</p>
633
710
  </div>
634
711
  )}
635
712
  {view === "content" && (
636
- <table className={tableClass} style={tableSizingStyle}>
713
+ <table
714
+ className={tableClass}
715
+ style={tableSizingStyle}
716
+ ref={bodyTableRef}
717
+ >
637
718
  <tbody>
638
719
  {rows.map((row) => (
639
720
  <BodyRow<TData>
@@ -666,6 +747,7 @@ export const DataTable = <TData extends { id: string }>({
666
747
  onPaginationChange={onPaginationChange}
667
748
  pageSizeOptions={pageSizeOptions}
668
749
  showPageSizeSelect={showPageSizeSelect}
750
+ isLoading={view === "loader"}
669
751
  totalCount={totalCount}
670
752
  rowLabel={rowLabel}
671
753
  rowsSelectable={rowsSelectable}