@vendure-io/ui 2.0.0-beta.1 → 2.0.0-beta.11

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.
@@ -0,0 +1,33 @@
1
+ 'use client';
2
+
3
+ import { createContext, type ReactNode, useContext, useMemo } from 'react';
4
+
5
+ // Function props can't cross a server→client boundary, so a copy surface
6
+ // rendered from RSC (e.g. MDX docs) could never receive an `onCopied` callback
7
+ // for toast wiring. This context is the RSC-safe alternative: mount
8
+ // CopyFeedbackProvider once in a client component (wire your toast there — the
9
+ // DS never toasts), and copy surfaces resolve their feedback in a fixed order —
10
+ // explicit prop → this context → nothing beyond the built-in copied icon.
11
+ interface CopyFeedbackContextValue {
12
+ /** Called after a successful copy. Wire your toast here — the DS never toasts. */
13
+ onCopied?: () => void;
14
+ /** Called when the clipboard write fails. */
15
+ onCopyError?: (error: Error) => void;
16
+ }
17
+
18
+ const CopyFeedbackContext = createContext<CopyFeedbackContextValue>({});
19
+
20
+ function CopyFeedbackProvider({
21
+ children,
22
+ onCopied,
23
+ onCopyError,
24
+ }: CopyFeedbackContextValue & { children: ReactNode }) {
25
+ const value = useMemo(() => ({ onCopied, onCopyError }), [onCopied, onCopyError]);
26
+ return <CopyFeedbackContext.Provider value={value}>{children}</CopyFeedbackContext.Provider>;
27
+ }
28
+
29
+ function useCopyFeedback(): CopyFeedbackContextValue {
30
+ return useContext(CopyFeedbackContext);
31
+ }
32
+
33
+ export { CopyFeedbackProvider, useCopyFeedback, type CopyFeedbackContextValue };
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import { Button } from '@vendure-io/ui/components/atoms/button';
4
+ import { useCopyFeedback } from '@vendure-io/ui/components/molecules/copy-feedback-provider';
4
5
  import { useCopy } from '@vendure-io/ui/hooks/use-copy';
5
6
  import { cn } from '@vendure-io/ui/lib/utils';
6
7
  import { CheckIcon, CopyIcon } from 'lucide-react';
@@ -11,9 +12,16 @@ interface CopyButtonProps extends Omit<React.ComponentProps<typeof Button>, 'val
11
12
  value: string;
12
13
  /** How long the check-mark feedback stays visible, in ms. @default 2000 */
13
14
  timeout?: number;
14
- /** Called after a successful copy. Wire your toast here — the DS never toasts. */
15
+ /**
16
+ * Called after a successful copy. Wire your toast here — the DS never toasts.
17
+ * Falls back to `CopyFeedbackProvider` when omitted (the RSC-safe path, since
18
+ * function props can't be passed from server components).
19
+ */
15
20
  onCopied?: () => void;
16
- /** Called when the clipboard write fails (e.g. permissions, insecure context). */
21
+ /**
22
+ * Called when the clipboard write fails (e.g. permissions, insecure context).
23
+ * Falls back to `CopyFeedbackProvider` when omitted.
24
+ */
17
25
  onCopyError?: (error: Error) => void;
18
26
  /** Accessible label before copying. @default "Copy" */
19
27
  copyLabel?: string;
@@ -40,6 +48,7 @@ function CopyButton({
40
48
  ...props
41
49
  }: CopyButtonProps) {
42
50
  const { copied, copy } = useCopy({ timeout });
51
+ const copyFeedback = useCopyFeedback();
43
52
 
44
53
  return (
45
54
  <Button
@@ -53,8 +62,9 @@ function CopyButton({
53
62
  onClick?.(event);
54
63
  if (event.defaultPrevented) return;
55
64
  const ok = await copy(value);
56
- if (ok) onCopied?.();
57
- else onCopyError?.(new Error('Failed to copy to the clipboard'));
65
+ if (ok) (onCopied ?? copyFeedback.onCopied)?.();
66
+ else
67
+ (onCopyError ?? copyFeedback.onCopyError)?.(new Error('Failed to copy to the clipboard'));
58
68
  }}
59
69
  {...props}
60
70
  >
@@ -71,10 +81,14 @@ interface CopyableTextProps {
71
81
  className?: string;
72
82
  /** How long the check-mark feedback stays visible, in ms. @default 2000 */
73
83
  timeout?: number;
74
- /** Called after a successful copy. Wire your toast here — the DS never toasts. */
84
+ /** Called after a successful copy. Wire your toast here — the DS never toasts. Falls back to `CopyFeedbackProvider`. */
75
85
  onCopied?: () => void;
76
- /** Called when the clipboard write fails. */
86
+ /** Called when the clipboard write fails. Falls back to `CopyFeedbackProvider`. */
77
87
  onCopyError?: (error: Error) => void;
88
+ /** Accessible label before copying, forwarded to the inner `CopyButton`. @default "Copy" */
89
+ copyLabel?: string;
90
+ /** Accessible label shown while the copied state is active, forwarded to the inner `CopyButton`. @default "Copied" */
91
+ copiedLabel?: string;
78
92
  }
79
93
 
80
94
  /**
@@ -89,11 +103,20 @@ function CopyableText({
89
103
  timeout,
90
104
  onCopied,
91
105
  onCopyError,
106
+ copyLabel,
107
+ copiedLabel,
92
108
  }: CopyableTextProps) {
93
109
  return (
94
110
  <span data-slot="copyable-text" className={cn('inline-flex items-center gap-1.5', className)}>
95
111
  {children ?? value}
96
- <CopyButton value={value} timeout={timeout} onCopied={onCopied} onCopyError={onCopyError} />
112
+ <CopyButton
113
+ value={value}
114
+ timeout={timeout}
115
+ onCopied={onCopied}
116
+ onCopyError={onCopyError}
117
+ copyLabel={copyLabel}
118
+ copiedLabel={copiedLabel}
119
+ />
97
120
  </span>
98
121
  );
99
122
  }
@@ -9,11 +9,12 @@ import type { DataTableBulkActionContext } from '@vendure-io/ui/components/molec
9
9
  import { cn } from '@vendure-io/ui/lib/utils';
10
10
  import * as React from 'react';
11
11
 
12
- // The selection overlay that replaces the controls row while rows are selected.
13
- // The cross-page selection cache lives here (donor parity): `rows` only ever
14
- // holds the current page, so we accumulate `row.original` by id as pages are
15
- // seen, letting `bulkActions` receive every selected original — not just the
16
- // ones still on screen.
12
+ // The selection bar that replaces the controls row inside the header band
13
+ // while rows are selected (returns null with an empty selection; the core
14
+ // decides where it renders). The cross-page selection cache lives here (donor
15
+ // parity): `rows` only ever holds the current page, so we accumulate
16
+ // `row.original` by id as pages are seen, letting `bulkActions` receive every
17
+ // selected original — not just the ones still on screen.
17
18
 
18
19
  /**
19
20
  * Accumulate `row.original` keyed by row id across page changes. Populated during
@@ -53,12 +54,12 @@ function DataTableBulkActions<TData>({
53
54
  };
54
55
 
55
56
  return (
57
+ // A plain band row, not a box: it takes the controls row's place inside
58
+ // the header band, so it carries no chrome of its own. min-h-9 matches
59
+ // the controls row's h-9 inputs so the swap holds the band height steady.
56
60
  <div
57
61
  data-slot="data-table-bulk-actions"
58
- className={cn(
59
- 'bg-muted/50 flex flex-wrap items-center gap-2 rounded-md border px-3 py-2',
60
- className,
61
- )}
62
+ className={cn('flex min-h-9 flex-wrap items-center gap-2', className)}
62
63
  {...props}
63
64
  >
64
65
  {render(ctx)}
@@ -182,7 +182,11 @@ export interface DataTableProps<TData> {
182
182
  header?: React.ReactNode;
183
183
  /** Controls row (search, faceted filters, custom buttons). Render-prop gets the live table. */
184
184
  toolbar?: React.ReactNode | ((table: Table<TData>) => React.ReactNode);
185
- /** Overlay content when selection is non-empty. Only meaningful with `rowSelection` wired. */
185
+ /**
186
+ * Replaces the controls row inside the header band while selection is
187
+ * non-empty (title and applied-filter chips stay). Only meaningful with
188
+ * `rowSelection` wired.
189
+ */
186
190
  bulkActions?: (ctx: DataTableBulkActionContext<TData>) => React.ReactNode;
187
191
  /**
188
192
  * Per-row actions → appends an actions column. First arg is `row.original`
@@ -225,8 +229,31 @@ export interface DataTableProps<TData> {
225
229
  */
226
230
  setTableOptions?: (options: TableOptions<TData>) => TableOptions<TData>;
227
231
 
232
+ /**
233
+ * The table's frame. `'card'` (default) renders the band anatomy on a real
234
+ * `Card`: a `CardHeader` (border-b) hosting the header/controls/chips zones,
235
+ * `CardTable` hosting the table flush to the card edges, and a `CardFooter`
236
+ * (border-t) hosting pagination when it is wired — no footer means the last
237
+ * row sits flush against the card's bottom edge. `'plain'` renders the same
238
+ * band structure without any card chrome, for a table embedded in an
239
+ * existing card (e.g. a dashboard widget): it MUST sit inside a `Card`,
240
+ * whose spacing variables (`--card-px`/`--card-gap`) drive the bands and
241
+ * edge-cell alignment — the host card is the frame.
242
+ */
243
+ frame?: 'card' | 'plain';
244
+ /**
245
+ * Extra `<TableRow>`s appended inside `TableBody` after the data rows — e.g.
246
+ * an order table's subtotal/shipping/total rows. Rendered only alongside
247
+ * real rows, never with the skeleton or empty states, and has no interaction
248
+ * with selection, sorting, or pagination. Use the function form to receive
249
+ * `columnCount` for `colSpan`s — display columns (select, actions) are
250
+ * injected around your columns, so a hard-coded count desyncs. Rows are
251
+ * presentational: give them `hover:bg-transparent`.
252
+ */
253
+ footerRows?: React.ReactNode | ((ctx: { columnCount: number }) => React.ReactNode);
254
+
228
255
  labels?: DataTableLabels;
229
- className?: string; // outer div[data-slot="data-table"]
256
+ className?: string; // the frame root (data-slot="data-table")
230
257
  }
231
258
 
232
259
  /**
@@ -19,6 +19,7 @@ import {
19
19
  useReactTable,
20
20
  type VisibilityState,
21
21
  } from '@tanstack/react-table';
22
+ import { Card, CardFooter, CardHeader, CardTable } from '@vendure-io/ui/components/atoms/card';
22
23
  import { Checkbox } from '@vendure-io/ui/components/atoms/checkbox';
23
24
  import {
24
25
  ContextMenu,
@@ -45,6 +46,7 @@ import {
45
46
  } from '@vendure-io/ui/components/molecules/data-table/data-table-filters';
46
47
  import {
47
48
  buildDisplayColumns,
49
+ getSelectedRowIds,
48
50
  resolveSlot,
49
51
  } from '@vendure-io/ui/components/molecules/data-table/data-table-helpers';
50
52
  import type {
@@ -59,13 +61,17 @@ import {
59
61
  } from '@vendure-io/ui/components/molecules/data-table/list-header';
60
62
  import { TablePagination } from '@vendure-io/ui/components/molecules/data-table/table-pagination';
61
63
  import { cn } from '@vendure-io/ui/lib/utils';
64
+ import { AnimatePresence, motion } from 'motion/react';
62
65
  import * as React from 'react';
63
66
 
64
67
  // The composition root. Owns the single `useReactTable` instance and the
65
- // controlled/uncontrolled bridge, then lays out header → controls → chips →
66
- // bulk overlay → Table → footer. Every capability follows the Phase-1 rule
67
- // verbatim: a feature renders only if its config is wired — no feature flags, no
68
- // disabled placeholders. Only this folder imports TanStack; the composed
68
+ // controlled/uncontrolled bridge, then lays out the card-framed table anatomy:
69
+ // a CardHeader band (header → controls → chips → bulk overlay), a CardTable
70
+ // hosting the Table flush to the card edges, and a CardFooter band for
71
+ // pagination. `frame="plain"` keeps the band structure without the card
72
+ // chrome. Every capability follows the Phase-1 rule verbatim: a feature
73
+ // renders only if its config is wired — no feature flags, no disabled
74
+ // placeholders. Only this folder imports TanStack; the composed
69
75
  // ListHeader/TablePagination/Chip primitives stay TanStack-free.
70
76
 
71
77
  /**
@@ -98,6 +104,16 @@ function headerLabelText<TData>(column: Column<TData, unknown>): string {
98
104
  return typeof header === 'string' ? header : column.id;
99
105
  }
100
106
 
107
+ // The controls/bulk replace-on-select swap: the outgoing row drifts slightly
108
+ // up as it fades, the incoming row rises in from below — a subtle vertical
109
+ // hand-off (4px) rather than a plain crossfade.
110
+ const bandRowFade = {
111
+ initial: { opacity: 0, y: 4 },
112
+ animate: { opacity: 1, y: 0 },
113
+ exit: { opacity: 0, y: -4 },
114
+ transition: { duration: 0.1, ease: 'easeOut' },
115
+ } as const;
116
+
101
117
  function ariaSort<TData>(
102
118
  hasSorting: boolean,
103
119
  column: Column<TData, unknown>,
@@ -126,6 +142,8 @@ function DataTable<TData>({
126
142
  emptyState,
127
143
  renderRow,
128
144
  setTableOptions,
145
+ frame = 'card',
146
+ footerRows,
129
147
  labels,
130
148
  className,
131
149
  }: DataTableProps<TData>) {
@@ -307,7 +325,11 @@ function DataTable<TData>({
307
325
  ? table.getVisibleLeafColumns().find((column) => column.id !== selectColumnId)?.id
308
326
  : undefined;
309
327
  const columnClass = (id: string): string | undefined => {
310
- if (id === selectColumnId) return 'relative w-0 p-0';
328
+ // `pl-0!`: the select cell is the first child, so CardTable's edge-cell
329
+ // padding would otherwise widen the zero-width cell and push the whole
330
+ // gutter arrangement out; important because CardTable's descendant rule
331
+ // out-specifies a plain utility on the cell.
332
+ if (id === selectColumnId) return 'relative w-0 p-0 pl-0!';
311
333
  if (id === leadingColumnId) return 'pl-8';
312
334
  // Actions cells drop vertical padding so a row-action button (taller than a
313
335
  // text line) sits inside the natural row height rather than inflating every
@@ -327,153 +349,215 @@ function DataTable<TData>({
327
349
  filters.showAppliedFilters !== false &&
328
350
  table.getState().columnFilters.length > 0;
329
351
  const showHeader = header != null || showControls || showChips;
330
- const showBulk = rowSelection != null && bulkActions != null;
352
+ // Unlike the other zones, the bulk bar exists only while rows are selected,
353
+ // so it opens the header band on its own only then — a controls-less
354
+ // selectable table must not render an empty band. (Deliberate trade-off:
355
+ // for a table with no header content at all, the band pops in on first
356
+ // selection rather than reserving empty space.)
357
+ const showBulk =
358
+ rowSelection != null &&
359
+ bulkActions != null &&
360
+ getSelectedRowIds(table.getState().rowSelection).length > 0;
331
361
 
332
- return (
333
- <div data-slot="data-table" className={cn('flex flex-col gap-4', className)}>
334
- {showHeader && (
335
- <ListHeader>
336
- {header}
337
- {showControls && (
338
- <ListHeaderControls>
339
- {toolbarNode}
340
- {hasFilterMenu && filters?.columns && (
341
- <DataTableAddFilter table={table} columns={filters.columns} label={l.addFilter} />
342
- )}
343
- {showViewOptions && (
344
- <DataTableViewOptions
362
+ const bands = (
363
+ <>
364
+ {(showHeader || showBulk) && (
365
+ <CardHeader className="border-b">
366
+ <ListHeader>
367
+ {header}
368
+ {/* Replace-on-select: while rows are selected the bulk bar takes
369
+ the controls row's place (title and applied-filter chips stay).
370
+ The swap fades out then in — one --transition-duration-fast
371
+ (100ms) per phase; mode="wait" keeps the rows sequential so
372
+ they never stack. */}
373
+ {(showControls || (rowSelection != null && bulkActions != null)) && (
374
+ <AnimatePresence initial={false} mode="wait">
375
+ {showBulk && bulkActions ? (
376
+ <motion.div key="bulk-actions" {...bandRowFade}>
377
+ <DataTableBulkActions
378
+ table={table}
379
+ cache={selectionCache}
380
+ render={bulkActions}
381
+ />
382
+ </motion.div>
383
+ ) : showControls ? (
384
+ <motion.div key="controls" {...bandRowFade}>
385
+ <ListHeaderControls>
386
+ {toolbarNode}
387
+ {hasFilterMenu && filters?.columns && (
388
+ <DataTableAddFilter
389
+ table={table}
390
+ columns={filters.columns}
391
+ label={l.addFilter}
392
+ />
393
+ )}
394
+ {showViewOptions && (
395
+ <DataTableViewOptions
396
+ table={table}
397
+ triggerLabel={l.columnsTrigger}
398
+ heading={l.columnsHeading}
399
+ />
400
+ )}
401
+ </ListHeaderControls>
402
+ </motion.div>
403
+ ) : null}
404
+ </AnimatePresence>
405
+ )}
406
+ {showChips && (
407
+ <ListHeaderChips>
408
+ <DataTableAppliedFilters
345
409
  table={table}
346
- triggerLabel={l.columnsTrigger}
347
- heading={l.columnsHeading}
410
+ columns={filters?.columns}
411
+ inlineChipLimit={filters?.inlineChipLimit}
412
+ removeLabel={l.removeFilter}
413
+ collapsedLabel={l.filtersCollapsed}
348
414
  />
349
- )}
350
- </ListHeaderControls>
351
- )}
352
- {showChips && (
353
- <ListHeaderChips>
354
- <DataTableAppliedFilters
355
- table={table}
356
- columns={filters?.columns}
357
- inlineChipLimit={filters?.inlineChipLimit}
358
- removeLabel={l.removeFilter}
359
- collapsedLabel={l.filtersCollapsed}
360
- />
361
- </ListHeaderChips>
362
- )}
363
- </ListHeader>
415
+ </ListHeaderChips>
416
+ )}
417
+ </ListHeader>
418
+ </CardHeader>
364
419
  )}
365
420
 
366
- {showBulk && bulkActions && (
367
- <DataTableBulkActions table={table} cache={selectionCache} render={bulkActions} />
368
- )}
369
-
370
- <Table>
371
- <TableHeader>
372
- {table.getHeaderGroups().map((headerGroup) => (
373
- <TableRow key={headerGroup.id} className="group/header-row">
374
- {headerGroup.headers.map((headerCell) => {
375
- const content = headerCell.isPlaceholder
376
- ? null
377
- : flexRender(headerCell.column.columnDef.header, headerCell.getContext());
378
- return (
379
- <TableHead
380
- key={headerCell.id}
381
- aria-sort={ariaSort(sorting != null, headerCell.column)}
382
- className={columnClass(headerCell.column.id)}
383
- >
384
- {sorting != null && !headerCell.isPlaceholder ? (
385
- <DataTableColumnHeader
386
- column={headerCell.column}
387
- sortLabel={l.sortLabel(headerLabelText(headerCell.column))}
388
- >
389
- {content}
390
- </DataTableColumnHeader>
391
- ) : (
392
- content
393
- )}
394
- </TableHead>
395
- );
396
- })}
397
- </TableRow>
398
- ))}
399
- </TableHeader>
400
- <TableBody>
401
- {isLoading && rows.length === 0 ? (
402
- Array.from({ length: skeletonRowCount }).map((_, rowIndex) => (
403
- <TableRow key={`skeleton-${rowIndex}`}>
404
- {Array.from({ length: Math.max(columnCount, 1) }).map((__, cellIndex) => (
405
- <TableCell key={`skeleton-cell-${cellIndex}`}>
406
- <Skeleton className="h-4 w-full" />
407
- </TableCell>
408
- ))}
421
+ <CardTable>
422
+ <Table>
423
+ <TableHeader>
424
+ {table.getHeaderGroups().map((headerGroup) => (
425
+ <TableRow key={headerGroup.id} className="group/header-row">
426
+ {headerGroup.headers.map((headerCell) => {
427
+ const content = headerCell.isPlaceholder
428
+ ? null
429
+ : flexRender(headerCell.column.columnDef.header, headerCell.getContext());
430
+ return (
431
+ <TableHead
432
+ key={headerCell.id}
433
+ aria-sort={ariaSort(sorting != null, headerCell.column)}
434
+ className={columnClass(headerCell.column.id)}
435
+ >
436
+ {sorting != null && !headerCell.isPlaceholder ? (
437
+ <DataTableColumnHeader
438
+ column={headerCell.column}
439
+ sortLabel={l.sortLabel(headerLabelText(headerCell.column))}
440
+ >
441
+ {content}
442
+ </DataTableColumnHeader>
443
+ ) : (
444
+ content
445
+ )}
446
+ </TableHead>
447
+ );
448
+ })}
409
449
  </TableRow>
410
- ))
411
- ) : bodyRows.length === 0 ? (
412
- <TableRow className="hover:bg-transparent">
413
- <TableCell
414
- colSpan={Math.max(columnCount, 1)}
415
- className="text-muted-foreground h-24 text-center"
416
- >
417
- {emptyState ?? l.empty}
418
- </TableCell>
419
- </TableRow>
420
- ) : (
421
- bodyRows.map((row) => {
422
- const rowNode = (
423
- <TableRow
424
- className="group/row"
425
- data-state={row.getIsSelected() ? 'selected' : undefined}
426
- >
427
- {row.getVisibleCells().map((cell) => (
428
- <TableCell key={cell.id} className={columnClass(cell.column.id)}>
429
- {flexRender(cell.column.columnDef.cell, cell.getContext())}
450
+ ))}
451
+ </TableHeader>
452
+ <TableBody>
453
+ {isLoading && rows.length === 0 ? (
454
+ Array.from({ length: skeletonRowCount }).map((_, rowIndex) => (
455
+ <TableRow key={`skeleton-${rowIndex}`}>
456
+ {Array.from({ length: Math.max(columnCount, 1) }).map((__, cellIndex) => (
457
+ <TableCell key={`skeleton-cell-${cellIndex}`}>
458
+ <Skeleton className="h-4 w-full" />
430
459
  </TableCell>
431
460
  ))}
432
461
  </TableRow>
433
- );
434
- // Right-click accelerator: the row itself is the context-menu
435
- // trigger, so the consumer supplies only the items and the core
436
- // owns the menu chrome.
437
- const defaultRow = contextActions ? (
438
- <ContextMenu>
439
- <ContextMenuTrigger render={rowNode} />
440
- <ContextMenuContent>
441
- {contextActions(row.original, { row, table })}
442
- </ContextMenuContent>
443
- </ContextMenu>
444
- ) : (
445
- rowNode
446
- );
447
- // Row-render seam: consumers can swap the default row for a
448
- // full-width utility row or a per-row wrapper the cell grid can't
449
- // express. Returning `defaultRow` keeps the built-in rendering.
450
- return (
451
- <React.Fragment key={row.id}>
452
- {renderRow ? renderRow(row, { table, columnCount, defaultRow }) : defaultRow}
453
- </React.Fragment>
454
- );
455
- })
456
- )}
457
- </TableBody>
458
- </Table>
462
+ ))
463
+ ) : bodyRows.length === 0 ? (
464
+ <TableRow className="hover:bg-transparent">
465
+ <TableCell
466
+ colSpan={Math.max(columnCount, 1)}
467
+ className="text-muted-foreground h-24 text-center"
468
+ >
469
+ {emptyState ?? l.empty}
470
+ </TableCell>
471
+ </TableRow>
472
+ ) : (
473
+ <>
474
+ {bodyRows.map((row) => {
475
+ const rowNode = (
476
+ <TableRow
477
+ className="group/row"
478
+ data-state={row.getIsSelected() ? 'selected' : undefined}
479
+ >
480
+ {row.getVisibleCells().map((cell) => (
481
+ <TableCell key={cell.id} className={columnClass(cell.column.id)}>
482
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
483
+ </TableCell>
484
+ ))}
485
+ </TableRow>
486
+ );
487
+ // Right-click accelerator: the row itself is the context-menu
488
+ // trigger, so the consumer supplies only the items and the core
489
+ // owns the menu chrome.
490
+ const defaultRow = contextActions ? (
491
+ <ContextMenu>
492
+ <ContextMenuTrigger render={rowNode} />
493
+ <ContextMenuContent>
494
+ {contextActions(row.original, { row, table })}
495
+ </ContextMenuContent>
496
+ </ContextMenu>
497
+ ) : (
498
+ rowNode
499
+ );
500
+ // Row-render seam: consumers can swap the default row for a
501
+ // full-width utility row or a per-row wrapper the cell grid can't
502
+ // express. Returning `defaultRow` keeps the built-in rendering.
503
+ return (
504
+ <React.Fragment key={row.id}>
505
+ {renderRow ? renderRow(row, { table, columnCount, defaultRow }) : defaultRow}
506
+ </React.Fragment>
507
+ );
508
+ })}
509
+ {typeof footerRows === 'function' ? footerRows({ columnCount }) : footerRows}
510
+ </>
511
+ )}
512
+ </TableBody>
513
+ </Table>
514
+ </CardTable>
459
515
 
460
516
  {pagination && (
461
- <TablePagination
462
- page={pagination.page}
463
- pageSize={pagination.pageSize}
464
- totalItems={pagination.totalItems ?? table.getPrePaginationRowModel().rows.length}
465
- onPageChange={pagination.onPageChange}
466
- onPageSizeChange={pagination.onPageSizeChange}
467
- pageSizeOptions={pagination.pageSizeOptions}
468
- getPageHref={pagination.getPageHref}
469
- formatRange={pagination.formatRange}
470
- navLabel={l.pagination?.navLabel}
471
- previousLabel={l.pagination?.previousLabel}
472
- nextLabel={l.pagination?.nextLabel}
473
- pageSizeLabel={l.pagination?.pageSizeLabel}
474
- />
517
+ <CardFooter className="border-t">
518
+ <TablePagination
519
+ className="w-full"
520
+ page={pagination.page}
521
+ pageSize={pagination.pageSize}
522
+ totalItems={pagination.totalItems ?? table.getPrePaginationRowModel().rows.length}
523
+ onPageChange={pagination.onPageChange}
524
+ onPageSizeChange={pagination.onPageSizeChange}
525
+ pageSizeOptions={pagination.pageSizeOptions}
526
+ getPageHref={pagination.getPageHref}
527
+ formatRange={pagination.formatRange}
528
+ navLabel={l.pagination?.navLabel}
529
+ previousLabel={l.pagination?.previousLabel}
530
+ nextLabel={l.pagination?.nextLabel}
531
+ pageSizeLabel={l.pagination?.pageSizeLabel}
532
+ />
533
+ </CardFooter>
534
+ )}
535
+ </>
536
+ );
537
+
538
+ // `plain` renders a chrome-less stack instead of a Card so the bands and
539
+ // CardTable consume the HOST card's spacing variables (--card-px /
540
+ // --card-gap): edge cells align with the host's padding regardless of its
541
+ // size, and a trailing CardTable's negative margin reaches through the
542
+ // host's bottom padding so the last row sits flush against the host's edge.
543
+ // With no header band the leading CardTable's negative margin would eat the
544
+ // host's flex gap too and press the rows against the preceding content, so
545
+ // the stack restores that one gap as padding.
546
+ return frame === 'plain' ? (
547
+ <div
548
+ data-slot="data-table"
549
+ className={cn(
550
+ 'flex flex-col gap-(--card-gap)',
551
+ !(showHeader || showBulk) && 'pt-(--card-gap)',
552
+ className,
475
553
  )}
554
+ >
555
+ {bands}
476
556
  </div>
557
+ ) : (
558
+ <Card data-slot="data-table" className={className}>
559
+ {bands}
560
+ </Card>
477
561
  );
478
562
  }
479
563