@payglocal_ui/flux-ui 0.2.4 → 0.2.6

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.4",
3
+ "version": "0.2.6",
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;
@@ -109,6 +141,17 @@ interface DataTableProps<T> {
109
141
  footerCountLabels?: { singular: string; plural: string };
110
142
  /** With `density="compact"`, use tighter cell gutters (`pl-1.5 pr-2.5` vs `px-3`). Footer keeps normal horizontal padding. */
111
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>;
112
155
  }
113
156
 
114
157
  export function DataTable<T>({
@@ -134,6 +177,8 @@ export function DataTable<T>({
134
177
  footerSummary = "range",
135
178
  footerCountLabels = { singular: "item", plural: "items" },
136
179
  snug = false,
180
+ expandable,
181
+ footerLeading,
137
182
  }: DataTableProps<T>) {
138
183
  const isControlled = controlledPage !== undefined;
139
184
  const [internalPage, setInternalPage] = useState(1);
@@ -153,6 +198,31 @@ export function DataTable<T>({
153
198
  // width while the table still spans the full container width.
154
199
  const hasSpacer = tableLayout === "content";
155
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
+
156
226
  const total = totalRows ?? data.length;
157
227
  const totalPages = Math.ceil(total / pageSize);
158
228
  const paginated = isControlled ? data : data.slice((page - 1) * pageSize, page * pageSize);
@@ -169,6 +239,16 @@ export function DataTable<T>({
169
239
  return seen === 0 ? base : `${base}__${seen}`;
170
240
  });
171
241
 
242
+ /**
243
+ * No rows to show. The width hints are dropped in this state: a grid whose
244
+ * columns carry minimums (via `colgroup` in fixed layout, or `cellClassName`
245
+ * in the others) would otherwise have its HEADER row alone force the table
246
+ * past the container and raise a horizontal scrollbar — over an empty region
247
+ * with nothing to scroll to. Headers still render, at their natural width, so
248
+ * the shape of the missing data is still legible.
249
+ */
250
+ const isEmpty = !isLoading && paginated.length === 0;
251
+
172
252
  const comfortable = density === "comfortable";
173
253
  const compact = density === "compact";
174
254
  const compactCellPad = compact
@@ -189,6 +269,23 @@ export function DataTable<T>({
189
269
  : compact
190
270
  ? "text-[11px] font-semibold text-muted-foreground"
191
271
  : "text-[11px] font-semibold text-foreground/75 dark:text-foreground/85";
272
+ /**
273
+ * Expansion geometry, in px, derived from the same padding the cells use:
274
+ *
275
+ * - `expandIndent` lines the panel's content up with the first DATA column
276
+ * (past the 40px disclosure column), so the detail reads as hanging off the
277
+ * row rather than starting outside it.
278
+ * - `expandGuideLeft` is the centre of the chevron, where the vertical
279
+ * connector runs — the cue that the panel belongs to the row above.
280
+ *
281
+ * Both live here rather than in each consumer's panel: a hardcoded indent in
282
+ * a feature silently drifts the moment this padding or the column width
283
+ * changes.
284
+ */
285
+ const cellPadLeft = comfortable ? 20 : compact ? (snug ? 6 : 12) : 16;
286
+ const expandIndent = 40 + cellPadLeft;
287
+ const expandGuideLeft = cellPadLeft + 10;
288
+
192
289
  // Action overlay geometry: the action floats this many px in from the right
193
290
  // edge of the viewport (it has no reserved column — it overlays the row).
194
291
  const actionGutter = comfortable ? 20 : compact ? 12 : 16;
@@ -244,8 +341,9 @@ export function DataTable<T>({
244
341
  width: "100%",
245
342
  }}
246
343
  >
247
- {tableLayout === "fixed" && (
344
+ {tableLayout === "fixed" && !isEmpty && (
248
345
  <colgroup>
346
+ {hasExpand ? <col style={{ width: 40 }} /> : null}
249
347
  {columns.map((col) => (
250
348
  <col
251
349
  key={col.key}
@@ -275,6 +373,7 @@ export function DataTable<T>({
275
373
  headerStyle === "surface" ? "border-border" : "border-border/70"
276
374
  )}
277
375
  >
376
+ {hasExpand ? <th className={cn(headPad, "w-10 p-0")} aria-hidden /> : null}
278
377
  {columns.map((col) => (
279
378
  <th
280
379
  key={col.key}
@@ -287,7 +386,8 @@ export function DataTable<T>({
287
386
  : col.align === "center"
288
387
  ? "text-center"
289
388
  : "text-left",
290
- col.cellClassName
389
+ // Width hints live in `cellClassName`; see `isEmpty`.
390
+ !isEmpty && col.cellClassName
291
391
  )}
292
392
  >
293
393
  {col.header}
@@ -305,19 +405,19 @@ export function DataTable<T>({
305
405
  Array.from({ length: skeletonRows }).map((_, i) => (
306
406
  <TableRowSkeleton
307
407
  key={i}
308
- cols={columns.length}
408
+ cols={columns.length + (hasExpand ? 1 : 0)}
309
409
  density={density}
310
410
  snug={snug}
311
411
  />
312
412
  ))
313
413
  ) : paginated.length === 0 ? (
314
414
  <tr>
315
- <td colSpan={columns.length + (hasSpacer ? 1 : 0) + (hasAction ? 1 : 0)}>
415
+ <td colSpan={totalColSpan}>
316
416
  <EmptyState title={emptyTitle} description={emptyDescription} />
317
417
  </td>
318
418
  </tr>
319
419
  ) : (
320
- paginated.map((row, i) => (
420
+ paginated.flatMap((row, i) => [
321
421
  <tr
322
422
  key={rowKeys[i]}
323
423
  className={cn(
@@ -331,7 +431,14 @@ export function DataTable<T>({
331
431
  // when reached by keyboard. `focus-visible` only, so a
332
432
  // mouse click does not leave a ring behind on the row.
333
433
  onRowClick &&
334
- "cursor-pointer focus-visible:outline-none focus-visible:bg-muted/40 dark:focus-visible:bg-muted/25"
434
+ "cursor-pointer focus-visible:outline-none focus-visible:bg-muted/40 dark:focus-visible:bg-muted/25",
435
+ // An open row takes its panel's background and drops the
436
+ // divider beneath it, so the row and its detail read as one
437
+ // block. Hover is pinned to the same value, or moving the
438
+ // mouse over an open row would make it flicker away from
439
+ // the panel it belongs to.
440
+ rowExpanded(row, i) &&
441
+ "border-b-0 bg-muted/40 hover:bg-muted/40 dark:bg-muted/25 dark:hover:bg-muted/25"
335
442
  )}
336
443
  // Keyboard parity with the mouse: the row is reachable by
337
444
  // Tab and activated by Enter / Space, which a bare <tr> with
@@ -360,6 +467,26 @@ export function DataTable<T>({
360
467
  : undefined
361
468
  }
362
469
  >
470
+ {hasExpand ? (
471
+ <td className={cn(cellPad, "w-10 align-middle")}>
472
+ {(expandable!.isExpandable?.(row, i) ?? true) ? (
473
+ <button
474
+ type="button"
475
+ aria-expanded={rowExpanded(row, i)}
476
+ aria-label={expandable!.toggleLabel ?? "Toggle row details"}
477
+ onClick={() => toggleExpanded(rowKeys[i], row, i)}
478
+ 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"
479
+ >
480
+ <ChevronDown
481
+ className={cn(
482
+ "h-3.5 w-3.5 transition-transform duration-150",
483
+ rowExpanded(row, i) && "rotate-180"
484
+ )}
485
+ />
486
+ </button>
487
+ ) : null}
488
+ </td>
489
+ ) : null}
363
490
  {columns.map((col) => (
364
491
  <td
365
492
  key={col.key}
@@ -430,8 +557,38 @@ export function DataTable<T>({
430
557
  </span>
431
558
  </td>
432
559
  ) : null}
433
- </tr>
434
- ))
560
+ </tr>,
561
+
562
+ // The panel spans every column, including the toggle, spacer and
563
+ // action cells, so it reads as one band under its row rather
564
+ // than as a cell inside the grid.
565
+ rowExpanded(row, i) ? (
566
+ <tr
567
+ key={`${rowKeys[i]}__panel`}
568
+ className="border-b border-border/60 bg-muted/40 last:border-b-0 dark:bg-muted/25"
569
+ >
570
+ <td colSpan={totalColSpan} className="p-0 align-top">
571
+ <div
572
+ className="relative"
573
+ style={{ paddingLeft: expandIndent, paddingRight: cellPadLeft }}
574
+ >
575
+ {/* Connector: a hairline dropping from the chevron down
576
+ the panel. The row and its detail share a background
577
+ so they read as one block, which on its own leaves
578
+ nothing to say the lower half is derived rather than
579
+ more row content — this is that cue. Stops short of
580
+ the bottom so it reads as hanging, not as a border. */}
581
+ <span
582
+ aria-hidden
583
+ className="absolute top-0 bottom-4 w-px bg-border"
584
+ style={{ left: expandGuideLeft }}
585
+ />
586
+ {expandable!.render(row, i)}
587
+ </div>
588
+ </td>
589
+ </tr>
590
+ ) : null,
591
+ ])
435
592
  )}
436
593
  </tbody>
437
594
  </table>
@@ -442,32 +599,38 @@ export function DataTable<T>({
442
599
  className={cn(
443
600
  "flex items-center gap-4 flex-wrap border-t border-border",
444
601
  footerPad,
445
- footerSummary === "count" && totalPages <= 1
602
+ footerSummary === "count" && totalPages <= 1 && !footerLeading
446
603
  ? "justify-start"
447
604
  : "justify-between"
448
605
  )}
449
606
  >
450
- {footerSummary === "count" ? (
451
- <span className="text-[12px] text-muted-foreground tabular-nums">
452
- <span className="font-medium text-foreground">{total}</span>{" "}
453
- {total === 1
454
- ? footerCountLabels.singular
455
- : footerCountLabels.plural}
456
- </span>
457
- ) : (
458
- <span className="text-[12px] text-muted-foreground tabular-nums">
459
- Showing{" "}
460
- <span className="text-foreground font-medium">
461
- {Math.min((page - 1) * pageSize + 1, total)}–
462
- {Math.min(page * pageSize, total)}
463
- </span>{" "}
464
- of{" "}
465
- <span className="text-foreground font-medium">
466
- {total.toLocaleString()}
467
- </span>{" "}
468
- {total !== 1 ? "results" : "result"}
469
- </span>
470
- )}
607
+ {/* `footerLeading` (a rows-per-page picker, typically) groups with the
608
+ summary on the left rather than becoming a third item the
609
+ justify-between would fling to its own corner. */}
610
+ <div className="flex items-center gap-3">
611
+ {footerLeading}
612
+ {footerSummary === "count" ? (
613
+ <span className="text-[12px] text-muted-foreground tabular-nums">
614
+ <span className="font-medium text-foreground">{total}</span>{" "}
615
+ {total === 1
616
+ ? footerCountLabels.singular
617
+ : footerCountLabels.plural}
618
+ </span>
619
+ ) : (
620
+ <span className="text-[12px] text-muted-foreground tabular-nums">
621
+ Showing{" "}
622
+ <span className="text-foreground font-medium">
623
+ {Math.min((page - 1) * pageSize + 1, total)}
624
+ {Math.min(page * pageSize, total)}
625
+ </span>{" "}
626
+ of{" "}
627
+ <span className="text-foreground font-medium">
628
+ {total.toLocaleString()}
629
+ </span>{" "}
630
+ {total !== 1 ? "results" : "result"}
631
+ </span>
632
+ )}
633
+ </div>
471
634
 
472
635
  {(footerSummary === "range" || footerSummary === "count") &&
473
636
  totalPages > 1 && (
@@ -19,7 +19,9 @@ const DropdownMenuSubTrigger = React.forwardRef<
19
19
  <DropdownMenuPrimitive.SubTrigger
20
20
  ref={ref}
21
21
  className={cn(
22
- "flex cursor-default select-none items-center gap-2 rounded-lg px-3 py-2.5 text-[15px] outline-none",
22
+ // Matches DropdownMenuItem: a submenu row sits in the same list as the
23
+ // plain rows, so it cannot be a different size from them.
24
+ "flex cursor-default select-none items-center gap-2 rounded-lg px-2.5 py-1.5 text-[13px] outline-none",
23
25
  "focus:bg-muted data-[state=open]:bg-muted",
24
26
  inset && "pl-8",
25
27
  className
@@ -74,7 +76,10 @@ const DropdownMenuItem = React.forwardRef<
74
76
  <DropdownMenuPrimitive.Item
75
77
  ref={ref}
76
78
  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",
79
+ // 13px / tighter padding: a menu drops out of a toolbar button or a
80
+ // compact table control, and at 15px with 10px vertical padding it read
81
+ // as a larger, separate UI than the control that opened it.
82
+ "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
83
  "focus:bg-muted focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
79
84
  inset && "pl-8",
80
85
  className
@@ -91,7 +96,9 @@ const DropdownMenuCheckboxItem = React.forwardRef<
91
96
  <DropdownMenuPrimitive.CheckboxItem
92
97
  ref={ref}
93
98
  className={cn(
94
- "relative flex cursor-default select-none items-center rounded-lg py-2 pl-8 pr-2 text-sm outline-none transition-colors",
99
+ // Matches DropdownMenuItem. `pl-8` stays: that gutter is the check /
100
+ // dot indicator's, not padding.
101
+ "relative flex cursor-default select-none items-center rounded-lg py-1.5 pl-8 pr-2.5 text-[13px] outline-none transition-colors",
95
102
  "focus:bg-muted focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
96
103
  className
97
104
  )}
@@ -115,7 +122,9 @@ const DropdownMenuRadioItem = React.forwardRef<
115
122
  <DropdownMenuPrimitive.RadioItem
116
123
  ref={ref}
117
124
  className={cn(
118
- "relative flex cursor-default select-none items-center rounded-lg py-2 pl-8 pr-2 text-sm outline-none transition-colors",
125
+ // Matches DropdownMenuItem. `pl-8` stays: that gutter is the check /
126
+ // dot indicator's, not padding.
127
+ "relative flex cursor-default select-none items-center rounded-lg py-1.5 pl-8 pr-2.5 text-[13px] outline-none transition-colors",
119
128
  "focus:bg-muted focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
120
129
  className
121
130
  )}
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