@jinaan/uikit 0.1.32 → 0.1.35

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.32",
3
+ "version": "0.1.35",
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,44 @@ 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 [columnWidths, setColumnWidths] = useState<number[]>([]);
357
+ // Deps must be primitives: visibleColumns is a fresh array every render and
358
+ // would re-run this effect (and its setState) in a loop.
359
+ const columnKey = visibleColumns.map((c) => c.id).join("\u0000");
360
+
361
+ useLayoutEffect(() => {
362
+ const table = bodyTableRef.current;
363
+ if (!table) return;
364
+ const measure = () => {
365
+ const cells = table.tBodies[0]?.rows.item(0)?.cells;
366
+ if (!cells || cells.length === 0) {
367
+ setColumnWidths((prev) => (prev.length === 0 ? prev : []));
368
+ return;
369
+ }
370
+ const widths = Array.from(cells, (cell) => Math.round(cell.getBoundingClientRect().width));
371
+ // Bail out when nothing changed: a new array would re-render, and the
372
+ // re-render would re-run this effect, looping forever.
373
+ setColumnWidths((prev) =>
374
+ prev.length === widths.length && prev.every((w, i) => w === widths[i]) ? prev : widths,
375
+ );
376
+ };
377
+ measure();
378
+ if (typeof ResizeObserver === "undefined") return;
379
+ const observer = new ResizeObserver(measure);
380
+ observer.observe(table);
381
+ return () => observer.disconnect();
382
+ }, [columnKey, selectable, rowActions, loading, error]);
383
+
384
+ const totalWidth = columnWidths.reduce((sum, width) => sum + width, 0);
385
+
353
386
  return (
354
387
  <div className={cn("space-y-4", className)}>
355
388
  {tabs && tabs.length > 0 && (
@@ -616,12 +649,20 @@ export function DataTable<T>({
616
649
  headerClassName={headerClassName}
617
650
  panelClassName={panelClassName}
618
651
  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">
652
+ // Visible header band. Presentational (aria-sort lives on the body's
653
+ // sr-only thead); widths are pinned to the measured body columns and
654
+ // hidden until the first measurement lands so columns never flash.
655
+ <table
656
+ role="presentation"
657
+ className="w-full table-fixed border-collapse"
658
+ style={{
659
+ width: totalWidth > 0 ? totalWidth : undefined,
660
+ visibility: totalWidth > 0 ? undefined : "hidden",
661
+ }}
662
+ >
622
663
  <colgroup>
623
- {colWidths.map((w, i) => (
624
- <col key={i} style={{ width: w }} />
664
+ {columnWidths.map((width, i) => (
665
+ <col key={i} style={{ width }} />
625
666
  ))}
626
667
  </colgroup>
627
668
  <thead>
@@ -711,13 +752,8 @@ export function DataTable<T>({
711
752
  action={empty?.action}
712
753
  />
713
754
  ) : (
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). */}
755
+ <table ref={bodyTableRef} className="w-full table-auto border-collapse text-left">
756
+ {/* Semantic headers for assistive tech; the visible band is presentational. */}
721
757
  <thead className="sr-only">
722
758
  <tr>
723
759
  {selectable && <th scope="col">Select</th>}
@@ -771,40 +807,44 @@ export function DataTable<T>({
771
807
  )}
772
808
  </TableSurface>
773
809
 
774
- {!loading && !error && sorted.length > 0 && (
810
+ {showPagination && (
775
811
  <div className="flex flex-wrap items-center justify-between gap-3 text-sm">
776
812
  <span className="text-muted">
777
813
  {start + 1}–{Math.min(start + pageSize, sorted.length)} of {sorted.length}
778
814
  </span>
779
815
  <div className="flex items-center gap-2">
780
816
  <Select value={String(pageSize)} onValueChange={(v) => setPageSize(Number(v))}>
781
- <SelectTrigger aria-label="Rows per page" className="w-27.5">
817
+ <SelectTrigger aria-label="Rows per page" className="w-27.5" variant="inline">
782
818
  <SelectValue />
783
819
  </SelectTrigger>
784
820
  <SelectContent>
785
821
  {pageSizeOptions.map((n) => (
786
822
  <SelectItem key={n} value={String(n)}>
787
- {n} / page
823
+ {n}
788
824
  </SelectItem>
789
825
  ))}
790
826
  </SelectContent>
791
827
  </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>
828
+ <div className="flex items-center gap-1">
829
+ <Button
830
+ variant="secondary"
831
+ size="icon"
832
+ aria-label="Previous page"
833
+ disabled={page <= 0}
834
+ onClick={() => setPage((p) => Math.max(0, p - 1))}
835
+ >
836
+ <CaretLeft />
837
+ </Button>
838
+ <Button
839
+ variant="secondary"
840
+ size="icon"
841
+ aria-label="Next page"
842
+ disabled={page >= pageCount - 1}
843
+ onClick={() => setPage((p) => Math.min(pageCount - 1, p + 1))}
844
+ >
845
+ <CaretRight />
846
+ </Button>
847
+ </div>
808
848
  </div>
809
849
  </div>
810
850
  )}
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}