@payglocal_ui/flux-ui 0.2.4 → 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.4",
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;
@@ -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);
@@ -189,6 +259,23 @@ export function DataTable<T>({
189
259
  : compact
190
260
  ? "text-[11px] font-semibold text-muted-foreground"
191
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
+
192
279
  // Action overlay geometry: the action floats this many px in from the right
193
280
  // edge of the viewport (it has no reserved column — it overlays the row).
194
281
  const actionGutter = comfortable ? 20 : compact ? 12 : 16;
@@ -246,6 +333,7 @@ export function DataTable<T>({
246
333
  >
247
334
  {tableLayout === "fixed" && (
248
335
  <colgroup>
336
+ {hasExpand ? <col style={{ width: 40 }} /> : null}
249
337
  {columns.map((col) => (
250
338
  <col
251
339
  key={col.key}
@@ -275,6 +363,7 @@ export function DataTable<T>({
275
363
  headerStyle === "surface" ? "border-border" : "border-border/70"
276
364
  )}
277
365
  >
366
+ {hasExpand ? <th className={cn(headPad, "w-10 p-0")} aria-hidden /> : null}
278
367
  {columns.map((col) => (
279
368
  <th
280
369
  key={col.key}
@@ -305,19 +394,19 @@ export function DataTable<T>({
305
394
  Array.from({ length: skeletonRows }).map((_, i) => (
306
395
  <TableRowSkeleton
307
396
  key={i}
308
- cols={columns.length}
397
+ cols={columns.length + (hasExpand ? 1 : 0)}
309
398
  density={density}
310
399
  snug={snug}
311
400
  />
312
401
  ))
313
402
  ) : paginated.length === 0 ? (
314
403
  <tr>
315
- <td colSpan={columns.length + (hasSpacer ? 1 : 0) + (hasAction ? 1 : 0)}>
404
+ <td colSpan={totalColSpan}>
316
405
  <EmptyState title={emptyTitle} description={emptyDescription} />
317
406
  </td>
318
407
  </tr>
319
408
  ) : (
320
- paginated.map((row, i) => (
409
+ paginated.flatMap((row, i) => [
321
410
  <tr
322
411
  key={rowKeys[i]}
323
412
  className={cn(
@@ -331,7 +420,14 @@ export function DataTable<T>({
331
420
  // when reached by keyboard. `focus-visible` only, so a
332
421
  // mouse click does not leave a ring behind on the row.
333
422
  onRowClick &&
334
- "cursor-pointer focus-visible:outline-none focus-visible:bg-muted/40 dark:focus-visible:bg-muted/25"
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"
335
431
  )}
336
432
  // Keyboard parity with the mouse: the row is reachable by
337
433
  // Tab and activated by Enter / Space, which a bare <tr> with
@@ -360,6 +456,26 @@ export function DataTable<T>({
360
456
  : undefined
361
457
  }
362
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}
363
479
  {columns.map((col) => (
364
480
  <td
365
481
  key={col.key}
@@ -430,8 +546,38 @@ export function DataTable<T>({
430
546
  </span>
431
547
  </td>
432
548
  ) : null}
433
- </tr>
434
- ))
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
+ ])
435
581
  )}
436
582
  </tbody>
437
583
  </table>
@@ -442,32 +588,38 @@ export function DataTable<T>({
442
588
  className={cn(
443
589
  "flex items-center gap-4 flex-wrap border-t border-border",
444
590
  footerPad,
445
- footerSummary === "count" && totalPages <= 1
591
+ footerSummary === "count" && totalPages <= 1 && !footerLeading
446
592
  ? "justify-start"
447
593
  : "justify-between"
448
594
  )}
449
595
  >
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
- )}
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>
471
623
 
472
624
  {(footerSummary === "range" || footerSummary === "count") &&
473
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