@jinaan/uikit 0.1.39 → 0.1.40

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.40",
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";
@@ -354,20 +362,43 @@ export function DataTable<T>({
354
362
  // same pixel widths; both tables share the surface's horizontal scroller.
355
363
  const bodyTableRef = useRef<HTMLTableElement | null>(null);
356
364
  const bandTableRef = useRef<HTMLTableElement | null>(null);
365
+ const bandContentRef = useRef<HTMLDivElement | null>(null);
366
+ const scrollerRef = useRef<HTMLDivElement | null>(null);
357
367
  const [columnWidths, setColumnWidths] = useState<number[]>([]);
358
368
  // Deps must be primitives: visibleColumns is a fresh array every render and
359
369
  // would re-run this effect (and its setState) in a loop.
360
370
  const columnKey = visibleColumns.map((c) => c.id).join("\u0000");
371
+ // Identity of the rendered column set (selection, actions, visibility,
372
+ // order). The pinned colgroup maps positionally onto the columns, so any
373
+ // structural change invalidates the measured widths — otherwise the new
374
+ // column steals its neighbor's width and every column shifts by one.
375
+ const structureKey = `${selectable ? "s" : "-"}|${rowActions ? "a" : "-"}|${columnKey}`;
376
+ const structureRef = useRef(structureKey);
377
+ const pinned = columnWidths.length > 0;
378
+
379
+ // Panel-scroll mode: the band stays fixed to the shell and cannot scroll
380
+ // natively, so it translates by the row scroller's scrollLeft to keep every
381
+ // title over its column. Direct DOM write — no re-render per scroll event.
382
+ const syncBandScroll = useCallback(() => {
383
+ const band = bandContentRef.current;
384
+ const scroller = scrollerRef.current;
385
+ if (band && scroller) band.style.transform = `translateX(${-scroller.scrollLeft}px)`;
386
+ }, []);
361
387
 
362
388
  useLayoutEffect(() => {
363
389
  const table = bodyTableRef.current;
364
390
  if (!table) return;
391
+ if (structureRef.current !== structureKey) {
392
+ // Column set changed: drop the pinned widths so the body re-renders
393
+ // content-sized (table-auto) and the next pass measures intrinsic
394
+ // widths against the new structure. Without this, the effect would
395
+ // measure the stale colgroup's shifted layout and pin the wrong widths.
396
+ structureRef.current = structureKey;
397
+ setColumnWidths((prev) => (prev.length === 0 ? prev : []));
398
+ return;
399
+ }
365
400
  const measure = () => {
366
401
  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
402
  if (!cells || cells.length === 0) {
372
403
  setColumnWidths((prev) => (prev.length === 0 ? prev : []));
373
404
  return;
@@ -375,10 +406,19 @@ export function DataTable<T>({
375
406
  // Widths cover every rendered column — including the selection checkbox
376
407
  // and row-actions cells — in order, so one <col> per entry maps 1:1 in
377
408
  // both colgroups. Never add extra <col> elements alongside these.
409
+ //
410
+ // Header floor: read the th's sizer span, never the th/cell layout
411
+ // itself. Once pinned with w-full, fixed-layout columns render wider
412
+ // than their content (surplus distribution) and reading them back would
413
+ // ratchet totalWidth up to the container size — after any container
414
+ // shrink the stale minWidth would keep a scrollbar alive forever. The
415
+ // sizer span is intrinsic in every state. Widths stay unrounded so the
416
+ // sum is the exact content requirement.
417
+ const bandThs = bandTableRef.current?.querySelectorAll("thead th");
378
418
  const widths = Array.from(cells, (cell, i) =>
379
419
  Math.max(
380
- Math.round(cell.getBoundingClientRect().width),
381
- Math.round(bandCells?.item(i)?.scrollWidth ?? 0),
420
+ cell.getBoundingClientRect().width,
421
+ bandThs?.item(i)?.querySelector("[data-col-sizer]")?.getBoundingClientRect().width ?? 0,
382
422
  ),
383
423
  );
384
424
  // Bail out when nothing changed: a new array would re-render, and the
@@ -388,11 +428,16 @@ export function DataTable<T>({
388
428
  );
389
429
  };
390
430
  measure();
431
+ syncBandScroll();
391
432
  if (typeof ResizeObserver === "undefined") return;
392
433
  const observer = new ResizeObserver(measure);
393
434
  observer.observe(table);
394
435
  return () => observer.disconnect();
395
- }, [columnKey, selectable, rowActions, loading, error]);
436
+ // `pinned` re-runs this effect after the structure reset clears the
437
+ // widths (false) and again after re-pinning (true). Safe to measure in
438
+ // the pinned state because the floor reads sizer spans — intrinsic in
439
+ // every state — never the pinned layout itself.
440
+ }, [structureKey, pinned, loading, error, syncBandScroll]);
396
441
 
397
442
  const totalWidth = columnWidths.reduce((sum, width) => sum + width, 0);
398
443
 
@@ -657,6 +702,8 @@ export function DataTable<T>({
657
702
  )}
658
703
 
659
704
  <TableSurface
705
+ scrollMode="panel"
706
+ headerContentRef={bandContentRef}
660
707
  action={headerAction}
661
708
  tone={tone}
662
709
  headerClassName={headerClassName}
@@ -670,7 +717,9 @@ export function DataTable<T>({
670
717
  role="presentation"
671
718
  className="w-full table-fixed border-collapse"
672
719
  style={{
673
- width: totalWidth > 0 ? totalWidth : undefined,
720
+ // Fill the container when it fits; force totalWidth (and the
721
+ // shared scroller) only when the columns need more room.
722
+ minWidth: totalWidth > 0 ? totalWidth : undefined,
674
723
  visibility: totalWidth > 0 ? undefined : "hidden",
675
724
  }}
676
725
  >
@@ -681,8 +730,11 @@ export function DataTable<T>({
681
730
  </colgroup>
682
731
  <thead>
683
732
  <tr>
733
+ {/* text-left: UA stylesheets center th content; without this
734
+ the select-all checkbox centers in its (surplus-widened)
735
+ column and drifts right of the row checkboxes. */}
684
736
  {selectable && (
685
- <th className="h-(--table-header-h) w-11 px-4 align-middle">
737
+ <th className="h-(--table-header-h) w-11 px-(--table-pad-x) text-left align-middle">
686
738
  <Checkbox
687
739
  aria-label="Select all rows"
688
740
  checked={allSelected ? true : someSelected ? "indeterminate" : false}
@@ -695,34 +747,44 @@ export function DataTable<T>({
695
747
  key={c.id}
696
748
  className={cn(HEADER_CELL, c.align === "right" && "text-right", c.className)}
697
749
  >
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" />
750
+ {/* Sizer: shrink-wraps the title (sort carets included) so
751
+ its rect is the column's intrinsic header width even
752
+ while the fixed layout squeezes the th. The negative
753
+ margins cancel the cell padding the span adds, so the
754
+ measured width is directly comparable to cell widths. */}
755
+ <span
756
+ data-col-sizer
757
+ className="inline-block whitespace-nowrap px-(--table-pad-x) -mx-(--table-pad-x)"
758
+ >
759
+ {c.sortable ? (
760
+ <Button
761
+ type="button"
762
+ variant="ghost"
763
+ size="xs"
764
+ aria-label={`Sort by ${c.header}`}
765
+ onClick={() => toggleSort(c)}
766
+ className={cn(
767
+ "h-auto gap-1.5 rounded-none px-0 py-0 text-[0.8125rem] text-table-header transition-colors [&_svg]:size-3.5",
768
+ "hover:bg-transparent hover:text-table-header-active hover:shadow-none",
769
+ "active:scale-100 active:bg-transparent active:shadow-none",
770
+ sort?.id === c.id && "text-table-header-active",
771
+ )}
772
+ >
773
+ {c.header}
774
+ {sort?.id === c.id ? (
775
+ sort.dir === "asc" ? (
776
+ <CaretUp className="size-3.5" weight="bold" />
777
+ ) : (
778
+ <CaretDown className="size-3.5" weight="bold" />
779
+ )
716
780
  ) : (
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
- )}
781
+ <CaretUpDown className="size-3.5 opacity-50" />
782
+ )}
783
+ </Button>
784
+ ) : (
785
+ c.header
786
+ )}
787
+ </span>
726
788
  </th>
727
789
  ))}
728
790
  {rowActions && (
@@ -735,6 +797,10 @@ export function DataTable<T>({
735
797
  </table>
736
798
  }
737
799
  >
800
+ {/* The scroller lives inside the panel: both shells stay fixed while
801
+ only the row content scrolls, and onScroll keeps the band titles
802
+ translated in lockstep with the rows. */}
803
+ <div ref={scrollerRef} className="overflow-x-auto" onScroll={syncBandScroll}>
738
804
  {loading ? (
739
805
  <div className="space-y-2 p-4">
740
806
  {[0, 1, 2].map((i) => (
@@ -772,10 +838,12 @@ export function DataTable<T>({
772
838
  "border-collapse text-left",
773
839
  // Unmeasured: content-sized so intrinsic column widths can be
774
840
  // measured (w-full would squeeze columns into the container).
775
- // Measured: pinned to the band's colgroup pixel widths.
841
+ // Measured: fill the container, pinned to the band's colgroup
842
+ // pixel widths; minWidth forces the shared scroller open only
843
+ // when the columns need more room than the container has.
776
844
  columnWidths.length > 0 ? "w-full table-fixed" : "table-auto",
777
845
  )}
778
- style={{ width: totalWidth > 0 ? totalWidth : undefined }}
846
+ style={{ minWidth: totalWidth > 0 ? totalWidth : undefined }}
779
847
  >
780
848
  {columnWidths.length > 0 && (
781
849
  <colgroup>
@@ -808,7 +876,7 @@ export function DataTable<T>({
808
876
  className={onRowClick ? "cursor-pointer" : undefined}
809
877
  >
810
878
  {selectable && (
811
- <TableCell className="w-11 px-4" onClick={(e) => e.stopPropagation()}>
879
+ <TableCell className="w-11" onClick={(e) => e.stopPropagation()}>
812
880
  <Checkbox
813
881
  aria-label="Select row"
814
882
  checked={isSelected}
@@ -836,7 +904,8 @@ export function DataTable<T>({
836
904
  </TableBody>
837
905
  </table>
838
906
  )}
839
- </TableSurface>
907
+ </div>
908
+ </TableSurface>
840
909
 
841
910
  {showPagination && (
842
911
  <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>