@payglocal_ui/flux-ui 0.2.3 → 0.2.5

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.3",
3
+ "version": "0.2.5",
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",
@@ -4,7 +4,7 @@ import type { ReactNode } from "react";
4
4
  import { cn } from "./utils";
5
5
  import { TableRowSkeleton } from "./skeleton";
6
6
  import { EmptyState } from "./empty-state";
7
- import { ChevronLeft, ChevronRight } from "lucide-react";
7
+ import { ChevronDown, ChevronLeft, ChevronRight } from "lucide-react";
8
8
  import { useState } from "react";
9
9
 
10
10
  export type DataTableDensity = "default" | "comfortable" | "compact";
@@ -24,6 +24,38 @@ function getPageRange(current: number, total: number): (number | "…")[] {
24
24
  return pages;
25
25
  }
26
26
 
27
+ /**
28
+ * Row expansion: a disclosure column plus a full-width panel rendered directly
29
+ * beneath the expanded row. Use it when the detail belongs *with* the row in
30
+ * the flow of the table (a request's headers, a payload, a breakdown) rather
31
+ * than in a drawer that covers it.
32
+ *
33
+ * Leave `expandedKeys` unset for uncontrolled behaviour (the table remembers
34
+ * which rows are open). Pass `expandedKeys` + `onExpandedChange` to drive it
35
+ * from outside — needed when opening a row triggers a fetch.
36
+ */
37
+ export type DataTableExpandable<T> = {
38
+ /** The panel shown under an expanded row. */
39
+ render: (row: T, index: number) => ReactNode;
40
+ /**
41
+ * Which rows can open at all. Rows that cannot get no toggle and no chevron,
42
+ * keeping the column's width without implying an affordance that isn't there.
43
+ * Defaults to every row.
44
+ */
45
+ isExpandable?: (row: T, index: number) => boolean;
46
+ /** Controlled open rows, as `rowKey` values. Omit for uncontrolled. */
47
+ expandedKeys?: string[];
48
+ /** Fires on every open/close in controlled mode. */
49
+ onExpandedChange?: (keys: string[]) => void;
50
+ /**
51
+ * Fires only when a row opens, in both modes — the hook for lazily fetching
52
+ * that row's detail. Not called on close.
53
+ */
54
+ onExpand?: (row: T, index: number) => void;
55
+ /** Accessible name for the toggle. Default "Toggle row details". */
56
+ toggleLabel?: string;
57
+ };
58
+
27
59
  export type Column<T> = {
28
60
  key: string;
29
61
  header: ReactNode;
@@ -72,6 +104,22 @@ interface DataTableProps<T> {
72
104
  * flush with nothing trailing it. Takes precedence over `rowCta`.
73
105
  */
74
106
  rowAction?: ReactNode | ((row: T, index: number) => ReactNode);
107
+ /**
108
+ * Makes the whole row a click target — the row itself opens a drawer, a
109
+ * detail page, whatever the table drills into — instead of that living in a
110
+ * per-cell wrapper or a hover-revealed button.
111
+ *
112
+ * The handler sits on the `<tr>`, so the entire row including cell padding
113
+ * and the empty space between columns is clickable, and the row gets
114
+ * `cursor-pointer` plus keyboard access (focusable, Enter / Space).
115
+ *
116
+ * Clicks that originate inside something interactive — a `<button>`, `<a>`,
117
+ * a form control, a Radix trigger, or anything marked
118
+ * `data-row-click-ignore` — do NOT fire this. Copy buttons, per-row menus
119
+ * and the `rowAction` overlay therefore keep doing only their own job
120
+ * without each having to stop propagation.
121
+ */
122
+ onRowClick?: (row: T, index: number) => void;
75
123
  /** Row / cell vertical rhythm and horizontal gutters */
76
124
  density?: DataTableDensity;
77
125
  /**
@@ -93,6 +141,17 @@ interface DataTableProps<T> {
93
141
  footerCountLabels?: { singular: string; plural: string };
94
142
  /** With `density="compact"`, use tighter cell gutters (`pl-1.5 pr-2.5` vs `px-3`). Footer keeps normal horizontal padding. */
95
143
  snug?: boolean;
144
+ /**
145
+ * Extra control at the far left of the built-in footer, before the
146
+ * "Showing x–y of N" summary — a rows-per-page picker, typically.
147
+ *
148
+ * Without it a grid that needs a page-size control has to abandon the
149
+ * built-in footer and hand-roll one, which is how two different pagers end up
150
+ * in the same app.
151
+ */
152
+ footerLeading?: ReactNode;
153
+ /** Per-row disclosure panel rendered beneath the row. See `DataTableExpandable`. */
154
+ expandable?: DataTableExpandable<T>;
96
155
  }
97
156
 
98
157
  export function DataTable<T>({
@@ -110,6 +169,7 @@ export function DataTable<T>({
110
169
  rowKey,
111
170
  rowCta,
112
171
  rowAction,
172
+ onRowClick,
113
173
  density = "default",
114
174
  tableLayout = "fixed",
115
175
  theadClassName,
@@ -117,6 +177,8 @@ export function DataTable<T>({
117
177
  footerSummary = "range",
118
178
  footerCountLabels = { singular: "item", plural: "items" },
119
179
  snug = false,
180
+ expandable,
181
+ footerLeading,
120
182
  }: DataTableProps<T>) {
121
183
  const isControlled = controlledPage !== undefined;
122
184
  const [internalPage, setInternalPage] = useState(1);
@@ -136,6 +198,31 @@ export function DataTable<T>({
136
198
  // width while the table still spans the full container width.
137
199
  const hasSpacer = tableLayout === "content";
138
200
 
201
+ // Row expansion. Uncontrolled by default; `expandedKeys` hands control to the
202
+ // caller, which is what a row whose panel fetches its own data needs.
203
+ // `onExpand` fires only on open, in both modes.
204
+ const [internalExpanded, setInternalExpanded] = useState<string[]>([]);
205
+ const isExpandControlled = expandable?.expandedKeys !== undefined;
206
+ const expandedKeys = isExpandControlled ? expandable!.expandedKeys! : internalExpanded;
207
+ const hasExpand = expandable != null;
208
+ /** Every column the expansion panel has to span. */
209
+ const totalColSpan =
210
+ columns.length + (hasExpand ? 1 : 0) + (hasSpacer ? 1 : 0) + (hasAction ? 1 : 0);
211
+
212
+ /** Whether this row is open — drives both its own styling and the panel. */
213
+ const rowExpanded = (row: T, index: number) =>
214
+ hasExpand &&
215
+ (expandable!.isExpandable?.(row, index) ?? true) &&
216
+ expandedKeys.includes(rowKeys[index]);
217
+
218
+ const toggleExpanded = (key: string, row: T, index: number) => {
219
+ const isOpen = expandedKeys.includes(key);
220
+ const next = isOpen ? expandedKeys.filter((k) => k !== key) : [...expandedKeys, key];
221
+ if (isExpandControlled) expandable!.onExpandedChange?.(next);
222
+ else setInternalExpanded(next);
223
+ if (!isOpen) expandable!.onExpand?.(row, index);
224
+ };
225
+
139
226
  const total = totalRows ?? data.length;
140
227
  const totalPages = Math.ceil(total / pageSize);
141
228
  const paginated = isControlled ? data : data.slice((page - 1) * pageSize, page * pageSize);
@@ -172,10 +259,51 @@ export function DataTable<T>({
172
259
  : compact
173
260
  ? "text-[11px] font-semibold text-muted-foreground"
174
261
  : "text-[11px] font-semibold text-foreground/75 dark:text-foreground/85";
262
+ /**
263
+ * Expansion geometry, in px, derived from the same padding the cells use:
264
+ *
265
+ * - `expandIndent` lines the panel's content up with the first DATA column
266
+ * (past the 40px disclosure column), so the detail reads as hanging off the
267
+ * row rather than starting outside it.
268
+ * - `expandGuideLeft` is the centre of the chevron, where the vertical
269
+ * connector runs — the cue that the panel belongs to the row above.
270
+ *
271
+ * Both live here rather than in each consumer's panel: a hardcoded indent in
272
+ * a feature silently drifts the moment this padding or the column width
273
+ * changes.
274
+ */
275
+ const cellPadLeft = comfortable ? 20 : compact ? (snug ? 6 : 12) : 16;
276
+ const expandIndent = 40 + cellPadLeft;
277
+ const expandGuideLeft = cellPadLeft + 10;
278
+
175
279
  // Action overlay geometry: the action floats this many px in from the right
176
280
  // edge of the viewport (it has no reserved column — it overlays the row).
177
281
  const actionGutter = comfortable ? 20 : compact ? 12 : 16;
178
282
 
283
+ /**
284
+ * Selector for everything a row-level click must keep its hands off. A click
285
+ * landing inside one of these belongs to that control alone — a copy button,
286
+ * a per-row menu, a link, a checkbox, the `rowAction` overlay — so the row
287
+ * handler ignores it rather than firing as well.
288
+ *
289
+ * `data-row-click-ignore` is the escape hatch for anything not covered here
290
+ * (a custom widget in a cell, a drag handle) without it needing to stop
291
+ * propagation itself.
292
+ */
293
+ const ROW_CLICK_IGNORE =
294
+ 'button, a, input, select, textarea, label, [role="button"], [role="link"], ' +
295
+ '[role="checkbox"], [role="menuitem"], [role="menu"], [role="dialog"], ' +
296
+ "[data-row-click-ignore]";
297
+
298
+ /** Whether a click/keypress inside a row should reach `onRowClick`. */
299
+ const isRowClickTarget = (target: EventTarget | null, rowEl: HTMLElement) => {
300
+ if (!(target instanceof Element)) return false;
301
+ const interactive = target.closest(ROW_CLICK_IGNORE);
302
+ // `closest` can walk out of the row entirely (a portalled menu, say); only
303
+ // a match inside THIS row means the click belonged to that control.
304
+ return !(interactive && rowEl.contains(interactive));
305
+ };
306
+
179
307
  return (
180
308
  <div
181
309
  className={cn(
@@ -205,6 +333,7 @@ export function DataTable<T>({
205
333
  >
206
334
  {tableLayout === "fixed" && (
207
335
  <colgroup>
336
+ {hasExpand ? <col style={{ width: 40 }} /> : null}
208
337
  {columns.map((col) => (
209
338
  <col
210
339
  key={col.key}
@@ -234,6 +363,7 @@ export function DataTable<T>({
234
363
  headerStyle === "surface" ? "border-border" : "border-border/70"
235
364
  )}
236
365
  >
366
+ {hasExpand ? <th className={cn(headPad, "w-10 p-0")} aria-hidden /> : null}
237
367
  {columns.map((col) => (
238
368
  <th
239
369
  key={col.key}
@@ -264,19 +394,19 @@ export function DataTable<T>({
264
394
  Array.from({ length: skeletonRows }).map((_, i) => (
265
395
  <TableRowSkeleton
266
396
  key={i}
267
- cols={columns.length}
397
+ cols={columns.length + (hasExpand ? 1 : 0)}
268
398
  density={density}
269
399
  snug={snug}
270
400
  />
271
401
  ))
272
402
  ) : paginated.length === 0 ? (
273
403
  <tr>
274
- <td colSpan={columns.length + (hasSpacer ? 1 : 0) + (hasAction ? 1 : 0)}>
404
+ <td colSpan={totalColSpan}>
275
405
  <EmptyState title={emptyTitle} description={emptyDescription} />
276
406
  </td>
277
407
  </tr>
278
408
  ) : (
279
- paginated.map((row, i) => (
409
+ paginated.flatMap((row, i) => [
280
410
  <tr
281
411
  key={rowKeys[i]}
282
412
  className={cn(
@@ -285,9 +415,67 @@ export function DataTable<T>({
285
415
  compact && "min-h-[44px]",
286
416
  "hover:bg-muted/40 dark:hover:bg-muted/25",
287
417
  hasAction &&
288
- "hover:shadow-[0_1px_0_rgba(0,0,0,0.04)] dark:hover:shadow-none"
418
+ "hover:shadow-[0_1px_0_rgba(0,0,0,0.04)] dark:hover:shadow-none",
419
+ // Clickable rows read as clickable, and show a focus ring
420
+ // when reached by keyboard. `focus-visible` only, so a
421
+ // mouse click does not leave a ring behind on the row.
422
+ onRowClick &&
423
+ "cursor-pointer focus-visible:outline-none focus-visible:bg-muted/40 dark:focus-visible:bg-muted/25",
424
+ // An open row takes its panel's background and drops the
425
+ // divider beneath it, so the row and its detail read as one
426
+ // block. Hover is pinned to the same value, or moving the
427
+ // mouse over an open row would make it flicker away from
428
+ // the panel it belongs to.
429
+ rowExpanded(row, i) &&
430
+ "border-b-0 bg-muted/40 hover:bg-muted/40 dark:bg-muted/25 dark:hover:bg-muted/25"
289
431
  )}
432
+ // Keyboard parity with the mouse: the row is reachable by
433
+ // Tab and activated by Enter / Space, which a bare <tr> with
434
+ // an onClick would not be. No role override — the row stays a
435
+ // row for assistive tech rather than claiming to be a button.
436
+ tabIndex={onRowClick ? 0 : undefined}
437
+ onClick={
438
+ onRowClick
439
+ ? (e) => {
440
+ if (!isRowClickTarget(e.target, e.currentTarget)) return;
441
+ onRowClick(row, i);
442
+ }
443
+ : undefined
444
+ }
445
+ onKeyDown={
446
+ onRowClick
447
+ ? (e) => {
448
+ if (e.key !== "Enter" && e.key !== " ") return;
449
+ // Only the row's own focus activates it; a keypress
450
+ // inside a control in the row belongs to that control.
451
+ if (e.target !== e.currentTarget) return;
452
+ // Space scrolls the page by default.
453
+ e.preventDefault();
454
+ onRowClick(row, i);
455
+ }
456
+ : undefined
457
+ }
290
458
  >
459
+ {hasExpand ? (
460
+ <td className={cn(cellPad, "w-10 align-middle")}>
461
+ {(expandable!.isExpandable?.(row, i) ?? true) ? (
462
+ <button
463
+ type="button"
464
+ aria-expanded={rowExpanded(row, i)}
465
+ aria-label={expandable!.toggleLabel ?? "Toggle row details"}
466
+ onClick={() => toggleExpanded(rowKeys[i], row, i)}
467
+ className="inline-flex h-5 w-5 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
468
+ >
469
+ <ChevronDown
470
+ className={cn(
471
+ "h-3.5 w-3.5 transition-transform duration-150",
472
+ rowExpanded(row, i) && "rotate-180"
473
+ )}
474
+ />
475
+ </button>
476
+ ) : null}
477
+ </td>
478
+ ) : null}
291
479
  {columns.map((col) => (
292
480
  <td
293
481
  key={col.key}
@@ -358,8 +546,38 @@ export function DataTable<T>({
358
546
  </span>
359
547
  </td>
360
548
  ) : null}
361
- </tr>
362
- ))
549
+ </tr>,
550
+
551
+ // The panel spans every column, including the toggle, spacer and
552
+ // action cells, so it reads as one band under its row rather
553
+ // than as a cell inside the grid.
554
+ rowExpanded(row, i) ? (
555
+ <tr
556
+ key={`${rowKeys[i]}__panel`}
557
+ className="border-b border-border/60 bg-muted/40 last:border-b-0 dark:bg-muted/25"
558
+ >
559
+ <td colSpan={totalColSpan} className="p-0 align-top">
560
+ <div
561
+ className="relative"
562
+ style={{ paddingLeft: expandIndent, paddingRight: cellPadLeft }}
563
+ >
564
+ {/* Connector: a hairline dropping from the chevron down
565
+ the panel. The row and its detail share a background
566
+ so they read as one block, which on its own leaves
567
+ nothing to say the lower half is derived rather than
568
+ more row content — this is that cue. Stops short of
569
+ the bottom so it reads as hanging, not as a border. */}
570
+ <span
571
+ aria-hidden
572
+ className="absolute top-0 bottom-4 w-px bg-border"
573
+ style={{ left: expandGuideLeft }}
574
+ />
575
+ {expandable!.render(row, i)}
576
+ </div>
577
+ </td>
578
+ </tr>
579
+ ) : null,
580
+ ])
363
581
  )}
364
582
  </tbody>
365
583
  </table>
@@ -370,32 +588,38 @@ export function DataTable<T>({
370
588
  className={cn(
371
589
  "flex items-center gap-4 flex-wrap border-t border-border",
372
590
  footerPad,
373
- footerSummary === "count" && totalPages <= 1
591
+ footerSummary === "count" && totalPages <= 1 && !footerLeading
374
592
  ? "justify-start"
375
593
  : "justify-between"
376
594
  )}
377
595
  >
378
- {footerSummary === "count" ? (
379
- <span className="text-[12px] text-muted-foreground tabular-nums">
380
- <span className="font-medium text-foreground">{total}</span>{" "}
381
- {total === 1
382
- ? footerCountLabels.singular
383
- : footerCountLabels.plural}
384
- </span>
385
- ) : (
386
- <span className="text-[12px] text-muted-foreground tabular-nums">
387
- Showing{" "}
388
- <span className="text-foreground font-medium">
389
- {Math.min((page - 1) * pageSize + 1, total)}–
390
- {Math.min(page * pageSize, total)}
391
- </span>{" "}
392
- of{" "}
393
- <span className="text-foreground font-medium">
394
- {total.toLocaleString()}
395
- </span>{" "}
396
- {total !== 1 ? "results" : "result"}
397
- </span>
398
- )}
596
+ {/* `footerLeading` (a rows-per-page picker, typically) groups with the
597
+ summary on the left rather than becoming a third item the
598
+ justify-between would fling to its own corner. */}
599
+ <div className="flex items-center gap-3">
600
+ {footerLeading}
601
+ {footerSummary === "count" ? (
602
+ <span className="text-[12px] text-muted-foreground tabular-nums">
603
+ <span className="font-medium text-foreground">{total}</span>{" "}
604
+ {total === 1
605
+ ? footerCountLabels.singular
606
+ : footerCountLabels.plural}
607
+ </span>
608
+ ) : (
609
+ <span className="text-[12px] text-muted-foreground tabular-nums">
610
+ Showing{" "}
611
+ <span className="text-foreground font-medium">
612
+ {Math.min((page - 1) * pageSize + 1, total)}
613
+ {Math.min(page * pageSize, total)}
614
+ </span>{" "}
615
+ of{" "}
616
+ <span className="text-foreground font-medium">
617
+ {total.toLocaleString()}
618
+ </span>{" "}
619
+ {total !== 1 ? "results" : "result"}
620
+ </span>
621
+ )}
622
+ </div>
399
623
 
400
624
  {(footerSummary === "range" || footerSummary === "count") &&
401
625
  totalPages > 1 && (
@@ -74,7 +74,10 @@ const DropdownMenuItem = React.forwardRef<
74
74
  <DropdownMenuPrimitive.Item
75
75
  ref={ref}
76
76
  className={cn(
77
- "relative flex cursor-default select-none items-center gap-2 rounded-lg px-3 py-2.5 text-[15px] outline-none transition-colors",
77
+ // 13px / tighter padding: a menu drops out of a toolbar button or a
78
+ // compact table control, and at 15px with 10px vertical padding it read
79
+ // as a larger, separate UI than the control that opened it.
80
+ "relative flex cursor-default select-none items-center gap-2 rounded-lg px-2.5 py-1.5 text-[13px] outline-none transition-colors",
78
81
  "focus:bg-muted focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
79
82
  inset && "pl-8",
80
83
  className
package/src/index.ts CHANGED
@@ -194,6 +194,7 @@ export {
194
194
  SelectScrollUpButton,
195
195
  SelectScrollDownButton,
196
196
  } from "./select";
197
+ export type { SelectTriggerSize } from "./select";
197
198
  export { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem, CommandSeparator, CommandShortcut } from "./command";
198
199
  export type {
199
200
  CommandProps,
@@ -232,7 +233,7 @@ export { Shimmer, StatCardSkeleton, TableRowSkeleton, ChartSkeleton } from "./sk
232
233
 
233
234
  // Data display
234
235
  export { DataTable } from "./data-table";
235
- export type { Column, DataTableDensity, DataTableFooterSummary, DataTableHeaderStyle } from "./data-table";
236
+ export type { Column, DataTableDensity, DataTableExpandable, DataTableFooterSummary, DataTableHeaderStyle } from "./data-table";
236
237
  export { EmptyState } from "./empty-state";
237
238
  export { PageHeader } from "./page-header";
238
239
  export { Code, CodeBlock } from "./code";
package/src/select.tsx CHANGED
@@ -9,14 +9,31 @@ const Select = SelectPrimitive.Root;
9
9
  const SelectGroup = SelectPrimitive.Group;
10
10
  const SelectValue = SelectPrimitive.Value;
11
11
 
12
+ /**
13
+ * `md` (default) is the form-field trigger. `sm` is for dense furniture — a
14
+ * rows-per-page picker in a table footer, a control inside a toolbar — where
15
+ * the full-height field towers over everything beside it.
16
+ *
17
+ * This is a size prop rather than a job for `className` because the base sets
18
+ * `min-h-11`, which beats an `h-8` utility: every caller wanting a short
19
+ * trigger had to override min-height, padding, gap and text size together.
20
+ */
21
+ export type SelectTriggerSize = "sm" | "md";
22
+
23
+ const selectTriggerSizes: Record<SelectTriggerSize, string> = {
24
+ md: "h-11 min-h-11 gap-2.5 px-4 py-2 text-[15px]",
25
+ sm: "h-7 min-h-7 w-auto gap-1 px-2 py-0 text-[12px]",
26
+ };
27
+
12
28
  const SelectTrigger = React.forwardRef<
13
29
  React.ElementRef<typeof SelectPrimitive.Trigger>,
14
- React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
15
- >(({ className, children, ...props }, ref) => (
30
+ React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> & { size?: SelectTriggerSize }
31
+ >(({ className, children, size = "md", ...props }, ref) => (
16
32
  <SelectPrimitive.Trigger
17
33
  ref={ref}
18
34
  className={cn(
19
- "flex h-11 min-h-11 w-full items-center justify-between gap-2.5 rounded-lg border border-border bg-card px-4 py-2 text-[15px] text-foreground shadow-sm outline-none",
35
+ "flex w-full items-center justify-between rounded-lg border border-border bg-card text-foreground shadow-sm outline-none",
36
+ selectTriggerSizes[size],
20
37
  "ring-ring/50 focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-50",
21
38
  "[&>span]:line-clamp-1",
22
39
  className