@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.
- package/dist/data-table.d.ts +3 -1
- package/dist/data-table.d.ts.map +1 -1
- package/dist/index.js +691 -651
- package/dist/index.js.map +1 -1
- package/dist/table-surface.d.ts +11 -2
- package/dist/table-surface.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/data-table.tsx +188 -114
- package/src/table-surface.tsx +64 -28
package/dist/table-surface.d.ts
CHANGED
|
@@ -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;
|
|
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
package/src/data-table.tsx
CHANGED
|
@@ -12,7 +12,15 @@ import {
|
|
|
12
12
|
WarningCircle,
|
|
13
13
|
X,
|
|
14
14
|
} from "@phosphor-icons/react";
|
|
15
|
-
import {
|
|
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
|
-
|
|
381
|
-
|
|
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
|
-
|
|
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
|
-
|
|
443
|
-
<
|
|
444
|
-
<
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
<
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
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
|
|
510
|
-
|
|
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
|
-
|
|
518
|
-
|
|
519
|
-
|
|
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
|
-
|
|
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-
|
|
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
|
-
{
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
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
|
-
<
|
|
718
|
-
)
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
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
|
|
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={{
|
|
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
|
|
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
|
-
|
|
912
|
+
</div>
|
|
913
|
+
</TableSurface>
|
|
840
914
|
|
|
841
915
|
{showPagination && (
|
|
842
916
|
<div className="flex flex-wrap items-center justify-between gap-3 text-sm">
|
package/src/table-surface.tsx
CHANGED
|
@@ -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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
<
|
|
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>
|