@vendure-io/ui 1.2.0 → 2.0.0-beta.0

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.
Files changed (112) hide show
  1. package/README.md +5 -5
  2. package/package.json +9 -4
  3. package/src/components/{ui → atoms}/alert-dialog.tsx +4 -4
  4. package/src/components/{ui → atoms}/badge.tsx +6 -3
  5. package/src/components/{ui → atoms}/button-group.tsx +1 -2
  6. package/src/components/{ui → atoms}/button.tsx +8 -2
  7. package/src/components/{ui → atoms}/calendar.tsx +1 -1
  8. package/src/components/{ui → atoms}/card.tsx +2 -3
  9. package/src/components/{ui → atoms}/carousel.tsx +1 -1
  10. package/src/components/atoms/checkbox.tsx +29 -0
  11. package/src/components/{ui → atoms}/combobox.tsx +3 -4
  12. package/src/components/{ui → atoms}/command.tsx +2 -2
  13. package/src/components/{ui → atoms}/context-menu.tsx +1 -1
  14. package/src/components/{ui → atoms}/dialog.tsx +4 -4
  15. package/src/components/{ui → atoms}/drawer.tsx +3 -3
  16. package/src/components/{ui → atoms}/dropdown-menu.tsx +3 -2
  17. package/src/components/{ui → atoms}/empty.tsx +1 -1
  18. package/src/components/{ui → atoms}/field.tsx +2 -2
  19. package/src/components/{ui → atoms}/hover-card.tsx +1 -1
  20. package/src/components/{ui → atoms}/input-group.tsx +3 -4
  21. package/src/components/{ui → atoms}/input-otp.tsx +0 -1
  22. package/src/components/{ui → atoms}/input.tsx +0 -1
  23. package/src/components/{ui → atoms}/item.tsx +2 -2
  24. package/src/components/{ui → atoms}/menubar.tsx +3 -4
  25. package/src/components/{ui → atoms}/native-select.tsx +0 -1
  26. package/src/components/{ui → atoms}/navigation-menu.tsx +7 -4
  27. package/src/components/{ui → atoms}/pagination.tsx +1 -1
  28. package/src/components/{ui → atoms}/popover.tsx +2 -2
  29. package/src/components/{ui → atoms}/select.tsx +22 -4
  30. package/src/components/{ui → atoms}/sheet.tsx +4 -4
  31. package/src/components/{ui → atoms}/sidebar.tsx +6 -6
  32. package/src/components/atoms/skeleton.tsx +16 -0
  33. package/src/components/{ui → atoms}/slider.tsx +0 -1
  34. package/src/components/{ui → atoms}/sonner.tsx +1 -1
  35. package/src/components/{ui → atoms}/switch.tsx +0 -1
  36. package/src/components/{ui → atoms}/tabs.tsx +6 -4
  37. package/src/components/{ui → atoms}/textarea.tsx +0 -1
  38. package/src/components/{ui → atoms}/toggle-group.tsx +3 -3
  39. package/src/components/{ui → atoms}/toggle.tsx +0 -1
  40. package/src/components/{ui → atoms}/tooltip.tsx +2 -1
  41. package/src/components/molecules/anonymized-token.tsx +170 -0
  42. package/src/components/molecules/chip.tsx +85 -0
  43. package/src/components/molecules/combobox-free-text.tsx +160 -0
  44. package/src/components/molecules/confirm-dialog.tsx +108 -0
  45. package/src/components/molecules/copyable-text.tsx +102 -0
  46. package/src/components/molecules/data-table/data-table-bulk-actions.tsx +69 -0
  47. package/src/components/molecules/data-table/data-table-column-header.tsx +61 -0
  48. package/src/components/molecules/data-table/data-table-filters.tsx +198 -0
  49. package/src/components/molecules/data-table/data-table-helpers.tsx +73 -0
  50. package/src/components/molecules/data-table/data-table-types.tsx +241 -0
  51. package/src/components/molecules/data-table/data-table-view-options.tsx +73 -0
  52. package/src/components/molecules/data-table/data-table.tsx +500 -0
  53. package/src/components/molecules/data-table/list-header.tsx +53 -0
  54. package/src/components/molecules/data-table/table-pagination.tsx +263 -0
  55. package/src/components/molecules/date-time.tsx +73 -0
  56. package/src/components/molecules/description-list.tsx +120 -0
  57. package/src/components/molecules/format-provider.tsx +46 -0
  58. package/src/components/molecules/id-chip.tsx +79 -0
  59. package/src/components/molecules/illustrations/access-denied.tsx +55 -0
  60. package/src/components/molecules/illustrations/empty-collection.tsx +66 -0
  61. package/src/components/molecules/illustrations/empty-database.tsx +44 -0
  62. package/src/components/molecules/illustrations/empty-media.tsx +68 -0
  63. package/src/components/molecules/illustrations/error.tsx +64 -0
  64. package/src/components/molecules/illustrations/first-run.tsx +55 -0
  65. package/src/components/molecules/illustrations/illustration-types.tsx +9 -0
  66. package/src/components/molecules/illustrations/no-activity.tsx +49 -0
  67. package/src/components/molecules/illustrations/no-deployments.tsx +49 -0
  68. package/src/components/molecules/illustrations/no-documents.tsx +67 -0
  69. package/src/components/molecules/illustrations/no-keys.tsx +50 -0
  70. package/src/components/molecules/illustrations/no-logs.tsx +53 -0
  71. package/src/components/molecules/illustrations/no-members.tsx +56 -0
  72. package/src/components/molecules/illustrations/no-notifications.tsx +52 -0
  73. package/src/components/molecules/illustrations/no-orders.tsx +48 -0
  74. package/src/components/molecules/illustrations/no-plugins.tsx +72 -0
  75. package/src/components/molecules/illustrations/no-results.tsx +69 -0
  76. package/src/components/molecules/illustrations/not-found.tsx +48 -0
  77. package/src/components/molecules/illustrations/offline.tsx +43 -0
  78. package/src/components/molecules/illustrations/pending-approval.tsx +56 -0
  79. package/src/components/molecules/illustrations/upload-dropzone.tsx +59 -0
  80. package/src/components/molecules/money.tsx +91 -0
  81. package/src/components/{custom → molecules}/multi-select.tsx +2 -2
  82. package/src/components/molecules/page-header.tsx +80 -0
  83. package/src/components/molecules/password-input.tsx +47 -0
  84. package/src/components/molecules/relative-time.tsx +81 -0
  85. package/src/components/molecules/stat-card.tsx +134 -0
  86. package/src/components/molecules/state-views/empty-state.tsx +75 -0
  87. package/src/components/molecules/state-views/error-state.tsx +91 -0
  88. package/src/components/molecules/state-views/loading-state.tsx +80 -0
  89. package/src/components/molecules/status-badge.tsx +100 -0
  90. package/src/hooks/use-copy.ts +51 -0
  91. package/src/lib/base-ui.ts +2 -1
  92. package/src/lib/format-relative.ts +41 -0
  93. package/src/lib/state-dictionary.ts +137 -0
  94. package/src/components/ui/checkbox.tsx +0 -30
  95. package/src/components/ui/skeleton.tsx +0 -13
  96. /package/src/components/{ui → atoms}/accordion.tsx +0 -0
  97. /package/src/components/{ui → atoms}/alert.tsx +0 -0
  98. /package/src/components/{ui → atoms}/aspect-ratio.tsx +0 -0
  99. /package/src/components/{ui → atoms}/avatar.tsx +0 -0
  100. /package/src/components/{ui → atoms}/breadcrumb.tsx +0 -0
  101. /package/src/components/{ui → atoms}/chart.tsx +0 -0
  102. /package/src/components/{ui → atoms}/collapsible.tsx +0 -0
  103. /package/src/components/{ui → atoms}/direction.tsx +0 -0
  104. /package/src/components/{ui → atoms}/kbd.tsx +0 -0
  105. /package/src/components/{ui → atoms}/label.tsx +0 -0
  106. /package/src/components/{ui → atoms}/progress.tsx +0 -0
  107. /package/src/components/{ui → atoms}/radio-group.tsx +0 -0
  108. /package/src/components/{ui → atoms}/resizable.tsx +0 -0
  109. /package/src/components/{ui → atoms}/scroll-area.tsx +0 -0
  110. /package/src/components/{ui → atoms}/separator.tsx +0 -0
  111. /package/src/components/{ui → atoms}/spinner.tsx +0 -0
  112. /package/src/components/{ui → atoms}/table.tsx +0 -0
@@ -0,0 +1,160 @@
1
+ 'use client';
2
+
3
+ import { Autocomplete } from '@base-ui/react/autocomplete';
4
+ import {
5
+ ComboboxContent,
6
+ ComboboxItem,
7
+ ComboboxList,
8
+ } from '@vendure-io/ui/components/atoms/combobox';
9
+ import {
10
+ InputGroup,
11
+ InputGroupAddon,
12
+ InputGroupInput,
13
+ } from '@vendure-io/ui/components/atoms/input-group';
14
+ import { Spinner } from '@vendure-io/ui/components/atoms/spinner';
15
+ import { cn } from '@vendure-io/ui/lib/utils';
16
+ import * as React from 'react';
17
+
18
+ export interface ComboboxFreeTextItem {
19
+ /**
20
+ * The string committed to `value` when this row is chosen. Must be unique
21
+ * across `items`: it is used as the React key and to resolve a picked row back
22
+ * to its record for `onSelectItem`.
23
+ */
24
+ value: string;
25
+ /** Primary line of the suggestion row. */
26
+ label: string;
27
+ /** Optional secondary line. */
28
+ description?: string;
29
+ }
30
+
31
+ export interface ComboboxFreeTextProps<T extends ComboboxFreeTextItem = ComboboxFreeTextItem> {
32
+ /** The input text — the source of truth. Free-form: it need not match any item. */
33
+ value: string;
34
+ /**
35
+ * Fires on every keystroke and when a suggestion is chosen (with the chosen
36
+ * item's `value`). Always a string — never the item object, because most
37
+ * fires (keystrokes, free text) have no item behind them.
38
+ */
39
+ onValueChange: (value: string) => void;
40
+ /**
41
+ * Fires only when a suggestion is chosen (by click or keyboard) — never on a
42
+ * keystroke or free-text entry, since those have no underlying record. Hands
43
+ * back the full item, so attach extra fields (e.g. an `id`) to your `items`
44
+ * and read them here.
45
+ */
46
+ onSelectItem?: (item: T) => void;
47
+ /**
48
+ * Suggestions for the current input. The caller fetches these — typically
49
+ * debounced against `value` — and they are assumed to be already filtered
50
+ * (the component does no client-side filtering). Items may carry extra fields
51
+ * beyond `value`/`label`/`description`; those flow through to `onSelectItem`.
52
+ */
53
+ items: readonly T[];
54
+ /** Async state of the suggestion source. Shows a trailing spinner while true. */
55
+ loading?: boolean;
56
+ /** Render a suggestion row. Defaults to `label` over `description`. */
57
+ renderItem?: (item: T) => React.ReactNode;
58
+ /** id forwarded to the input. */
59
+ id?: string;
60
+ placeholder?: string;
61
+ /** Render the error state (sets `aria-invalid` on the input). */
62
+ invalid?: boolean;
63
+ disabled?: boolean;
64
+ /** Class applied to the input container. */
65
+ className?: string;
66
+ }
67
+
68
+ function defaultRenderItem(item: ComboboxFreeTextItem): React.ReactNode {
69
+ return (
70
+ <div className="flex min-w-0 flex-col">
71
+ <span className="truncate">{item.label}</span>
72
+ {item.description ? (
73
+ <span className="text-muted-foreground truncate text-xs">{item.description}</span>
74
+ ) : null}
75
+ </div>
76
+ );
77
+ }
78
+
79
+ /**
80
+ * A text input with debounced, server-driven suggestions where free text wins:
81
+ * pick a suggestion to commit its value, or keep typing and your text is the
82
+ * value. A choice is never forced — Enter, blur and no-match all keep the text.
83
+ *
84
+ * Built on Base UI's `Autocomplete` (the free-text primitive — `selectionMode`
85
+ * is fixed to `none` and the input value is the source of truth), not the
86
+ * pick-only `Combobox`. `mode="none"` keeps suggestions static (no client-side
87
+ * filtering, since the caller pre-filters server-side) and disables
88
+ * inline-autocompletion. Fetching, debounce and validation live with the caller.
89
+ */
90
+ function ComboboxFreeText<T extends ComboboxFreeTextItem = ComboboxFreeTextItem>({
91
+ value,
92
+ onValueChange,
93
+ onSelectItem,
94
+ items,
95
+ loading = false,
96
+ renderItem = defaultRenderItem,
97
+ id,
98
+ placeholder,
99
+ invalid,
100
+ disabled,
101
+ className,
102
+ }: ComboboxFreeTextProps<T>) {
103
+ // Base UI opens the popup on every keystroke; we only want it open when there
104
+ // is something pickable, so a no-match closes it silently instead of flashing
105
+ // an empty box. `open` tracks the user's intent (typing, Escape, blur,
106
+ // select) and we AND it with "items present" to derive the rendered state.
107
+ const [open, setOpen] = React.useState(false);
108
+
109
+ return (
110
+ <Autocomplete.Root
111
+ mode="none"
112
+ autoHighlight={false}
113
+ value={value}
114
+ onValueChange={(next, details) => {
115
+ // Call onValueChange before onSelectItem so a record captured in the
116
+ // latter survives any reset the value handler performs.
117
+ onValueChange(next);
118
+ // `item-press` is the only reason a row selection (mouse or keyboard)
119
+ // drives a value change; keystrokes and free text use other reasons.
120
+ // The literal is checked against Base UI's public `reason` union, so a
121
+ // future rename surfaces as a type error rather than silent breakage.
122
+ if (onSelectItem && details.reason === 'item-press') {
123
+ const picked = items.find((i) => i.value === next);
124
+ if (picked) {
125
+ onSelectItem(picked);
126
+ }
127
+ }
128
+ }}
129
+ open={open && items.length > 0}
130
+ onOpenChange={setOpen}
131
+ disabled={disabled}
132
+ >
133
+ <InputGroup className={cn('w-full', className)}>
134
+ <Autocomplete.Input
135
+ render={<InputGroupInput />}
136
+ id={id}
137
+ placeholder={placeholder}
138
+ disabled={disabled}
139
+ aria-invalid={invalid || undefined}
140
+ />
141
+ {loading ? (
142
+ <InputGroupAddon align="inline-end">
143
+ <Spinner />
144
+ </InputGroupAddon>
145
+ ) : null}
146
+ </InputGroup>
147
+ <ComboboxContent>
148
+ <ComboboxList>
149
+ {items.map((item) => (
150
+ <ComboboxItem key={item.value} value={item.value}>
151
+ {renderItem(item)}
152
+ </ComboboxItem>
153
+ ))}
154
+ </ComboboxList>
155
+ </ComboboxContent>
156
+ </Autocomplete.Root>
157
+ );
158
+ }
159
+
160
+ export { ComboboxFreeText };
@@ -0,0 +1,108 @@
1
+ 'use client';
2
+
3
+ import {
4
+ AlertDialog,
5
+ AlertDialogAction,
6
+ AlertDialogCancel,
7
+ AlertDialogContent,
8
+ AlertDialogDescription,
9
+ AlertDialogFooter,
10
+ AlertDialogHeader,
11
+ AlertDialogTitle,
12
+ AlertDialogTrigger,
13
+ } from '@vendure-io/ui/components/atoms/alert-dialog';
14
+ import { Spinner } from '@vendure-io/ui/components/atoms/spinner';
15
+ import { type ReactElement, type ReactNode, useRef, useState } from 'react';
16
+
17
+ interface ConfirmDialogProps {
18
+ title: ReactNode;
19
+ description?: ReactNode;
20
+ /**
21
+ * May return a promise: the dialog then keeps itself open, disables both
22
+ * buttons, shows a pending spinner on the action, and closes only on resolve.
23
+ * A rejection leaves the dialog open so the user can retry.
24
+ */
25
+ onConfirm: () => void | Promise<void>;
26
+ confirmLabel?: ReactNode;
27
+ cancelLabel?: ReactNode;
28
+ variant?: 'default' | 'destructive';
29
+ /** Uncontrolled mode — the element that opens the dialog. */
30
+ children?: ReactElement;
31
+ /** Controlled mode — supply with `onOpenChange`. */
32
+ open?: boolean;
33
+ onOpenChange?: (open: boolean) => void;
34
+ }
35
+
36
+ function ConfirmDialog({
37
+ title,
38
+ description,
39
+ onConfirm,
40
+ confirmLabel = 'Confirm',
41
+ cancelLabel = 'Cancel',
42
+ variant = 'default',
43
+ children,
44
+ open,
45
+ onOpenChange,
46
+ }: ConfirmDialogProps) {
47
+ const isControlled = open !== undefined;
48
+ const [internalOpen, setInternalOpen] = useState(false);
49
+ const [pending, setPending] = useState(false);
50
+ // State alone can't stop a fast double-click: both clicks land before the
51
+ // re-render disables the button, so the in-flight flag lives in a ref too.
52
+ const pendingRef = useRef(false);
53
+ const actualOpen = isControlled ? open : internalOpen;
54
+
55
+ const setOpen = (next: boolean) => {
56
+ if (!isControlled) setInternalOpen(next);
57
+ onOpenChange?.(next);
58
+ };
59
+
60
+ const handleConfirm = async () => {
61
+ if (pendingRef.current) return;
62
+ pendingRef.current = true;
63
+ try {
64
+ setPending(true);
65
+ await onConfirm();
66
+ setOpen(false);
67
+ } catch {
68
+ // Stay open on rejection so the user can retry or cancel.
69
+ } finally {
70
+ pendingRef.current = false;
71
+ setPending(false);
72
+ }
73
+ };
74
+
75
+ return (
76
+ <AlertDialog
77
+ open={actualOpen}
78
+ onOpenChange={(next) => {
79
+ // Block dismissal (escape/backdrop) while the action is in flight.
80
+ if (!pending) setOpen(next);
81
+ }}
82
+ >
83
+ {children ? <AlertDialogTrigger render={children} /> : null}
84
+ <AlertDialogContent data-slot="confirm-dialog">
85
+ <AlertDialogHeader>
86
+ <AlertDialogTitle>{title}</AlertDialogTitle>
87
+ {description ? <AlertDialogDescription>{description}</AlertDialogDescription> : null}
88
+ </AlertDialogHeader>
89
+ <AlertDialogFooter>
90
+ <AlertDialogCancel disabled={pending}>{cancelLabel}</AlertDialogCancel>
91
+ {/* AlertDialogAction is a plain Button (not a Close), so closing stays
92
+ under our control — the promise decides when the dialog leaves. */}
93
+ <AlertDialogAction
94
+ variant={variant}
95
+ disabled={pending}
96
+ aria-busy={pending || undefined}
97
+ onClick={handleConfirm}
98
+ >
99
+ {pending ? <Spinner /> : null}
100
+ {confirmLabel}
101
+ </AlertDialogAction>
102
+ </AlertDialogFooter>
103
+ </AlertDialogContent>
104
+ </AlertDialog>
105
+ );
106
+ }
107
+
108
+ export { ConfirmDialog, type ConfirmDialogProps };
@@ -0,0 +1,102 @@
1
+ 'use client';
2
+
3
+ import { Button } from '@vendure-io/ui/components/atoms/button';
4
+ import { useCopy } from '@vendure-io/ui/hooks/use-copy';
5
+ import { cn } from '@vendure-io/ui/lib/utils';
6
+ import { CheckIcon, CopyIcon } from 'lucide-react';
7
+ import type * as React from 'react';
8
+
9
+ interface CopyButtonProps extends Omit<React.ComponentProps<typeof Button>, 'value' | 'children'> {
10
+ /** The text written to the clipboard. */
11
+ value: string;
12
+ /** How long the check-mark feedback stays visible, in ms. @default 2000 */
13
+ timeout?: number;
14
+ /** Called after a successful copy. Wire your toast here — the DS never toasts. */
15
+ onCopied?: () => void;
16
+ /** Called when the clipboard write fails (e.g. permissions, insecure context). */
17
+ onCopyError?: (error: Error) => void;
18
+ /** Accessible label before copying. @default "Copy" */
19
+ copyLabel?: string;
20
+ /** Accessible label shown while the copied state is active. @default "Copied" */
21
+ copiedLabel?: string;
22
+ }
23
+
24
+ /**
25
+ * Ghost icon button that copies `value` and swaps its icon to a check-mark for a
26
+ * short window. Colours the confirmation with the semantic `text-success` token
27
+ * (never a raw green), per ADR 0003.
28
+ */
29
+ function CopyButton({
30
+ value,
31
+ timeout,
32
+ onCopied,
33
+ onCopyError,
34
+ copyLabel = 'Copy',
35
+ copiedLabel = 'Copied',
36
+ variant = 'ghost',
37
+ size = 'icon-xs',
38
+ className,
39
+ onClick,
40
+ ...props
41
+ }: CopyButtonProps) {
42
+ const { copied, copy } = useCopy({ timeout });
43
+
44
+ return (
45
+ <Button
46
+ type="button"
47
+ data-slot="copy-button"
48
+ variant={variant}
49
+ size={size}
50
+ aria-label={copied ? copiedLabel : copyLabel}
51
+ className={cn('text-muted-foreground hover:text-foreground', className)}
52
+ onClick={async (event) => {
53
+ onClick?.(event);
54
+ if (event.defaultPrevented) return;
55
+ const ok = await copy(value);
56
+ if (ok) onCopied?.();
57
+ else onCopyError?.(new Error('Failed to copy to the clipboard'));
58
+ }}
59
+ {...props}
60
+ >
61
+ {copied ? <CheckIcon className="text-success" /> : <CopyIcon />}
62
+ </Button>
63
+ );
64
+ }
65
+
66
+ interface CopyableTextProps {
67
+ /** The text written to the clipboard. */
68
+ value: string;
69
+ /** Content to render beside the copy button. Falls back to `value` as plain text. Styling is the consumer's. */
70
+ children?: React.ReactNode;
71
+ className?: string;
72
+ /** How long the check-mark feedback stays visible, in ms. @default 2000 */
73
+ timeout?: number;
74
+ /** Called after a successful copy. Wire your toast here — the DS never toasts. */
75
+ onCopied?: () => void;
76
+ /** Called when the clipboard write fails. */
77
+ onCopyError?: (error: Error) => void;
78
+ }
79
+
80
+ /**
81
+ * Renders arbitrary content alongside a {@link CopyButton}. Applies no styling
82
+ * to `children` — presentation is entirely the consumer's; only the layout
83
+ * (inline flex + gap) belongs to the molecule.
84
+ */
85
+ function CopyableText({
86
+ value,
87
+ children,
88
+ className,
89
+ timeout,
90
+ onCopied,
91
+ onCopyError,
92
+ }: CopyableTextProps) {
93
+ return (
94
+ <span data-slot="copyable-text" className={cn('inline-flex items-center gap-1.5', className)}>
95
+ {children ?? value}
96
+ <CopyButton value={value} timeout={timeout} onCopied={onCopied} onCopyError={onCopyError} />
97
+ </span>
98
+ );
99
+ }
100
+
101
+ export { CopyButton, CopyableText };
102
+ export type { CopyButtonProps, CopyableTextProps };
@@ -0,0 +1,69 @@
1
+ 'use client';
2
+
3
+ import type { Table } from '@tanstack/react-table';
4
+ import {
5
+ getSelectedOriginals,
6
+ getSelectedRowIds,
7
+ } from '@vendure-io/ui/components/molecules/data-table/data-table-helpers';
8
+ import type { DataTableBulkActionContext } from '@vendure-io/ui/components/molecules/data-table/data-table-types';
9
+ import { cn } from '@vendure-io/ui/lib/utils';
10
+ import * as React from 'react';
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.
17
+
18
+ /**
19
+ * Accumulate `row.original` keyed by row id across page changes. Populated during
20
+ * render (idempotent map writes) so the current render's `bulkActions` can
21
+ * resolve the full selection synchronously.
22
+ */
23
+ function useSelectionCache<TData>(table: Table<TData>): Map<string, TData> {
24
+ const cacheRef = React.useRef<Map<string, TData>>(new Map());
25
+ for (const row of table.getRowModel().rows) {
26
+ cacheRef.current.set(row.id, row.original);
27
+ }
28
+ return cacheRef.current;
29
+ }
30
+
31
+ interface DataTableBulkActionsProps<TData> extends React.ComponentProps<'div'> {
32
+ table: Table<TData>;
33
+ cache: Map<string, TData>;
34
+ render: (ctx: DataTableBulkActionContext<TData>) => React.ReactNode;
35
+ }
36
+
37
+ function DataTableBulkActions<TData>({
38
+ table,
39
+ cache,
40
+ render,
41
+ className,
42
+ ...props
43
+ }: DataTableBulkActionsProps<TData>) {
44
+ const selection = table.getState().rowSelection;
45
+ const selectedRowIds = getSelectedRowIds(selection);
46
+ if (selectedRowIds.length === 0) return null;
47
+
48
+ const ctx: DataTableBulkActionContext<TData> = {
49
+ selection: getSelectedOriginals(selection, cache),
50
+ selectedRowIds,
51
+ table,
52
+ clearSelection: () => table.resetRowSelection(),
53
+ };
54
+
55
+ return (
56
+ <div
57
+ 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
+ {...props}
63
+ >
64
+ {render(ctx)}
65
+ </div>
66
+ );
67
+ }
68
+
69
+ export { DataTableBulkActions, useSelectionCache };
@@ -0,0 +1,61 @@
1
+ 'use client';
2
+
3
+ import type { Column } from '@tanstack/react-table';
4
+ import { Button } from '@vendure-io/ui/components/atoms/button';
5
+ import { cn } from '@vendure-io/ui/lib/utils';
6
+ import { ArrowDownIcon, ArrowUpIcon, ChevronsUpDownIcon } from 'lucide-react';
7
+ import type * as React from 'react';
8
+
9
+ // The sortable header control rendered inside a `<TableHead>`. When the column
10
+ // can't sort it degrades to plain text — the header stays a passive label, no
11
+ // button, no affordance it can't honour. The `aria-sort` state lives on the
12
+ // enclosing `<TableHead>` (set by `data-table.tsx`), not here.
13
+
14
+ interface DataTableColumnHeaderProps<TData, TValue>
15
+ extends Omit<React.ComponentProps<'button'>, 'title'> {
16
+ column: Column<TData, TValue>;
17
+ /** Accessible name for the sort toggle, e.g. `Sort by Name`. */
18
+ sortLabel?: string;
19
+ }
20
+
21
+ function DataTableColumnHeader<TData, TValue>({
22
+ column,
23
+ sortLabel,
24
+ className,
25
+ children,
26
+ ...props
27
+ }: DataTableColumnHeaderProps<TData, TValue>) {
28
+ if (!column.getCanSort()) {
29
+ return <span className={cn('font-medium', className)}>{children}</span>;
30
+ }
31
+
32
+ const sorted = column.getIsSorted();
33
+
34
+ return (
35
+ <Button
36
+ type="button"
37
+ variant="ghost"
38
+ size="sm"
39
+ data-slot="data-table-column-header"
40
+ aria-label={sortLabel}
41
+ // Tri-state cycle asc → desc → none is TanStack's default toggle (removal
42
+ // enabled). We only wire the click; the engine owns the state machine.
43
+ onClick={column.getToggleSortingHandler()}
44
+ className={cn('-ml-2.5 h-8 gap-1.5 data-[slot=data-table-column-header]:px-2.5', className)}
45
+ {...props}
46
+ >
47
+ {children}
48
+ {/* Active sort reads at full intensity; the idle affordance stays faint so
49
+ it hints without competing with the header label. */}
50
+ {sorted === 'asc' ? (
51
+ <ArrowUpIcon />
52
+ ) : sorted === 'desc' ? (
53
+ <ArrowDownIcon />
54
+ ) : (
55
+ <ChevronsUpDownIcon className="text-muted-foreground/40" />
56
+ )}
57
+ </Button>
58
+ );
59
+ }
60
+
61
+ export { DataTableColumnHeader };
@@ -0,0 +1,198 @@
1
+ 'use client';
2
+
3
+ import type { Table } from '@tanstack/react-table';
4
+ import { Button } from '@vendure-io/ui/components/atoms/button';
5
+ import { Input } from '@vendure-io/ui/components/atoms/input';
6
+ import { Popover, PopoverContent, PopoverTrigger } from '@vendure-io/ui/components/atoms/popover';
7
+ import { Chip } from '@vendure-io/ui/components/molecules/chip';
8
+ import type {
9
+ DataTableFilterColumn,
10
+ DataTableFilterValue,
11
+ } from '@vendure-io/ui/components/molecules/data-table/data-table-types';
12
+ import { cn } from '@vendure-io/ui/lib/utils';
13
+ import { ChevronLeftIcon, ListFilterIcon } from 'lucide-react';
14
+ import * as React from 'react';
15
+
16
+ // The two filter surfaces. `DataTableAddFilter` is the core-owned column picker
17
+ // + value editor (rendered only when `filters.columns` is declared).
18
+ // `DataTableAppliedFilters` is the chip row — an applied filter is a `Chip` with
19
+ // `onRemove`, by decision there is no `FilterChip`. Both write through the live
20
+ // TanStack column API (`setFilterValue`), so the controlled `columnFilters`
21
+ // state stays the single source of truth.
22
+
23
+ /** The default text editor: emits `{ [firstOperator ?? 'contains']: value }`. */
24
+ function DefaultFilterInput<TData>({
25
+ column,
26
+ value,
27
+ onChange,
28
+ }: {
29
+ column: DataTableFilterColumn<TData>;
30
+ value: DataTableFilterValue | undefined;
31
+ onChange: (next: DataTableFilterValue | undefined) => void;
32
+ }) {
33
+ const operator = column.operators?.[0] ?? 'contains';
34
+ const current = value?.[operator];
35
+
36
+ return (
37
+ <Input
38
+ autoFocus
39
+ value={typeof current === 'string' ? current : ''}
40
+ placeholder="Value"
41
+ onChange={(event) => {
42
+ const next = event.target.value;
43
+ onChange(next ? { [operator]: next } : undefined);
44
+ }}
45
+ />
46
+ );
47
+ }
48
+
49
+ interface DataTableAddFilterProps<TData> {
50
+ table: Table<TData>;
51
+ columns: DataTableFilterColumn<TData>[];
52
+ label?: string;
53
+ }
54
+
55
+ function DataTableAddFilter<TData>({
56
+ table,
57
+ columns,
58
+ label = 'Add filter',
59
+ }: DataTableAddFilterProps<TData>) {
60
+ const [open, setOpen] = React.useState(false);
61
+ const [activeId, setActiveId] = React.useState<string | null>(null);
62
+ const active = columns.find((column) => column.id === activeId) ?? null;
63
+ const tableColumn = active ? table.getColumn(active.id) : undefined;
64
+
65
+ return (
66
+ <Popover
67
+ open={open}
68
+ onOpenChange={(next) => {
69
+ setOpen(next);
70
+ if (!next) setActiveId(null);
71
+ }}
72
+ >
73
+ <PopoverTrigger
74
+ data-slot="data-table-add-filter"
75
+ render={
76
+ <Button variant="outline" size="sm">
77
+ <ListFilterIcon />
78
+ {label}
79
+ </Button>
80
+ }
81
+ />
82
+ <PopoverContent align="start" className="w-64 gap-2 p-2">
83
+ {active ? (
84
+ <div className="flex flex-col gap-2">
85
+ <button
86
+ type="button"
87
+ className="text-muted-foreground hover:text-foreground -ml-1 flex items-center gap-1 text-xs"
88
+ onClick={() => setActiveId(null)}
89
+ >
90
+ <ChevronLeftIcon className="size-3" />
91
+ {active.label}
92
+ </button>
93
+ {active.renderInput ? (
94
+ active.renderInput({
95
+ value: tableColumn?.getFilterValue() as DataTableFilterValue | undefined,
96
+ onChange: (next) => tableColumn?.setFilterValue(next),
97
+ column: active,
98
+ })
99
+ ) : (
100
+ <DefaultFilterInput
101
+ column={active}
102
+ value={tableColumn?.getFilterValue() as DataTableFilterValue | undefined}
103
+ onChange={(next) => tableColumn?.setFilterValue(next)}
104
+ />
105
+ )}
106
+ </div>
107
+ ) : (
108
+ <div className="flex flex-col">
109
+ {columns.map((column) => (
110
+ <Button
111
+ key={column.id}
112
+ variant="ghost"
113
+ size="sm"
114
+ className="justify-start font-normal"
115
+ onClick={() => setActiveId(column.id)}
116
+ >
117
+ {column.label}
118
+ </Button>
119
+ ))}
120
+ </div>
121
+ )}
122
+ </PopoverContent>
123
+ </Popover>
124
+ );
125
+ }
126
+
127
+ /** String form of a column label, for accessible names that need a string. */
128
+ function labelText(label: React.ReactNode, fallback: string): string {
129
+ return typeof label === 'string' ? label : fallback;
130
+ }
131
+
132
+ interface DataTableAppliedFiltersProps<TData> {
133
+ table: Table<TData>;
134
+ columns?: DataTableFilterColumn<TData>[];
135
+ inlineChipLimit?: number;
136
+ removeLabel?: (columnLabel: string) => string;
137
+ collapsedLabel?: (count: number) => string;
138
+ }
139
+
140
+ function DataTableAppliedFilters<TData>({
141
+ table,
142
+ columns,
143
+ inlineChipLimit = Number.POSITIVE_INFINITY,
144
+ removeLabel = (columnLabel) => `Remove ${columnLabel} filter`,
145
+ collapsedLabel = (count) => `${count} filters`,
146
+ }: DataTableAppliedFiltersProps<TData>) {
147
+ const applied = table.getState().columnFilters;
148
+ if (applied.length === 0) return null;
149
+
150
+ const columnMap = new Map((columns ?? []).map((column) => [column.id, column]));
151
+
152
+ const chipFor = (filter: (typeof applied)[number]) => {
153
+ const column = columnMap.get(filter.id);
154
+ const text = labelText(column?.label, filter.id);
155
+ const content = column?.formatChip ? (
156
+ column.formatChip(filter.value as DataTableFilterValue)
157
+ ) : (
158
+ <>
159
+ {column?.label ?? filter.id}: {JSON.stringify(filter.value)}
160
+ </>
161
+ );
162
+
163
+ return (
164
+ <Chip
165
+ key={filter.id}
166
+ onRemove={() => table.getColumn(filter.id)?.setFilterValue(undefined)}
167
+ removeLabel={removeLabel(text)}
168
+ >
169
+ {content}
170
+ </Chip>
171
+ );
172
+ };
173
+
174
+ const inline = applied.slice(0, inlineChipLimit);
175
+ const overflow = applied.slice(inlineChipLimit);
176
+
177
+ return (
178
+ <>
179
+ {inline.map(chipFor)}
180
+ {overflow.length > 0 && (
181
+ <Popover>
182
+ <PopoverTrigger
183
+ render={
184
+ <Button variant="outline" size="sm">
185
+ {collapsedLabel(overflow.length)}
186
+ </Button>
187
+ }
188
+ />
189
+ <PopoverContent align="start" className={cn('w-auto max-w-72 flex-row flex-wrap gap-2')}>
190
+ {overflow.map(chipFor)}
191
+ </PopoverContent>
192
+ </Popover>
193
+ )}
194
+ </>
195
+ );
196
+ }
197
+
198
+ export { DataTableAddFilter, DataTableAppliedFilters };