@payglocal_ui/flux-ui 0.2.0 → 0.2.2

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": "@payglocal_ui/flux-ui",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Flux UI primitives — inputs, fields, dialog, data table, charts, calendar, and more (Tailwind v4 + Radix).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -67,6 +67,7 @@
67
67
  "devDependencies": {
68
68
  "@types/react": "^19",
69
69
  "@types/react-dom": "^19",
70
+ "esbuild": "^0.28.1",
70
71
  "tsup": "^8.5.1",
71
72
  "typescript": "^5"
72
73
  },
@@ -60,10 +60,31 @@ interface DataTableProps<T> {
60
60
  label: string;
61
61
  onClick?: (row: T) => void;
62
62
  };
63
+ /**
64
+ * Custom action revealed on row hover — typically a `<Button>` or
65
+ * `<ButtonGroup>`, but any `ReactNode` is accepted. Pass a **function** to
66
+ * render per-row: it receives `(row, index)`, so the action always has the
67
+ * record for its row (e.g. to navigate or open a drawer for that row).
68
+ *
69
+ * It is not a real column: it floats as an overlay **pinned to the right edge
70
+ * of the viewport**, so it stays in view as the table scrolls horizontally
71
+ * (no scrolling to the end to reach it) while the last data column stays
72
+ * flush with nothing trailing it. Takes precedence over `rowCta`.
73
+ */
74
+ rowAction?: ReactNode | ((row: T, index: number) => ReactNode);
63
75
  /** Row / cell vertical rhythm and horizontal gutters */
64
76
  density?: DataTableDensity;
65
- /** `auto` lets columns breathe; `fixed` uses `colgroup` hints */
66
- tableLayout?: "auto" | "fixed";
77
+ /**
78
+ * Column-sizing strategy:
79
+ * - `fixed` — widths come only from `colgroup` hints (`width`/`minWidth`/
80
+ * `maxWidth`); content is ignored and overflow is clipped. Table fills 100%.
81
+ * - `auto` — columns size to content but the table still fills 100%, so any
82
+ * leftover space is distributed into the columns (they stretch).
83
+ * - `content` — columns size to their content's intrinsic width and the table
84
+ * shrinks to fit. Leftover space stays empty to the right of the last
85
+ * column; it scrolls horizontally only once content exceeds the container.
86
+ */
87
+ tableLayout?: "auto" | "fixed" | "content";
67
88
  theadClassName?: string;
68
89
  headerStyle?: DataTableHeaderStyle;
69
90
  /** Footer: paginated range vs simple `n items` */
@@ -88,6 +109,7 @@ export function DataTable<T>({
88
109
  className,
89
110
  rowKey,
90
111
  rowCta,
112
+ rowAction,
91
113
  density = "default",
92
114
  tableLayout = "fixed",
93
115
  theadClassName,
@@ -103,10 +125,33 @@ export function DataTable<T>({
103
125
  ? (p: number) => onPageChange?.(p)
104
126
  : (p: number) => setInternalPage(p);
105
127
 
128
+ // The right-pinned row action. `rowAction` takes precedence over `rowCta`.
129
+ // It is NOT a real column: it renders as a per-row overlay floating at the
130
+ // right edge of the viewport (sticky), so the last data column stays flush
131
+ // and there's no reserved/empty trailing column when scrolled to the end.
132
+ const hasAction = rowAction != null || rowCta != null;
133
+
134
+ // `content` layout: a greedy, empty trailing column that absorbs leftover
135
+ // horizontal space so the data columns stay at their content (minimum)
136
+ // width while the table still spans the full container width.
137
+ const hasSpacer = tableLayout === "content";
138
+
106
139
  const total = totalRows ?? data.length;
107
140
  const totalPages = Math.ceil(total / pageSize);
108
141
  const paginated = isControlled ? data : data.slice((page - 1) * pageSize, page * pageSize);
109
142
 
143
+ // Guard against duplicate / non-unique rowKey() results. React silently fails
144
+ // to unmount old <tr> nodes when sibling keys collide, leaving stale rows
145
+ // rendered on top of new data (or the empty state). De-dupe by suffixing
146
+ // repeats so every rendered row gets a unique, stable key.
147
+ const seenKeys = new Map<string, number>();
148
+ const rowKeys = paginated.map((row) => {
149
+ const base = rowKey(row);
150
+ const seen = seenKeys.get(base) ?? 0;
151
+ seenKeys.set(base, seen + 1);
152
+ return seen === 0 ? base : `${base}__${seen}`;
153
+ });
154
+
110
155
  const comfortable = density === "comfortable";
111
156
  const compact = density === "compact";
112
157
  const compactCellPad = compact
@@ -127,7 +172,9 @@ export function DataTable<T>({
127
172
  : compact
128
173
  ? "text-[11px] font-semibold text-muted-foreground"
129
174
  : "text-[11px] font-semibold text-foreground/75 dark:text-foreground/85";
130
- const rowCtaColWidth = compact ? 108 : 130;
175
+ // Action overlay geometry: the action floats this many px in from the right
176
+ // edge of the viewport (it has no reserved column — it overlays the row).
177
+ const actionGutter = comfortable ? 20 : compact ? 12 : 16;
131
178
 
132
179
  return (
133
180
  <div
@@ -148,7 +195,13 @@ export function DataTable<T>({
148
195
  >
149
196
  <table
150
197
  className={cn(tableLayout === "auto" && "min-w-[920px]")}
151
- style={{ tableLayout, width: "100%" }}
198
+ style={{
199
+ // `content` uses the automatic algorithm but stays 100% wide; a
200
+ // greedy spacer column (below) soaks up the slack so the data
201
+ // columns collapse to their content width while the table fills.
202
+ tableLayout: tableLayout === "fixed" ? "fixed" : "auto",
203
+ width: "100%",
204
+ }}
152
205
  >
153
206
  {tableLayout === "fixed" && (
154
207
  <colgroup>
@@ -162,7 +215,9 @@ export function DataTable<T>({
162
215
  }}
163
216
  />
164
217
  ))}
165
- {rowCta ? <col style={{ width: rowCtaColWidth }} /> : null}
218
+ {/* Zero-width column: the action floats out of it as an overlay,
219
+ so the last data column stays flush and nothing trails it. */}
220
+ {hasAction ? <col style={{ width: 0 }} /> : null}
166
221
  </colgroup>
167
222
  )}
168
223
 
@@ -197,7 +252,10 @@ export function DataTable<T>({
197
252
  {col.header}
198
253
  </th>
199
254
  ))}
200
- {rowCta ? <th className={cn(headPad, "w-[1%]")} aria-hidden /> : null}
255
+ {hasSpacer ? <th className="w-full p-0" aria-hidden /> : null}
256
+ {hasAction ? (
257
+ <th className="sticky right-0 z-[1] w-0 p-0" aria-hidden />
258
+ ) : null}
201
259
  </tr>
202
260
  </thead>
203
261
 
@@ -206,27 +264,27 @@ export function DataTable<T>({
206
264
  Array.from({ length: skeletonRows }).map((_, i) => (
207
265
  <TableRowSkeleton
208
266
  key={i}
209
- cols={columns.length + (rowCta ? 1 : 0)}
267
+ cols={columns.length}
210
268
  density={density}
211
269
  snug={snug}
212
270
  />
213
271
  ))
214
272
  ) : paginated.length === 0 ? (
215
273
  <tr>
216
- <td colSpan={columns.length + (rowCta ? 1 : 0)}>
274
+ <td colSpan={columns.length + (hasSpacer ? 1 : 0) + (hasAction ? 1 : 0)}>
217
275
  <EmptyState title={emptyTitle} description={emptyDescription} />
218
276
  </td>
219
277
  </tr>
220
278
  ) : (
221
279
  paginated.map((row, i) => (
222
280
  <tr
223
- key={rowKey(row)}
281
+ key={rowKeys[i]}
224
282
  className={cn(
225
283
  "group transition-colors duration-150 border-b border-border/60 last:border-b-0",
226
284
  comfortable && "min-h-[56px]",
227
285
  compact && "min-h-[44px]",
228
286
  "hover:bg-muted/40 dark:hover:bg-muted/25",
229
- rowCta &&
287
+ hasAction &&
230
288
  "hover:shadow-[0_1px_0_rgba(0,0,0,0.04)] dark:hover:shadow-none"
231
289
  )}
232
290
  >
@@ -260,32 +318,44 @@ export function DataTable<T>({
260
318
  </td>
261
319
  ))}
262
320
 
263
- {rowCta ? (
264
- <td
265
- className={cn(
266
- cellPad,
267
- "text-left align-middle whitespace-nowrap",
268
- comfortable
269
- ? "pl-2 pr-5"
270
- : compact
271
- ? snug
272
- ? "pl-1.5 pr-2"
273
- : "pl-1.5 pr-3"
274
- : "pl-3 pr-4"
275
- )}
276
- >
277
- <button
278
- type="button"
279
- onClick={() => rowCta.onClick?.(row)}
280
- className={cn(
281
- "opacity-0 group-hover:opacity-100 transition-opacity duration-150 inline-flex items-center font-medium text-foreground bg-card rounded-lg border border-border hover:border-muted-foreground/50 whitespace-nowrap shadow-sm",
282
- compact
283
- ? "px-2.5 py-1 text-[11px]"
284
- : "px-3 py-1.5 text-[12px]"
285
- )}
321
+ {hasSpacer ? <td className="p-0" aria-hidden /> : null}
322
+
323
+ {hasAction ? (
324
+ // Zero-width sticky cell pinned to the right edge of the
325
+ // viewport. Its children are positioned absolutely so they
326
+ // float over the row (out of the 0-width cell) — the action
327
+ // stays in view while scrolling and nothing trails the last
328
+ // data column. Everything is revealed on hover only.
329
+ <td className="sticky right-0 z-[1] w-0 p-0 align-middle">
330
+ {/* The action itself — `rowAction(row, i)` so it always
331
+ receives this row's record. Vertically centered in the
332
+ row and anchored `actionGutter`px from the right edge,
333
+ overflowing left over the row content. Revealed on hover. */}
334
+ <span
335
+ className="absolute top-1/2 -translate-y-1/2 z-[1] inline-flex items-center opacity-0 transition-opacity duration-150 group-hover:opacity-100 group-focus-within:opacity-100"
336
+ style={{ right: actionGutter }}
286
337
  >
287
- {rowCta.label}
288
- </button>
338
+ {rowAction != null ? (
339
+ typeof rowAction === "function" ? (
340
+ rowAction(row, i)
341
+ ) : (
342
+ rowAction
343
+ )
344
+ ) : (
345
+ <button
346
+ type="button"
347
+ onClick={() => rowCta?.onClick?.(row)}
348
+ className={cn(
349
+ "inline-flex items-center font-medium text-foreground bg-card rounded-lg border border-border hover:border-muted-foreground/50 whitespace-nowrap shadow-sm",
350
+ compact
351
+ ? "px-2.5 py-1 text-[11px]"
352
+ : "px-3 py-1.5 text-[12px]"
353
+ )}
354
+ >
355
+ {rowCta?.label}
356
+ </button>
357
+ )}
358
+ </span>
289
359
  </td>
290
360
  ) : null}
291
361
  </tr>