@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.
- package/README.md +5 -5
- package/package.json +9 -4
- 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/molecules/combobox-free-text.tsx +160 -0
- 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 +2 -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,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 };
|