@jinaan/uikit 0.1.39 → 0.1.41

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,4 +1,4 @@
1
- import type { ReactNode } from "react";
1
+ import type { ReactNode, Ref } from "react";
2
2
  export type TableSurfaceTone = "default" | "muted" | "transparent";
3
3
  export interface TableSurfaceAction {
4
4
  label: string;
@@ -29,6 +29,15 @@ interface TableSurfaceProps {
29
29
  panelClassName?: string;
30
30
  /** Minimum panel height; keeps empty states from collapsing. */
31
31
  minHeight?: string;
32
+ /**
33
+ * Where horizontal scrolling lives. `surface` (default) scrolls band and
34
+ * panel together — right for short content. `panel` keeps both shells fixed
35
+ * while the caller puts the scroller inside the panel; the band content is
36
+ * then expected to translate in lockstep via `headerContentRef`.
37
+ */
38
+ scrollMode?: "surface" | "panel";
39
+ /** Panel mode only: ref for the band's translating inner wrapper. */
40
+ headerContentRef?: Ref<HTMLDivElement>;
32
41
  }
33
42
  /**
34
43
  * Layered table surface: an outer shell holding a header band and an inset
@@ -37,6 +46,6 @@ interface TableSurfaceProps {
37
46
  * Colors come from the `--table-*` tokens and can be adjusted per surface with
38
47
  * `tone` plus `headerClassName` / `panelClassName`.
39
48
  */
40
- export declare function TableSurface({ title, description, header, children, action, tone, className, headerClassName, panelClassName, minHeight, }: TableSurfaceProps): import("react").JSX.Element;
49
+ export declare function TableSurface({ title, description, header, children, action, tone, className, headerClassName, panelClassName, minHeight, scrollMode, headerContentRef, }: TableSurfaceProps): import("react").JSX.Element;
41
50
  export {};
42
51
  //# sourceMappingURL=table-surface.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"table-surface.d.ts","sourceRoot":"","sources":["../src/table-surface.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAMvC,MAAM,MAAM,gBAAgB,GAAG,SAAS,GAAG,OAAO,GAAG,aAAa,CAAC;AAEnE,MAAM,WAAW,kBAAkB;IACjC,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,SAAS,CAAC;IACjB,OAAO,CAAC,EAAE,SAAS,GAAG,SAAS,GAAG,OAAO,GAAG,aAAa,CAAC;IAC1D,OAAO,CAAC,EAAE,MAAM,IAAI,CAAC;IACrB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAED,UAAU,iBAAiB;IACzB,kCAAkC;IAClC,KAAK,CAAC,EAAE,SAAS,CAAC;IAClB,uCAAuC;IACvC,WAAW,CAAC,EAAE,SAAS,CAAC;IACxB,yDAAyD;IACzD,MAAM,CAAC,EAAE,SAAS,CAAC;IACnB,0EAA0E;IAC1E,QAAQ,EAAE,SAAS,CAAC;IACpB,2DAA2D;IAC3D,MAAM,CAAC,EAAE,SAAS,GAAG,kBAAkB,CAAC;IACxC,8EAA8E;IAC9E,IAAI,CAAC,EAAE,gBAAgB,CAAC;IACxB,8DAA8D;IAC9D,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,yDAAyD;IACzD,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,2DAA2D;IAC3D,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,gEAAgE;IAChE,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAyBD;;;;;;GAMG;AACH,wBAAgB,YAAY,CAAC,EAC3B,KAAK,EACL,WAAW,EACX,MAAM,EACN,QAAQ,EACR,MAAM,EACN,IAAgB,EAChB,SAAS,EACT,eAAe,EACf,cAAc,EACd,SAAS,GACV,EAAE,iBAAiB,+BA8DnB"}
1
+ {"version":3,"file":"table-surface.d.ts","sourceRoot":"","sources":["../src/table-surface.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,GAAG,EAAE,MAAM,OAAO,CAAC;AAM5C,MAAM,MAAM,gBAAgB,GAAG,SAAS,GAAG,OAAO,GAAG,aAAa,CAAC;AAEnE,MAAM,WAAW,kBAAkB;IACjC,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,SAAS,CAAC;IACjB,OAAO,CAAC,EAAE,SAAS,GAAG,SAAS,GAAG,OAAO,GAAG,aAAa,CAAC;IAC1D,OAAO,CAAC,EAAE,MAAM,IAAI,CAAC;IACrB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAED,UAAU,iBAAiB;IACzB,kCAAkC;IAClC,KAAK,CAAC,EAAE,SAAS,CAAC;IAClB,uCAAuC;IACvC,WAAW,CAAC,EAAE,SAAS,CAAC;IACxB,yDAAyD;IACzD,MAAM,CAAC,EAAE,SAAS,CAAC;IACnB,0EAA0E;IAC1E,QAAQ,EAAE,SAAS,CAAC;IACpB,2DAA2D;IAC3D,MAAM,CAAC,EAAE,SAAS,GAAG,kBAAkB,CAAC;IACxC,8EAA8E;IAC9E,IAAI,CAAC,EAAE,gBAAgB,CAAC;IACxB,8DAA8D;IAC9D,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,yDAAyD;IACzD,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,2DAA2D;IAC3D,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,gEAAgE;IAChE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;;OAKG;IACH,UAAU,CAAC,EAAE,SAAS,GAAG,OAAO,CAAC;IACjC,qEAAqE;IACrE,gBAAgB,CAAC,EAAE,GAAG,CAAC,cAAc,CAAC,CAAC;CACxC;AAyBD;;;;;;GAMG;AACH,wBAAgB,YAAY,CAAC,EAC3B,KAAK,EACL,WAAW,EACX,MAAM,EACN,QAAQ,EACR,MAAM,EACN,IAAgB,EAChB,SAAS,EACT,eAAe,EACf,cAAc,EACd,SAAS,EACT,UAAsB,EACtB,gBAAgB,GACjB,EAAE,iBAAiB,+BAuFnB"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jinaan/uikit",
3
- "version": "0.1.39",
3
+ "version": "0.1.41",
4
4
  "description": "Reusable React and Tailwind UI components for Jinaan projects.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -12,7 +12,15 @@ import {
12
12
  WarningCircle,
13
13
  X,
14
14
  } from "@phosphor-icons/react";
15
- import { useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react";
15
+ import {
16
+ useCallback,
17
+ useEffect,
18
+ useLayoutEffect,
19
+ useMemo,
20
+ useRef,
21
+ useState,
22
+ type ReactNode,
23
+ } from "react";
16
24
 
17
25
  import { Avatar, AvatarFallback, AvatarImage } from "./avatar";
18
26
  import { Button } from "./button";
@@ -117,6 +125,8 @@ interface DataTableProps<T> {
117
125
  filters?: DataTableFilter<T>[];
118
126
  toolbarStart?: ReactNode;
119
127
  toolbarEnd?: ReactNode;
128
+ /** Show the "Columns" visibility/reorder picker in the toolbar. Default true. */
129
+ columnPicker?: boolean;
120
130
 
121
131
  tabs?: TabDef[];
122
132
  activeTab?: string;
@@ -203,6 +213,7 @@ export function DataTable<T>({
203
213
  filters,
204
214
  toolbarStart,
205
215
  toolbarEnd,
216
+ columnPicker = true,
206
217
  tabs,
207
218
  activeTab,
208
219
  onTabChange,
@@ -354,20 +365,43 @@ export function DataTable<T>({
354
365
  // same pixel widths; both tables share the surface's horizontal scroller.
355
366
  const bodyTableRef = useRef<HTMLTableElement | null>(null);
356
367
  const bandTableRef = useRef<HTMLTableElement | null>(null);
368
+ const bandContentRef = useRef<HTMLDivElement | null>(null);
369
+ const scrollerRef = useRef<HTMLDivElement | null>(null);
357
370
  const [columnWidths, setColumnWidths] = useState<number[]>([]);
358
371
  // Deps must be primitives: visibleColumns is a fresh array every render and
359
372
  // would re-run this effect (and its setState) in a loop.
360
373
  const columnKey = visibleColumns.map((c) => c.id).join("\u0000");
374
+ // Identity of the rendered column set (selection, actions, visibility,
375
+ // order). The pinned colgroup maps positionally onto the columns, so any
376
+ // structural change invalidates the measured widths — otherwise the new
377
+ // column steals its neighbor's width and every column shifts by one.
378
+ const structureKey = `${selectable ? "s" : "-"}|${rowActions ? "a" : "-"}|${columnKey}`;
379
+ const structureRef = useRef(structureKey);
380
+ const pinned = columnWidths.length > 0;
381
+
382
+ // Panel-scroll mode: the band stays fixed to the shell and cannot scroll
383
+ // natively, so it translates by the row scroller's scrollLeft to keep every
384
+ // title over its column. Direct DOM write — no re-render per scroll event.
385
+ const syncBandScroll = useCallback(() => {
386
+ const band = bandContentRef.current;
387
+ const scroller = scrollerRef.current;
388
+ if (band && scroller) band.style.transform = `translateX(${-scroller.scrollLeft}px)`;
389
+ }, []);
361
390
 
362
391
  useLayoutEffect(() => {
363
392
  const table = bodyTableRef.current;
364
393
  if (!table) return;
394
+ if (structureRef.current !== structureKey) {
395
+ // Column set changed: drop the pinned widths so the body re-renders
396
+ // content-sized (table-auto) and the next pass measures intrinsic
397
+ // widths against the new structure. Without this, the effect would
398
+ // measure the stale colgroup's shifted layout and pin the wrong widths.
399
+ structureRef.current = structureKey;
400
+ setColumnWidths((prev) => (prev.length === 0 ? prev : []));
401
+ return;
402
+ }
365
403
  const measure = () => {
366
404
  const cells = table.tBodies[0]?.rows.item(0)?.cells;
367
- // The band may be squeezed narrower than its own sort-button content by
368
- // the body-measured widths; scrollWidth reports the true minimum, so it
369
- // floors each column and stops titles from overflowing their neighbors.
370
- const bandCells = bandTableRef.current?.querySelectorAll("thead th");
371
405
  if (!cells || cells.length === 0) {
372
406
  setColumnWidths((prev) => (prev.length === 0 ? prev : []));
373
407
  return;
@@ -375,10 +409,19 @@ export function DataTable<T>({
375
409
  // Widths cover every rendered column — including the selection checkbox
376
410
  // and row-actions cells — in order, so one <col> per entry maps 1:1 in
377
411
  // both colgroups. Never add extra <col> elements alongside these.
412
+ //
413
+ // Header floor: read the th's sizer span, never the th/cell layout
414
+ // itself. Once pinned with w-full, fixed-layout columns render wider
415
+ // than their content (surplus distribution) and reading them back would
416
+ // ratchet totalWidth up to the container size — after any container
417
+ // shrink the stale minWidth would keep a scrollbar alive forever. The
418
+ // sizer span is intrinsic in every state. Widths stay unrounded so the
419
+ // sum is the exact content requirement.
420
+ const bandThs = bandTableRef.current?.querySelectorAll("thead th");
378
421
  const widths = Array.from(cells, (cell, i) =>
379
422
  Math.max(
380
- Math.round(cell.getBoundingClientRect().width),
381
- Math.round(bandCells?.item(i)?.scrollWidth ?? 0),
423
+ cell.getBoundingClientRect().width,
424
+ bandThs?.item(i)?.querySelector("[data-col-sizer]")?.getBoundingClientRect().width ?? 0,
382
425
  ),
383
426
  );
384
427
  // Bail out when nothing changed: a new array would re-render, and the
@@ -388,11 +431,16 @@ export function DataTable<T>({
388
431
  );
389
432
  };
390
433
  measure();
434
+ syncBandScroll();
391
435
  if (typeof ResizeObserver === "undefined") return;
392
436
  const observer = new ResizeObserver(measure);
393
437
  observer.observe(table);
394
438
  return () => observer.disconnect();
395
- }, [columnKey, selectable, rowActions, loading, error]);
439
+ // `pinned` re-runs this effect after the structure reset clears the
440
+ // widths (false) and again after re-pinning (true). Safe to measure in
441
+ // the pinned state because the floor reads sizer spans — intrinsic in
442
+ // every state — never the pinned layout itself.
443
+ }, [structureKey, pinned, loading, error, syncBandScroll]);
396
444
 
397
445
  const totalWidth = columnWidths.reduce((sum, width) => sum + width, 0);
398
446
 
@@ -439,84 +487,86 @@ export function DataTable<T>({
439
487
  />
440
488
  )}
441
489
  {toolbarStart}
442
- <DropdownMenu>
443
- <DropdownMenuTrigger asChild>
444
- <Button
445
- variant="secondary"
446
- size="md"
447
- leadingIcon={<ColumnsIcon />}
448
- trailingIcon={<DropdownMenuChevron />}
449
- >
450
- Columns
451
- </Button>
452
- </DropdownMenuTrigger>
453
- <DropdownMenuContent align="end" className="w-54">
454
- <DropdownMenuLabel className="text-[13px]">Active columns</DropdownMenuLabel>
455
- {visibleColumns.map((c) => {
456
- const pinned = isPinned(c);
457
- return (
458
- <DropdownMenuCheckboxItem
459
- key={c.id}
460
- checked
461
- draggable={!pinned}
462
- onDragStart={(e) => {
463
- if (pinned) {
464
- e.preventDefault();
465
- return;
466
- }
467
- e.dataTransfer.effectAllowed = "move";
468
- e.dataTransfer.setData("text/plain", c.id);
469
- setDragId(c.id);
470
- }}
471
- onDragOver={(e) => {
472
- if (dragId) {
473
- e.preventDefault();
474
- moveBefore(dragId, c.id);
475
- }
476
- }}
477
- onDrop={(e) => e.preventDefault()}
478
- onDragEnd={() => setDragId(null)}
479
- onCheckedChange={(next) => {
480
- if (!next && !pinned) {
481
- setHidden((prev) => ({ ...prev, [c.id]: true }));
482
- }
483
- }}
484
- onSelect={(e) => e.preventDefault()}
485
- className="justify-between"
486
- >
487
- <span className="truncate">{c.header}</span>
488
- {pinned ? (
489
- <Lock className="size-3.5 shrink-0 text-subtle" aria-hidden />
490
- ) : (
491
- <DotsSixVertical
492
- aria-hidden
493
- className={cn(
494
- "size-3.5 shrink-0",
495
- dragId === c.id ? "text-primary" : "text-subtle",
496
- )}
497
- />
498
- )}
499
- </DropdownMenuCheckboxItem>
500
- );
501
- })}
502
- {hiddenColumns.length > 0 ? (
503
- <>
504
- <DropdownMenuSeparator />
505
- <DropdownMenuLabel className="text-[13px]">Available columns</DropdownMenuLabel>
506
- {hiddenColumns.map((c) => (
490
+ {columnPicker && (
491
+ <DropdownMenu>
492
+ <DropdownMenuTrigger asChild>
493
+ <Button
494
+ variant="secondary"
495
+ size="md"
496
+ leadingIcon={<ColumnsIcon />}
497
+ trailingIcon={<DropdownMenuChevron />}
498
+ >
499
+ Columns
500
+ </Button>
501
+ </DropdownMenuTrigger>
502
+ <DropdownMenuContent align="end" className="w-54">
503
+ <DropdownMenuLabel className="text-[13px]">Active columns</DropdownMenuLabel>
504
+ {visibleColumns.map((c) => {
505
+ const pinned = isPinned(c);
506
+ return (
507
507
  <DropdownMenuCheckboxItem
508
508
  key={c.id}
509
- checked={false}
510
- onCheckedChange={() => showColumn(c.id)}
509
+ checked
510
+ draggable={!pinned}
511
+ onDragStart={(e) => {
512
+ if (pinned) {
513
+ e.preventDefault();
514
+ return;
515
+ }
516
+ e.dataTransfer.effectAllowed = "move";
517
+ e.dataTransfer.setData("text/plain", c.id);
518
+ setDragId(c.id);
519
+ }}
520
+ onDragOver={(e) => {
521
+ if (dragId) {
522
+ e.preventDefault();
523
+ moveBefore(dragId, c.id);
524
+ }
525
+ }}
526
+ onDrop={(e) => e.preventDefault()}
527
+ onDragEnd={() => setDragId(null)}
528
+ onCheckedChange={(next) => {
529
+ if (!next && !pinned) {
530
+ setHidden((prev) => ({ ...prev, [c.id]: true }));
531
+ }
532
+ }}
511
533
  onSelect={(e) => e.preventDefault()}
534
+ className="justify-between"
512
535
  >
513
- {c.header}
536
+ <span className="truncate">{c.header}</span>
537
+ {pinned ? (
538
+ <Lock className="size-3.5 shrink-0 text-subtle" aria-hidden />
539
+ ) : (
540
+ <DotsSixVertical
541
+ aria-hidden
542
+ className={cn(
543
+ "size-3.5 shrink-0",
544
+ dragId === c.id ? "text-primary" : "text-subtle",
545
+ )}
546
+ />
547
+ )}
514
548
  </DropdownMenuCheckboxItem>
515
- ))}
516
- </>
517
- ) : null}
518
- </DropdownMenuContent>
519
- </DropdownMenu>
549
+ );
550
+ })}
551
+ {hiddenColumns.length > 0 ? (
552
+ <>
553
+ <DropdownMenuSeparator />
554
+ <DropdownMenuLabel className="text-[13px]">Available columns</DropdownMenuLabel>
555
+ {hiddenColumns.map((c) => (
556
+ <DropdownMenuCheckboxItem
557
+ key={c.id}
558
+ checked={false}
559
+ onCheckedChange={() => showColumn(c.id)}
560
+ onSelect={(e) => e.preventDefault()}
561
+ >
562
+ {c.header}
563
+ </DropdownMenuCheckboxItem>
564
+ ))}
565
+ </>
566
+ ) : null}
567
+ </DropdownMenuContent>
568
+ </DropdownMenu>
569
+ )}
520
570
  {filters && filters.length > 0 ? (
521
571
  <DropdownMenu>
522
572
  <DropdownMenuTrigger asChild>
@@ -657,6 +707,8 @@ export function DataTable<T>({
657
707
  )}
658
708
 
659
709
  <TableSurface
710
+ scrollMode="panel"
711
+ headerContentRef={bandContentRef}
660
712
  action={headerAction}
661
713
  tone={tone}
662
714
  headerClassName={headerClassName}
@@ -670,7 +722,9 @@ export function DataTable<T>({
670
722
  role="presentation"
671
723
  className="w-full table-fixed border-collapse"
672
724
  style={{
673
- width: totalWidth > 0 ? totalWidth : undefined,
725
+ // Fill the container when it fits; force totalWidth (and the
726
+ // shared scroller) only when the columns need more room.
727
+ minWidth: totalWidth > 0 ? totalWidth : undefined,
674
728
  visibility: totalWidth > 0 ? undefined : "hidden",
675
729
  }}
676
730
  >
@@ -681,8 +735,11 @@ export function DataTable<T>({
681
735
  </colgroup>
682
736
  <thead>
683
737
  <tr>
738
+ {/* text-left: UA stylesheets center th content; without this
739
+ the select-all checkbox centers in its (surplus-widened)
740
+ column and drifts right of the row checkboxes. */}
684
741
  {selectable && (
685
- <th className="h-(--table-header-h) w-11 px-4 align-middle">
742
+ <th className="h-(--table-header-h) w-11 px-(--table-pad-x) text-left align-middle">
686
743
  <Checkbox
687
744
  aria-label="Select all rows"
688
745
  checked={allSelected ? true : someSelected ? "indeterminate" : false}
@@ -695,34 +752,44 @@ export function DataTable<T>({
695
752
  key={c.id}
696
753
  className={cn(HEADER_CELL, c.align === "right" && "text-right", c.className)}
697
754
  >
698
- {c.sortable ? (
699
- <Button
700
- type="button"
701
- variant="ghost"
702
- size="xs"
703
- aria-label={`Sort by ${c.header}`}
704
- onClick={() => toggleSort(c)}
705
- className={cn(
706
- "h-auto gap-1.5 rounded-none px-0 py-0 text-[0.8125rem] text-table-header transition-colors [&_svg]:size-3.5",
707
- "hover:bg-transparent hover:text-table-header-active hover:shadow-none",
708
- "active:scale-100 active:bg-transparent active:shadow-none",
709
- sort?.id === c.id && "text-table-header-active",
710
- )}
711
- >
712
- {c.header}
713
- {sort?.id === c.id ? (
714
- sort.dir === "asc" ? (
715
- <CaretUp className="size-3.5" weight="bold" />
755
+ {/* Sizer: shrink-wraps the title (sort carets included) so
756
+ its rect is the column's intrinsic header width even
757
+ while the fixed layout squeezes the th. The negative
758
+ margins cancel the cell padding the span adds, so the
759
+ measured width is directly comparable to cell widths. */}
760
+ <span
761
+ data-col-sizer
762
+ className="inline-block whitespace-nowrap px-(--table-pad-x) -mx-(--table-pad-x)"
763
+ >
764
+ {c.sortable ? (
765
+ <Button
766
+ type="button"
767
+ variant="ghost"
768
+ size="xs"
769
+ aria-label={`Sort by ${c.header}`}
770
+ onClick={() => toggleSort(c)}
771
+ className={cn(
772
+ "h-auto gap-1.5 rounded-none px-0 py-0 text-[0.8125rem] text-table-header transition-colors [&_svg]:size-3.5",
773
+ "hover:bg-transparent hover:text-table-header-active hover:shadow-none",
774
+ "active:scale-100 active:bg-transparent active:shadow-none",
775
+ sort?.id === c.id && "text-table-header-active",
776
+ )}
777
+ >
778
+ {c.header}
779
+ {sort?.id === c.id ? (
780
+ sort.dir === "asc" ? (
781
+ <CaretUp className="size-3.5" weight="bold" />
782
+ ) : (
783
+ <CaretDown className="size-3.5" weight="bold" />
784
+ )
716
785
  ) : (
717
- <CaretDown className="size-3.5" weight="bold" />
718
- )
719
- ) : (
720
- <CaretUpDown className="size-3.5 opacity-50" />
721
- )}
722
- </Button>
723
- ) : (
724
- c.header
725
- )}
786
+ <CaretUpDown className="size-3.5 opacity-50" />
787
+ )}
788
+ </Button>
789
+ ) : (
790
+ c.header
791
+ )}
792
+ </span>
726
793
  </th>
727
794
  ))}
728
795
  {rowActions && (
@@ -735,6 +802,10 @@ export function DataTable<T>({
735
802
  </table>
736
803
  }
737
804
  >
805
+ {/* The scroller lives inside the panel: both shells stay fixed while
806
+ only the row content scrolls, and onScroll keeps the band titles
807
+ translated in lockstep with the rows. */}
808
+ <div ref={scrollerRef} className="overflow-x-auto" onScroll={syncBandScroll}>
738
809
  {loading ? (
739
810
  <div className="space-y-2 p-4">
740
811
  {[0, 1, 2].map((i) => (
@@ -772,10 +843,12 @@ export function DataTable<T>({
772
843
  "border-collapse text-left",
773
844
  // Unmeasured: content-sized so intrinsic column widths can be
774
845
  // measured (w-full would squeeze columns into the container).
775
- // Measured: pinned to the band's colgroup pixel widths.
846
+ // Measured: fill the container, pinned to the band's colgroup
847
+ // pixel widths; minWidth forces the shared scroller open only
848
+ // when the columns need more room than the container has.
776
849
  columnWidths.length > 0 ? "w-full table-fixed" : "table-auto",
777
850
  )}
778
- style={{ width: totalWidth > 0 ? totalWidth : undefined }}
851
+ style={{ minWidth: totalWidth > 0 ? totalWidth : undefined }}
779
852
  >
780
853
  {columnWidths.length > 0 && (
781
854
  <colgroup>
@@ -808,7 +881,7 @@ export function DataTable<T>({
808
881
  className={onRowClick ? "cursor-pointer" : undefined}
809
882
  >
810
883
  {selectable && (
811
- <TableCell className="w-11 px-4" onClick={(e) => e.stopPropagation()}>
884
+ <TableCell className="w-11" onClick={(e) => e.stopPropagation()}>
812
885
  <Checkbox
813
886
  aria-label="Select row"
814
887
  checked={isSelected}
@@ -836,7 +909,8 @@ export function DataTable<T>({
836
909
  </TableBody>
837
910
  </table>
838
911
  )}
839
- </TableSurface>
912
+ </div>
913
+ </TableSurface>
840
914
 
841
915
  {showPagination && (
842
916
  <div className="flex flex-wrap items-center justify-between gap-3 text-sm">
@@ -1,4 +1,4 @@
1
- import type { ReactNode } from "react";
1
+ import type { ReactNode, Ref } from "react";
2
2
 
3
3
  import { Button } from "./button";
4
4
  import { TableContainer, TablePanel } from "./table";
@@ -36,6 +36,15 @@ interface TableSurfaceProps {
36
36
  panelClassName?: string;
37
37
  /** Minimum panel height; keeps empty states from collapsing. */
38
38
  minHeight?: string;
39
+ /**
40
+ * Where horizontal scrolling lives. `surface` (default) scrolls band and
41
+ * panel together — right for short content. `panel` keeps both shells fixed
42
+ * while the caller puts the scroller inside the panel; the band content is
43
+ * then expected to translate in lockstep via `headerContentRef`.
44
+ */
45
+ scrollMode?: "surface" | "panel";
46
+ /** Panel mode only: ref for the band's translating inner wrapper. */
47
+ headerContentRef?: Ref<HTMLDivElement>;
39
48
  }
40
49
 
41
50
  const SHELL_TONE: Record<TableSurfaceTone, string> = {
@@ -79,8 +88,11 @@ export function TableSurface({
79
88
  headerClassName,
80
89
  panelClassName,
81
90
  minHeight,
91
+ scrollMode = "surface",
92
+ headerContentRef,
82
93
  }: TableSurfaceProps) {
83
94
  const hasTitle = Boolean(title || description);
95
+ const panelScroll = scrollMode === "panel";
84
96
  const actionNode = isActionConfig(action) ? (
85
97
  <Button
86
98
  variant={action.variant ?? "primary"}
@@ -103,37 +115,61 @@ export function TableSurface({
103
115
  action
104
116
  );
105
117
 
106
- return (
107
- <TableContainer className={cn("relative", SHELL_TONE[tone], className)}>
108
- <div className="overflow-x-auto">
109
- {header || hasTitle ? (
110
- // Header rows must not inherit the body row hover surface.
111
- <div className={cn("[--table-row-hover:transparent] text-table-header", headerClassName)}>
112
- {hasTitle ? (
113
- <div
114
- className={cn(
115
- "px-(--table-pad-x) pb-3 pt-4",
116
- actionNode && "pr-28",
117
- )}
118
- >
119
- {title ? (
120
- <div className="text-sm font-semibold text-fg">{title}</div>
121
- ) : null}
122
- {description ? (
123
- <div className="mt-0.5 text-sm text-muted">{description}</div>
124
- ) : null}
125
- </div>
118
+ const band =
119
+ header || hasTitle ? (
120
+ // Header rows must not inherit the body row hover surface. Panel mode
121
+ // clips the band instead of scrolling it; the caller translates the
122
+ // inner wrapper so titles track the row content. px-px mirrors the
123
+ // panel's 1px border so band and body share the same width context —
124
+ // identical fixed-layout distribution keeps every column aligned.
125
+ <div
126
+ className={cn(
127
+ "[--table-row-hover:transparent] text-table-header",
128
+ panelScroll && "overflow-hidden px-px",
129
+ headerClassName,
130
+ )}
131
+ >
132
+ {hasTitle ? (
133
+ <div
134
+ className={cn(
135
+ "px-(--table-pad-x) pb-3 pt-4",
136
+ actionNode && "pr-28",
137
+ )}
138
+ >
139
+ {title ? (
140
+ <div className="text-sm font-semibold text-fg">{title}</div>
141
+ ) : null}
142
+ {description ? (
143
+ <div className="mt-0.5 text-sm text-muted">{description}</div>
126
144
  ) : null}
127
- {header}
128
145
  </div>
129
146
  ) : null}
130
- <TablePanel
131
- className={cn(PANEL_TONE[tone], panelClassName)}
132
- style={minHeight ? { minHeight } : undefined}
133
- >
134
- {children}
135
- </TablePanel>
147
+ {panelScroll ? <div ref={headerContentRef}>{header}</div> : header}
136
148
  </div>
149
+ ) : null;
150
+
151
+ const panel = (
152
+ <TablePanel
153
+ className={cn(PANEL_TONE[tone], panelClassName)}
154
+ style={minHeight ? { minHeight } : undefined}
155
+ >
156
+ {children}
157
+ </TablePanel>
158
+ );
159
+
160
+ return (
161
+ <TableContainer className={cn("relative", SHELL_TONE[tone], className)}>
162
+ {panelScroll ? (
163
+ <>
164
+ {band}
165
+ {panel}
166
+ </>
167
+ ) : (
168
+ <div className="overflow-x-auto">
169
+ {band}
170
+ {panel}
171
+ </div>
172
+ )}
137
173
  {actionNode ? (
138
174
  <div className="pointer-events-none absolute right-4 top-(--table-inset) flex h-(--table-header-h) items-center">
139
175
  <div className="pointer-events-auto">{actionNode}</div>