@jinaan/uikit 0.1.34 → 0.1.36

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jinaan/uikit",
3
- "version": "0.1.34",
3
+ "version": "0.1.36",
4
4
  "description": "Reusable React and Tailwind UI components for Jinaan projects.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -1,5 +1,7 @@
1
1
  import {
2
2
  CaretDown,
3
+ CaretLeft,
4
+ CaretRight,
3
5
  CaretUp,
4
6
  CaretUpDown,
5
7
  Columns as ColumnsIcon,
@@ -10,7 +12,7 @@ import {
10
12
  WarningCircle,
11
13
  X,
12
14
  } from "@phosphor-icons/react";
13
- import { useEffect, useMemo, useState, type ReactNode } from "react";
15
+ import { useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react";
14
16
 
15
17
  import { Avatar, AvatarFallback, AvatarImage } from "./avatar";
16
18
  import { Button } from "./button";
@@ -52,7 +54,7 @@ export interface Column<T> {
52
54
  defaultHidden?: boolean;
53
55
  align?: "left" | "right";
54
56
  className?: string;
55
- /** Column width for the fixed layout (e.g. "18rem", "20%"). Defaults to auto. */
57
+ /** Advisory only: columns are content-sized; the layout ignores this. */
56
58
  width?: string;
57
59
  /** Required columns stay visible and cannot be hidden. */
58
60
  required?: boolean;
@@ -132,6 +134,12 @@ interface DataTableProps<T> {
132
134
  pageSize?: number;
133
135
  pageSizeOptions?: number[];
134
136
 
137
+ /**
138
+ * `auto` (default) hides the pagination footer when all rows fit on one
139
+ * page; `always` keeps it visible whenever there is at least one row.
140
+ */
141
+ pagination?: "auto" | "always";
142
+
135
143
  /** Surface preset for the table card. */
136
144
  tone?: TableSurfaceTone;
137
145
  /** Optional action pinned to the top-right of the header band. */
@@ -179,6 +187,11 @@ export function DataTableIdentity({
179
187
  );
180
188
  }
181
189
 
190
+ /**
191
+ * Toolbar + single content-sized table inside a TableSurface. Columns fit
192
+ * their widest content (never clipping or overlapping cells) and the surface
193
+ * scrolls horizontally when the columns exceed the viewport.
194
+ */
182
195
  export function DataTable<T>({
183
196
  rows,
184
197
  columns,
@@ -201,6 +214,7 @@ export function DataTable<T>({
201
214
  defaultSort,
202
215
  pageSize: initialPageSize = 25,
203
216
  pageSizeOptions = [10, 25, 50, 100],
217
+ pagination = "auto",
204
218
  tone,
205
219
  headerAction,
206
220
  headerClassName,
@@ -282,6 +296,8 @@ export function DataTable<T>({
282
296
  }, [filtered, sort, columns]);
283
297
 
284
298
  const pageCount = Math.max(1, Math.ceil(sorted.length / pageSize));
299
+ const showPagination =
300
+ !loading && !error && sorted.length > 0 && (pagination === "always" || sorted.length > pageSize);
285
301
 
286
302
  useEffect(() => {
287
303
  setPage(0);
@@ -329,27 +345,54 @@ export function DataTable<T>({
329
345
 
330
346
  const hasToolbar = Boolean(search || filters?.length || toolbarStart || toolbarEnd);
331
347
 
332
- // Fixed layout keeps header and body columns aligned across the two tables.
333
- // When no column declares a width, give the first column more room and split
334
- // the rest evenly so tables don't render as uniform stripes.
335
- const anyExplicitWidth = visibleColumns.some((c) => c.width);
336
- const restShare =
337
- visibleColumns.length > 1 ? (100 - 30) / (visibleColumns.length - 1) : 100;
338
- const dataWidths = visibleColumns.map((c, i) => {
339
- if (c.width) return c.width;
340
- if (anyExplicitWidth) return "auto";
341
- return `${i === 0 ? 30 : restShare}%`;
342
- });
343
-
344
- const colWidths = [
345
- ...(selectable ? ["2.75rem"] : []),
346
- ...dataWidths,
347
- ...(rowActions ? ["11rem"] : []),
348
- ];
349
-
350
348
  const ariaSort = (c: Column<T>) =>
351
349
  sort?.id === c.id ? (sort.dir === "asc" ? "ascending" : "descending") : undefined;
352
350
 
351
+ // The visible header lives in the TableSurface band while the body table
352
+ // sizes its columns to their content (table-auto). Keep the band aligned by
353
+ // measuring the rendered body columns and pinning the band colgroup to the
354
+ // same pixel widths; both tables share the surface's horizontal scroller.
355
+ const bodyTableRef = useRef<HTMLTableElement | null>(null);
356
+ const bandTableRef = useRef<HTMLTableElement | null>(null);
357
+ const [columnWidths, setColumnWidths] = useState<number[]>([]);
358
+ // Deps must be primitives: visibleColumns is a fresh array every render and
359
+ // would re-run this effect (and its setState) in a loop.
360
+ const columnKey = visibleColumns.map((c) => c.id).join("\u0000");
361
+
362
+ useLayoutEffect(() => {
363
+ const table = bodyTableRef.current;
364
+ if (!table) return;
365
+ const measure = () => {
366
+ 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
+ if (!cells || cells.length === 0) {
372
+ setColumnWidths((prev) => (prev.length === 0 ? prev : []));
373
+ return;
374
+ }
375
+ const widths = Array.from(cells, (cell, i) =>
376
+ Math.max(
377
+ Math.round(cell.getBoundingClientRect().width),
378
+ Math.round(bandCells?.item(i)?.scrollWidth ?? 0),
379
+ ),
380
+ );
381
+ // Bail out when nothing changed: a new array would re-render, and the
382
+ // re-render would re-run this effect, looping forever.
383
+ setColumnWidths((prev) =>
384
+ prev.length === widths.length && prev.every((w, i) => w === widths[i]) ? prev : widths,
385
+ );
386
+ };
387
+ measure();
388
+ if (typeof ResizeObserver === "undefined") return;
389
+ const observer = new ResizeObserver(measure);
390
+ observer.observe(table);
391
+ return () => observer.disconnect();
392
+ }, [columnKey, selectable, rowActions, loading, error]);
393
+
394
+ const totalWidth = columnWidths.reduce((sum, width) => sum + width, 0);
395
+
353
396
  return (
354
397
  <div className={cn("space-y-4", className)}>
355
398
  {tabs && tabs.length > 0 && (
@@ -616,12 +659,21 @@ export function DataTable<T>({
616
659
  headerClassName={headerClassName}
617
660
  panelClassName={panelClassName}
618
661
  header={
619
- // Visible header: presentational so its controls stay focusable while
620
- // the body table carries the semantic column headers (sr-only).
621
- <table role="presentation" className="w-full table-fixed border-collapse">
662
+ // Visible header band. Presentational (aria-sort lives on the body's
663
+ // sr-only thead); widths are pinned to the measured body columns and
664
+ // hidden until the first measurement lands so columns never flash.
665
+ <table
666
+ ref={bandTableRef}
667
+ role="presentation"
668
+ className="w-full table-fixed border-collapse"
669
+ style={{
670
+ width: totalWidth > 0 ? totalWidth : undefined,
671
+ visibility: totalWidth > 0 ? undefined : "hidden",
672
+ }}
673
+ >
622
674
  <colgroup>
623
- {colWidths.map((w, i) => (
624
- <col key={i} style={{ width: w }} />
675
+ {columnWidths.map((width, i) => (
676
+ <col key={i} style={{ width }} />
625
677
  ))}
626
678
  </colgroup>
627
679
  <thead>
@@ -711,13 +763,8 @@ export function DataTable<T>({
711
763
  action={empty?.action}
712
764
  />
713
765
  ) : (
714
- <table className="w-full table-fixed border-collapse">
715
- <colgroup>
716
- {colWidths.map((w, i) => (
717
- <col key={i} style={{ width: w }} />
718
- ))}
719
- </colgroup>
720
- {/* Semantic headers for assistive tech (visible header is presentational). */}
766
+ <table ref={bodyTableRef} className="w-full table-auto border-collapse text-left">
767
+ {/* Semantic headers for assistive tech; the visible band is presentational. */}
721
768
  <thead className="sr-only">
722
769
  <tr>
723
770
  {selectable && <th scope="col">Select</th>}
@@ -771,7 +818,7 @@ export function DataTable<T>({
771
818
  )}
772
819
  </TableSurface>
773
820
 
774
- {!loading && !error && sorted.length > 0 && (
821
+ {showPagination && (
775
822
  <div className="flex flex-wrap items-center justify-between gap-3 text-sm">
776
823
  <span className="text-muted">
777
824
  {start + 1}–{Math.min(start + pageSize, sorted.length)} of {sorted.length}
@@ -784,27 +831,31 @@ export function DataTable<T>({
784
831
  <SelectContent>
785
832
  {pageSizeOptions.map((n) => (
786
833
  <SelectItem key={n} value={String(n)}>
787
- {n} / page
834
+ {n}
788
835
  </SelectItem>
789
836
  ))}
790
837
  </SelectContent>
791
838
  </Select>
792
- <Button
793
- variant="outline"
794
- size="md"
795
- disabled={page <= 0}
796
- onClick={() => setPage((p) => Math.max(0, p - 1))}
797
- >
798
- Previous
799
- </Button>
800
- <Button
801
- variant="outline"
802
- size="md"
803
- disabled={page >= pageCount - 1}
804
- onClick={() => setPage((p) => Math.min(pageCount - 1, p + 1))}
805
- >
806
- Next
807
- </Button>
839
+ <div className="flex items-center gap-1">
840
+ <Button
841
+ variant="secondary"
842
+ size="icon"
843
+ aria-label="Previous page"
844
+ disabled={page <= 0}
845
+ onClick={() => setPage((p) => Math.max(0, p - 1))}
846
+ >
847
+ <CaretLeft />
848
+ </Button>
849
+ <Button
850
+ variant="secondary"
851
+ size="icon"
852
+ aria-label="Next page"
853
+ disabled={page >= pageCount - 1}
854
+ onClick={() => setPage((p) => Math.min(pageCount - 1, p + 1))}
855
+ >
856
+ <CaretRight />
857
+ </Button>
858
+ </div>
808
859
  </div>
809
860
  </div>
810
861
  )}
package/src/table.tsx CHANGED
@@ -82,7 +82,7 @@ export function TableCell({ className, ...props }: TdHTMLAttributes<HTMLTableCel
82
82
  return (
83
83
  <td
84
84
  className={cn(
85
- "px-(--table-pad-x) py-3 align-middle text-sm text-table-row data-numeric:font-numeric data-numeric:tabular-nums",
85
+ "whitespace-nowrap px-(--table-pad-x) py-3 align-middle text-sm text-table-row data-numeric:font-numeric data-numeric:tabular-nums",
86
86
  className,
87
87
  )}
88
88
  {...props}