@vendure-io/ui 1.3.0 → 2.0.0-beta.1
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/README.md +5 -5
- package/package.json +10 -5
- package/src/components/{ui → atoms}/alert-dialog.tsx +4 -4
- package/src/components/{ui → atoms}/badge.tsx +6 -3
- package/src/components/{ui → atoms}/button-group.tsx +1 -2
- package/src/components/{ui → atoms}/button.tsx +8 -2
- package/src/components/{ui → atoms}/calendar.tsx +1 -1
- package/src/components/{ui → atoms}/card.tsx +2 -3
- package/src/components/{ui → atoms}/carousel.tsx +1 -1
- package/src/components/atoms/checkbox.tsx +29 -0
- package/src/components/{ui → atoms}/combobox.tsx +3 -4
- package/src/components/{ui → atoms}/command.tsx +2 -2
- package/src/components/{ui → atoms}/context-menu.tsx +1 -1
- package/src/components/{ui → atoms}/dialog.tsx +4 -4
- package/src/components/{ui → atoms}/drawer.tsx +3 -3
- package/src/components/{ui → atoms}/dropdown-menu.tsx +3 -2
- package/src/components/{ui → atoms}/empty.tsx +1 -1
- package/src/components/{ui → atoms}/field.tsx +2 -2
- package/src/components/{ui → atoms}/hover-card.tsx +1 -1
- package/src/components/{ui → atoms}/input-group.tsx +3 -4
- package/src/components/{ui → atoms}/input-otp.tsx +0 -1
- package/src/components/{ui → atoms}/input.tsx +0 -1
- package/src/components/{ui → atoms}/item.tsx +2 -2
- package/src/components/{ui → atoms}/menubar.tsx +3 -4
- package/src/components/{ui → atoms}/native-select.tsx +0 -1
- package/src/components/{ui → atoms}/navigation-menu.tsx +7 -4
- package/src/components/{ui → atoms}/pagination.tsx +1 -1
- package/src/components/{ui → atoms}/popover.tsx +2 -2
- package/src/components/{ui → atoms}/select.tsx +22 -4
- package/src/components/{ui → atoms}/sheet.tsx +4 -4
- package/src/components/{ui → atoms}/sidebar.tsx +6 -6
- package/src/components/atoms/skeleton.tsx +16 -0
- package/src/components/{ui → atoms}/slider.tsx +0 -1
- package/src/components/{ui → atoms}/sonner.tsx +1 -1
- package/src/components/{ui → atoms}/switch.tsx +0 -1
- package/src/components/{ui → atoms}/tabs.tsx +6 -4
- package/src/components/{ui → atoms}/textarea.tsx +0 -1
- package/src/components/{ui → atoms}/toggle-group.tsx +3 -3
- package/src/components/{ui → atoms}/toggle.tsx +0 -1
- package/src/components/{ui → atoms}/tooltip.tsx +2 -1
- package/src/components/molecules/anonymized-token.tsx +170 -0
- package/src/components/molecules/chip.tsx +85 -0
- package/src/components/{custom → molecules}/combobox-free-text.tsx +7 -3
- package/src/components/molecules/confirm-dialog.tsx +108 -0
- package/src/components/molecules/copyable-text.tsx +102 -0
- package/src/components/molecules/data-table/data-table-bulk-actions.tsx +69 -0
- package/src/components/molecules/data-table/data-table-column-header.tsx +61 -0
- package/src/components/molecules/data-table/data-table-filters.tsx +198 -0
- package/src/components/molecules/data-table/data-table-helpers.tsx +73 -0
- package/src/components/molecules/data-table/data-table-types.tsx +241 -0
- package/src/components/molecules/data-table/data-table-view-options.tsx +73 -0
- package/src/components/molecules/data-table/data-table.tsx +500 -0
- package/src/components/molecules/data-table/list-header.tsx +53 -0
- package/src/components/molecules/data-table/table-pagination.tsx +263 -0
- package/src/components/molecules/date-time.tsx +73 -0
- package/src/components/molecules/description-list.tsx +120 -0
- package/src/components/molecules/format-provider.tsx +46 -0
- package/src/components/molecules/id-chip.tsx +79 -0
- package/src/components/molecules/illustrations/access-denied.tsx +55 -0
- package/src/components/molecules/illustrations/empty-collection.tsx +66 -0
- package/src/components/molecules/illustrations/empty-database.tsx +44 -0
- package/src/components/molecules/illustrations/empty-media.tsx +68 -0
- package/src/components/molecules/illustrations/error.tsx +64 -0
- package/src/components/molecules/illustrations/first-run.tsx +55 -0
- package/src/components/molecules/illustrations/illustration-types.tsx +9 -0
- package/src/components/molecules/illustrations/no-activity.tsx +49 -0
- package/src/components/molecules/illustrations/no-deployments.tsx +49 -0
- package/src/components/molecules/illustrations/no-documents.tsx +67 -0
- package/src/components/molecules/illustrations/no-keys.tsx +50 -0
- package/src/components/molecules/illustrations/no-logs.tsx +53 -0
- package/src/components/molecules/illustrations/no-members.tsx +56 -0
- package/src/components/molecules/illustrations/no-notifications.tsx +52 -0
- package/src/components/molecules/illustrations/no-orders.tsx +48 -0
- package/src/components/molecules/illustrations/no-plugins.tsx +72 -0
- package/src/components/molecules/illustrations/no-results.tsx +69 -0
- package/src/components/molecules/illustrations/not-found.tsx +48 -0
- package/src/components/molecules/illustrations/offline.tsx +43 -0
- package/src/components/molecules/illustrations/pending-approval.tsx +56 -0
- package/src/components/molecules/illustrations/upload-dropzone.tsx +59 -0
- package/src/components/molecules/money.tsx +91 -0
- package/src/components/{custom → molecules}/multi-select.tsx +2 -2
- package/src/components/molecules/page-header.tsx +80 -0
- package/src/components/molecules/password-input.tsx +47 -0
- package/src/components/molecules/relative-time.tsx +81 -0
- package/src/components/molecules/stat-card.tsx +134 -0
- package/src/components/molecules/state-views/empty-state.tsx +75 -0
- package/src/components/molecules/state-views/error-state.tsx +91 -0
- package/src/components/molecules/state-views/loading-state.tsx +80 -0
- package/src/components/molecules/status-badge.tsx +100 -0
- package/src/hooks/use-copy.ts +51 -0
- package/src/lib/base-ui.ts +1 -1
- package/src/lib/format-relative.ts +41 -0
- package/src/lib/state-dictionary.ts +137 -0
- package/src/components/ui/checkbox.tsx +0 -30
- package/src/components/ui/skeleton.tsx +0 -13
- /package/src/components/{ui → atoms}/accordion.tsx +0 -0
- /package/src/components/{ui → atoms}/alert.tsx +0 -0
- /package/src/components/{ui → atoms}/aspect-ratio.tsx +0 -0
- /package/src/components/{ui → atoms}/avatar.tsx +0 -0
- /package/src/components/{ui → atoms}/breadcrumb.tsx +0 -0
- /package/src/components/{ui → atoms}/chart.tsx +0 -0
- /package/src/components/{ui → atoms}/collapsible.tsx +0 -0
- /package/src/components/{ui → atoms}/direction.tsx +0 -0
- /package/src/components/{ui → atoms}/kbd.tsx +0 -0
- /package/src/components/{ui → atoms}/label.tsx +0 -0
- /package/src/components/{ui → atoms}/progress.tsx +0 -0
- /package/src/components/{ui → atoms}/radio-group.tsx +0 -0
- /package/src/components/{ui → atoms}/resizable.tsx +0 -0
- /package/src/components/{ui → atoms}/scroll-area.tsx +0 -0
- /package/src/components/{ui → atoms}/separator.tsx +0 -0
- /package/src/components/{ui → atoms}/spinner.tsx +0 -0
- /package/src/components/{ui → atoms}/table.tsx +0 -0
|
@@ -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 };
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { Column, ColumnDef, RowSelectionState, Table } from '@tanstack/react-table';
|
|
2
|
+
import type { ControlledState } from '@vendure-io/ui/components/molecules/data-table/data-table-types';
|
|
3
|
+
|
|
4
|
+
// Pure, TanStack-shaped logic for the DataTable core. No JSX and no React state,
|
|
5
|
+
// so each function is unit-testable in isolation (the getRange/clampPage
|
|
6
|
+
// precedent from table-pagination.tsx).
|
|
7
|
+
|
|
8
|
+
// biome-ignore lint/suspicious/noExplicitAny: matches TanStack's ColumnDef value-type convention
|
|
9
|
+
type AnyColumnDef<TData> = ColumnDef<TData, any>;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A capability config is controlled when it carries a `value`. Controlled means
|
|
13
|
+
* the prop is the source of truth on every render; uncontrolled (`defaultValue`
|
|
14
|
+
* or nothing) means the core owns the state.
|
|
15
|
+
*/
|
|
16
|
+
export function isControlled<T>(config: ControlledState<T> | undefined): boolean {
|
|
17
|
+
return config?.value !== undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Prepend a `select` column and append an `actions` column around the
|
|
22
|
+
* consumer's data columns, keeping `columns` about data only. The display
|
|
23
|
+
* columns are built in `data-table.tsx` (they render JSX); this helper owns the
|
|
24
|
+
* ordering so it can be tested without a DOM.
|
|
25
|
+
*/
|
|
26
|
+
export function buildDisplayColumns<TData>(
|
|
27
|
+
columns: AnyColumnDef<TData>[],
|
|
28
|
+
display: { select?: AnyColumnDef<TData>; actions?: AnyColumnDef<TData> },
|
|
29
|
+
): AnyColumnDef<TData>[] {
|
|
30
|
+
return [
|
|
31
|
+
...(display.select ? [display.select] : []),
|
|
32
|
+
...columns,
|
|
33
|
+
...(display.actions ? [display.actions] : []),
|
|
34
|
+
];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Hideable data columns for the column-visibility menu. Group columns are not
|
|
39
|
+
* listed because their visibility is derived from leaf columns.
|
|
40
|
+
*/
|
|
41
|
+
export function getHideableLeafColumns<TData>(table: Table<TData>): Column<TData, unknown>[] {
|
|
42
|
+
return table.getAllLeafColumns().filter((column) => column.getCanHide());
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Resolve a render-prop-or-node slot against the live table instance. */
|
|
46
|
+
export function resolveSlot<TData, TSlot>(
|
|
47
|
+
slot: TSlot | ((table: Table<TData>) => TSlot) | undefined,
|
|
48
|
+
table: Table<TData>,
|
|
49
|
+
): TSlot | undefined {
|
|
50
|
+
return typeof slot === 'function' ? (slot as (table: Table<TData>) => TSlot)(table) : slot;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Materialise the current selection into row originals via the cross-page cache.
|
|
55
|
+
* Selected ids missing from the cache (never-seen pages) are skipped rather than
|
|
56
|
+
* yielding `undefined` holes.
|
|
57
|
+
*/
|
|
58
|
+
export function getSelectedOriginals<TData>(
|
|
59
|
+
selection: RowSelectionState,
|
|
60
|
+
cache: Map<string, TData>,
|
|
61
|
+
): TData[] {
|
|
62
|
+
const originals: TData[] = [];
|
|
63
|
+
for (const id of getSelectedRowIds(selection)) {
|
|
64
|
+
const original = cache.get(id);
|
|
65
|
+
if (original !== undefined) originals.push(original);
|
|
66
|
+
}
|
|
67
|
+
return originals;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The ids flagged `true` in a `RowSelectionState`. */
|
|
71
|
+
export function getSelectedRowIds(selection: RowSelectionState): string[] {
|
|
72
|
+
return Object.keys(selection).filter((id) => selection[id]);
|
|
73
|
+
}
|