@vendure-io/ui 2.0.0-beta.0 → 2.0.0-beta.10
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 +6 -3
- package/package.json +7 -7
- package/src/components/atoms/button.tsx +5 -1
- package/src/components/atoms/card.tsx +1 -1
- package/src/components/atoms/scroll-area.tsx +4 -1
- package/src/components/atoms/sidebar.tsx +26 -11
- package/src/components/atoms/table.tsx +1 -1
- package/src/components/molecules/anonymized-token.tsx +2 -2
- package/src/components/molecules/app-shell.tsx +110 -0
- package/src/components/molecules/code-block.tsx +996 -0
- package/src/components/molecules/copy-feedback-provider.tsx +33 -0
- package/src/components/molecules/copyable-text.tsx +30 -7
- package/src/components/molecules/data-table/data-table.tsx +87 -85
- package/src/components/molecules/date-picker.tsx +117 -0
- package/src/components/molecules/date-range-picker.tsx +151 -0
- package/src/components/molecules/date-time-picker.tsx +131 -0
- package/src/components/molecules/file-dropzone.tsx +261 -0
- package/src/components/molecules/id-chip.tsx +1 -1
- package/src/components/molecules/skip-link.tsx +36 -0
- package/src/components/molecules/state-views/loading-state.tsx +7 -3
- package/src/lib/date-value.ts +48 -0
- package/src/lib/highlight.ts +141 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { createContext, type ReactNode, useContext, useMemo } from 'react';
|
|
4
|
+
|
|
5
|
+
// Function props can't cross a server→client boundary, so a copy surface
|
|
6
|
+
// rendered from RSC (e.g. MDX docs) could never receive an `onCopied` callback
|
|
7
|
+
// for toast wiring. This context is the RSC-safe alternative: mount
|
|
8
|
+
// CopyFeedbackProvider once in a client component (wire your toast there — the
|
|
9
|
+
// DS never toasts), and copy surfaces resolve their feedback in a fixed order —
|
|
10
|
+
// explicit prop → this context → nothing beyond the built-in copied icon.
|
|
11
|
+
interface CopyFeedbackContextValue {
|
|
12
|
+
/** Called after a successful copy. Wire your toast here — the DS never toasts. */
|
|
13
|
+
onCopied?: () => void;
|
|
14
|
+
/** Called when the clipboard write fails. */
|
|
15
|
+
onCopyError?: (error: Error) => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const CopyFeedbackContext = createContext<CopyFeedbackContextValue>({});
|
|
19
|
+
|
|
20
|
+
function CopyFeedbackProvider({
|
|
21
|
+
children,
|
|
22
|
+
onCopied,
|
|
23
|
+
onCopyError,
|
|
24
|
+
}: CopyFeedbackContextValue & { children: ReactNode }) {
|
|
25
|
+
const value = useMemo(() => ({ onCopied, onCopyError }), [onCopied, onCopyError]);
|
|
26
|
+
return <CopyFeedbackContext.Provider value={value}>{children}</CopyFeedbackContext.Provider>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function useCopyFeedback(): CopyFeedbackContextValue {
|
|
30
|
+
return useContext(CopyFeedbackContext);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export { CopyFeedbackProvider, useCopyFeedback, type CopyFeedbackContextValue };
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { Button } from '@vendure-io/ui/components/atoms/button';
|
|
4
|
+
import { useCopyFeedback } from '@vendure-io/ui/components/molecules/copy-feedback-provider';
|
|
4
5
|
import { useCopy } from '@vendure-io/ui/hooks/use-copy';
|
|
5
6
|
import { cn } from '@vendure-io/ui/lib/utils';
|
|
6
7
|
import { CheckIcon, CopyIcon } from 'lucide-react';
|
|
@@ -11,9 +12,16 @@ interface CopyButtonProps extends Omit<React.ComponentProps<typeof Button>, 'val
|
|
|
11
12
|
value: string;
|
|
12
13
|
/** How long the check-mark feedback stays visible, in ms. @default 2000 */
|
|
13
14
|
timeout?: number;
|
|
14
|
-
/**
|
|
15
|
+
/**
|
|
16
|
+
* Called after a successful copy. Wire your toast here — the DS never toasts.
|
|
17
|
+
* Falls back to `CopyFeedbackProvider` when omitted (the RSC-safe path, since
|
|
18
|
+
* function props can't be passed from server components).
|
|
19
|
+
*/
|
|
15
20
|
onCopied?: () => void;
|
|
16
|
-
/**
|
|
21
|
+
/**
|
|
22
|
+
* Called when the clipboard write fails (e.g. permissions, insecure context).
|
|
23
|
+
* Falls back to `CopyFeedbackProvider` when omitted.
|
|
24
|
+
*/
|
|
17
25
|
onCopyError?: (error: Error) => void;
|
|
18
26
|
/** Accessible label before copying. @default "Copy" */
|
|
19
27
|
copyLabel?: string;
|
|
@@ -40,6 +48,7 @@ function CopyButton({
|
|
|
40
48
|
...props
|
|
41
49
|
}: CopyButtonProps) {
|
|
42
50
|
const { copied, copy } = useCopy({ timeout });
|
|
51
|
+
const copyFeedback = useCopyFeedback();
|
|
43
52
|
|
|
44
53
|
return (
|
|
45
54
|
<Button
|
|
@@ -53,8 +62,9 @@ function CopyButton({
|
|
|
53
62
|
onClick?.(event);
|
|
54
63
|
if (event.defaultPrevented) return;
|
|
55
64
|
const ok = await copy(value);
|
|
56
|
-
if (ok) onCopied?.();
|
|
57
|
-
else
|
|
65
|
+
if (ok) (onCopied ?? copyFeedback.onCopied)?.();
|
|
66
|
+
else
|
|
67
|
+
(onCopyError ?? copyFeedback.onCopyError)?.(new Error('Failed to copy to the clipboard'));
|
|
58
68
|
}}
|
|
59
69
|
{...props}
|
|
60
70
|
>
|
|
@@ -71,10 +81,14 @@ interface CopyableTextProps {
|
|
|
71
81
|
className?: string;
|
|
72
82
|
/** How long the check-mark feedback stays visible, in ms. @default 2000 */
|
|
73
83
|
timeout?: number;
|
|
74
|
-
/** Called after a successful copy. Wire your toast here — the DS never toasts. */
|
|
84
|
+
/** Called after a successful copy. Wire your toast here — the DS never toasts. Falls back to `CopyFeedbackProvider`. */
|
|
75
85
|
onCopied?: () => void;
|
|
76
|
-
/** Called when the clipboard write fails. */
|
|
86
|
+
/** Called when the clipboard write fails. Falls back to `CopyFeedbackProvider`. */
|
|
77
87
|
onCopyError?: (error: Error) => void;
|
|
88
|
+
/** Accessible label before copying, forwarded to the inner `CopyButton`. @default "Copy" */
|
|
89
|
+
copyLabel?: string;
|
|
90
|
+
/** Accessible label shown while the copied state is active, forwarded to the inner `CopyButton`. @default "Copied" */
|
|
91
|
+
copiedLabel?: string;
|
|
78
92
|
}
|
|
79
93
|
|
|
80
94
|
/**
|
|
@@ -89,11 +103,20 @@ function CopyableText({
|
|
|
89
103
|
timeout,
|
|
90
104
|
onCopied,
|
|
91
105
|
onCopyError,
|
|
106
|
+
copyLabel,
|
|
107
|
+
copiedLabel,
|
|
92
108
|
}: CopyableTextProps) {
|
|
93
109
|
return (
|
|
94
110
|
<span data-slot="copyable-text" className={cn('inline-flex items-center gap-1.5', className)}>
|
|
95
111
|
{children ?? value}
|
|
96
|
-
<CopyButton
|
|
112
|
+
<CopyButton
|
|
113
|
+
value={value}
|
|
114
|
+
timeout={timeout}
|
|
115
|
+
onCopied={onCopied}
|
|
116
|
+
onCopyError={onCopyError}
|
|
117
|
+
copyLabel={copyLabel}
|
|
118
|
+
copiedLabel={copiedLabel}
|
|
119
|
+
/>
|
|
97
120
|
</span>
|
|
98
121
|
);
|
|
99
122
|
}
|
|
@@ -367,95 +367,97 @@ function DataTable<TData>({
|
|
|
367
367
|
<DataTableBulkActions table={table} cache={selectionCache} render={bulkActions} />
|
|
368
368
|
)}
|
|
369
369
|
|
|
370
|
-
<
|
|
371
|
-
<
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
{headerGroup.
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
))}
|
|
399
|
-
</TableHeader>
|
|
400
|
-
<TableBody>
|
|
401
|
-
{isLoading && rows.length === 0 ? (
|
|
402
|
-
Array.from({ length: skeletonRowCount }).map((_, rowIndex) => (
|
|
403
|
-
<TableRow key={`skeleton-${rowIndex}`}>
|
|
404
|
-
{Array.from({ length: Math.max(columnCount, 1) }).map((__, cellIndex) => (
|
|
405
|
-
<TableCell key={`skeleton-cell-${cellIndex}`}>
|
|
406
|
-
<Skeleton className="h-4 w-full" />
|
|
407
|
-
</TableCell>
|
|
408
|
-
))}
|
|
370
|
+
<div className="bg-card overflow-hidden rounded-xl border border-border/60">
|
|
371
|
+
<Table>
|
|
372
|
+
<TableHeader>
|
|
373
|
+
{table.getHeaderGroups().map((headerGroup) => (
|
|
374
|
+
<TableRow key={headerGroup.id} className="group/header-row">
|
|
375
|
+
{headerGroup.headers.map((headerCell) => {
|
|
376
|
+
const content = headerCell.isPlaceholder
|
|
377
|
+
? null
|
|
378
|
+
: flexRender(headerCell.column.columnDef.header, headerCell.getContext());
|
|
379
|
+
return (
|
|
380
|
+
<TableHead
|
|
381
|
+
key={headerCell.id}
|
|
382
|
+
aria-sort={ariaSort(sorting != null, headerCell.column)}
|
|
383
|
+
className={columnClass(headerCell.column.id)}
|
|
384
|
+
>
|
|
385
|
+
{sorting != null && !headerCell.isPlaceholder ? (
|
|
386
|
+
<DataTableColumnHeader
|
|
387
|
+
column={headerCell.column}
|
|
388
|
+
sortLabel={l.sortLabel(headerLabelText(headerCell.column))}
|
|
389
|
+
>
|
|
390
|
+
{content}
|
|
391
|
+
</DataTableColumnHeader>
|
|
392
|
+
) : (
|
|
393
|
+
content
|
|
394
|
+
)}
|
|
395
|
+
</TableHead>
|
|
396
|
+
);
|
|
397
|
+
})}
|
|
409
398
|
</TableRow>
|
|
410
|
-
))
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
</TableRow>
|
|
420
|
-
) : (
|
|
421
|
-
bodyRows.map((row) => {
|
|
422
|
-
const rowNode = (
|
|
423
|
-
<TableRow
|
|
424
|
-
className="group/row"
|
|
425
|
-
data-state={row.getIsSelected() ? 'selected' : undefined}
|
|
426
|
-
>
|
|
427
|
-
{row.getVisibleCells().map((cell) => (
|
|
428
|
-
<TableCell key={cell.id} className={columnClass(cell.column.id)}>
|
|
429
|
-
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
399
|
+
))}
|
|
400
|
+
</TableHeader>
|
|
401
|
+
<TableBody>
|
|
402
|
+
{isLoading && rows.length === 0 ? (
|
|
403
|
+
Array.from({ length: skeletonRowCount }).map((_, rowIndex) => (
|
|
404
|
+
<TableRow key={`skeleton-${rowIndex}`}>
|
|
405
|
+
{Array.from({ length: Math.max(columnCount, 1) }).map((__, cellIndex) => (
|
|
406
|
+
<TableCell key={`skeleton-cell-${cellIndex}`}>
|
|
407
|
+
<Skeleton className="h-4 w-full" />
|
|
430
408
|
</TableCell>
|
|
431
409
|
))}
|
|
432
410
|
</TableRow>
|
|
433
|
-
)
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
)
|
|
445
|
-
rowNode
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
411
|
+
))
|
|
412
|
+
) : bodyRows.length === 0 ? (
|
|
413
|
+
<TableRow className="hover:bg-transparent">
|
|
414
|
+
<TableCell
|
|
415
|
+
colSpan={Math.max(columnCount, 1)}
|
|
416
|
+
className="text-muted-foreground h-24 text-center"
|
|
417
|
+
>
|
|
418
|
+
{emptyState ?? l.empty}
|
|
419
|
+
</TableCell>
|
|
420
|
+
</TableRow>
|
|
421
|
+
) : (
|
|
422
|
+
bodyRows.map((row) => {
|
|
423
|
+
const rowNode = (
|
|
424
|
+
<TableRow
|
|
425
|
+
className="group/row"
|
|
426
|
+
data-state={row.getIsSelected() ? 'selected' : undefined}
|
|
427
|
+
>
|
|
428
|
+
{row.getVisibleCells().map((cell) => (
|
|
429
|
+
<TableCell key={cell.id} className={columnClass(cell.column.id)}>
|
|
430
|
+
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
431
|
+
</TableCell>
|
|
432
|
+
))}
|
|
433
|
+
</TableRow>
|
|
434
|
+
);
|
|
435
|
+
// Right-click accelerator: the row itself is the context-menu
|
|
436
|
+
// trigger, so the consumer supplies only the items and the core
|
|
437
|
+
// owns the menu chrome.
|
|
438
|
+
const defaultRow = contextActions ? (
|
|
439
|
+
<ContextMenu>
|
|
440
|
+
<ContextMenuTrigger render={rowNode} />
|
|
441
|
+
<ContextMenuContent>
|
|
442
|
+
{contextActions(row.original, { row, table })}
|
|
443
|
+
</ContextMenuContent>
|
|
444
|
+
</ContextMenu>
|
|
445
|
+
) : (
|
|
446
|
+
rowNode
|
|
447
|
+
);
|
|
448
|
+
// Row-render seam: consumers can swap the default row for a
|
|
449
|
+
// full-width utility row or a per-row wrapper the cell grid can't
|
|
450
|
+
// express. Returning `defaultRow` keeps the built-in rendering.
|
|
451
|
+
return (
|
|
452
|
+
<React.Fragment key={row.id}>
|
|
453
|
+
{renderRow ? renderRow(row, { table, columnCount, defaultRow }) : defaultRow}
|
|
454
|
+
</React.Fragment>
|
|
455
|
+
);
|
|
456
|
+
})
|
|
457
|
+
)}
|
|
458
|
+
</TableBody>
|
|
459
|
+
</Table>
|
|
460
|
+
</div>
|
|
459
461
|
|
|
460
462
|
{pagination && (
|
|
461
463
|
<TablePagination
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Button } from '@vendure-io/ui/components/atoms/button';
|
|
4
|
+
import { Calendar } from '@vendure-io/ui/components/atoms/calendar';
|
|
5
|
+
import { Popover, PopoverContent, PopoverTrigger } from '@vendure-io/ui/components/atoms/popover';
|
|
6
|
+
import { useFormatSettings } from '@vendure-io/ui/components/molecules/format-provider';
|
|
7
|
+
import {
|
|
8
|
+
formatCalendarDate,
|
|
9
|
+
formatDateLabel,
|
|
10
|
+
parseCalendarDate,
|
|
11
|
+
} from '@vendure-io/ui/lib/date-value';
|
|
12
|
+
import { cn } from '@vendure-io/ui/lib/utils';
|
|
13
|
+
import { CalendarIcon, XIcon } from 'lucide-react';
|
|
14
|
+
import * as React from 'react';
|
|
15
|
+
|
|
16
|
+
type CalendarProps = Omit<
|
|
17
|
+
React.ComponentProps<typeof Calendar>,
|
|
18
|
+
'mode' | 'selected' | 'onSelect' | 'disabled' | 'defaultMonth'
|
|
19
|
+
>;
|
|
20
|
+
|
|
21
|
+
interface DatePickerProps extends Omit<React.ComponentProps<'div'>, 'defaultValue' | 'onChange'> {
|
|
22
|
+
/** Local calendar date (`YYYY-MM-DD`), never an instant. */
|
|
23
|
+
value?: string;
|
|
24
|
+
onValueChange?: (value: string | undefined) => void;
|
|
25
|
+
min?: string;
|
|
26
|
+
max?: string;
|
|
27
|
+
placeholder?: React.ReactNode;
|
|
28
|
+
clearable?: boolean;
|
|
29
|
+
disabled?: boolean;
|
|
30
|
+
invalid?: boolean;
|
|
31
|
+
name?: string;
|
|
32
|
+
id?: string;
|
|
33
|
+
calendarProps?: CalendarProps;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Date-only picker whose value cannot shift when serialized across time zones. */
|
|
37
|
+
function DatePicker({
|
|
38
|
+
value,
|
|
39
|
+
onValueChange,
|
|
40
|
+
min,
|
|
41
|
+
max,
|
|
42
|
+
placeholder = 'Pick a date',
|
|
43
|
+
clearable = true,
|
|
44
|
+
disabled,
|
|
45
|
+
invalid,
|
|
46
|
+
name,
|
|
47
|
+
id,
|
|
48
|
+
calendarProps,
|
|
49
|
+
className,
|
|
50
|
+
...props
|
|
51
|
+
}: DatePickerProps) {
|
|
52
|
+
const [open, setOpen] = React.useState(false);
|
|
53
|
+
const { locale } = useFormatSettings();
|
|
54
|
+
const selected = parseCalendarDate(value);
|
|
55
|
+
const minDate = parseCalendarDate(min);
|
|
56
|
+
const maxDate = parseCalendarDate(max);
|
|
57
|
+
const disabledMatchers = [
|
|
58
|
+
...(minDate ? [{ before: minDate }] : []),
|
|
59
|
+
...(maxDate ? [{ after: maxDate }] : []),
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<div
|
|
64
|
+
data-slot="date-picker"
|
|
65
|
+
className={cn('flex min-w-0 items-center gap-1', className)}
|
|
66
|
+
{...props}
|
|
67
|
+
>
|
|
68
|
+
{name ? <input type="hidden" name={name} value={value ?? ''} /> : null}
|
|
69
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
70
|
+
<PopoverTrigger
|
|
71
|
+
render={
|
|
72
|
+
<Button
|
|
73
|
+
id={id}
|
|
74
|
+
type="button"
|
|
75
|
+
variant="outline"
|
|
76
|
+
disabled={disabled}
|
|
77
|
+
aria-invalid={invalid || undefined}
|
|
78
|
+
className="min-w-0 flex-1 justify-start font-normal"
|
|
79
|
+
/>
|
|
80
|
+
}
|
|
81
|
+
>
|
|
82
|
+
<CalendarIcon />
|
|
83
|
+
<span className={cn('truncate', !selected && 'text-muted-foreground')}>
|
|
84
|
+
{selected ? formatDateLabel(selected, locale) : placeholder}
|
|
85
|
+
</span>
|
|
86
|
+
</PopoverTrigger>
|
|
87
|
+
<PopoverContent className="w-auto p-0" align="start">
|
|
88
|
+
<Calendar
|
|
89
|
+
{...calendarProps}
|
|
90
|
+
mode="single"
|
|
91
|
+
selected={selected}
|
|
92
|
+
defaultMonth={selected ?? minDate}
|
|
93
|
+
disabled={disabledMatchers}
|
|
94
|
+
onSelect={(date) => {
|
|
95
|
+
onValueChange?.(date ? formatCalendarDate(date) : undefined);
|
|
96
|
+
if (date) setOpen(false);
|
|
97
|
+
}}
|
|
98
|
+
/>
|
|
99
|
+
</PopoverContent>
|
|
100
|
+
</Popover>
|
|
101
|
+
{clearable && selected ? (
|
|
102
|
+
<Button
|
|
103
|
+
type="button"
|
|
104
|
+
variant="ghost"
|
|
105
|
+
size="icon-sm"
|
|
106
|
+
disabled={disabled}
|
|
107
|
+
aria-label="Clear date"
|
|
108
|
+
onClick={() => onValueChange?.(undefined)}
|
|
109
|
+
>
|
|
110
|
+
<XIcon />
|
|
111
|
+
</Button>
|
|
112
|
+
) : null}
|
|
113
|
+
</div>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export { DatePicker, type DatePickerProps };
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Button } from '@vendure-io/ui/components/atoms/button';
|
|
4
|
+
import { Calendar } from '@vendure-io/ui/components/atoms/calendar';
|
|
5
|
+
import { Popover, PopoverContent, PopoverTrigger } from '@vendure-io/ui/components/atoms/popover';
|
|
6
|
+
import { useFormatSettings } from '@vendure-io/ui/components/molecules/format-provider';
|
|
7
|
+
import {
|
|
8
|
+
formatCalendarDate,
|
|
9
|
+
formatDateRangeLabel,
|
|
10
|
+
parseCalendarDate,
|
|
11
|
+
} from '@vendure-io/ui/lib/date-value';
|
|
12
|
+
import { cn } from '@vendure-io/ui/lib/utils';
|
|
13
|
+
import { CalendarRangeIcon, XIcon } from 'lucide-react';
|
|
14
|
+
import * as React from 'react';
|
|
15
|
+
import type { DateRange } from 'react-day-picker';
|
|
16
|
+
|
|
17
|
+
type CalendarProps = Omit<
|
|
18
|
+
React.ComponentProps<typeof Calendar>,
|
|
19
|
+
'mode' | 'selected' | 'onSelect' | 'defaultMonth'
|
|
20
|
+
>;
|
|
21
|
+
|
|
22
|
+
interface DateRangeValue {
|
|
23
|
+
from?: string;
|
|
24
|
+
to?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface DateRangePreset {
|
|
28
|
+
id: string;
|
|
29
|
+
label: React.ReactNode;
|
|
30
|
+
value: DateRangeValue | (() => DateRangeValue);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface DateRangePickerProps
|
|
34
|
+
extends Omit<React.ComponentProps<'div'>, 'defaultValue' | 'onChange'> {
|
|
35
|
+
/** Local calendar-date boundaries (`YYYY-MM-DD`), inclusive. */
|
|
36
|
+
value?: DateRangeValue;
|
|
37
|
+
onValueChange?: (value: DateRangeValue | undefined) => void;
|
|
38
|
+
placeholder?: React.ReactNode;
|
|
39
|
+
clearable?: boolean;
|
|
40
|
+
disabled?: boolean;
|
|
41
|
+
invalid?: boolean;
|
|
42
|
+
presets?: readonly DateRangePreset[];
|
|
43
|
+
calendarProps?: CalendarProps;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function resolvePreset(value: DateRangePreset['value']): DateRangeValue {
|
|
47
|
+
return typeof value === 'function' ? value() : value;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function DateRangePicker({
|
|
51
|
+
value,
|
|
52
|
+
onValueChange,
|
|
53
|
+
placeholder = 'Pick a date range',
|
|
54
|
+
clearable = true,
|
|
55
|
+
disabled,
|
|
56
|
+
invalid,
|
|
57
|
+
presets = [],
|
|
58
|
+
calendarProps,
|
|
59
|
+
className,
|
|
60
|
+
...props
|
|
61
|
+
}: DateRangePickerProps) {
|
|
62
|
+
const [open, setOpen] = React.useState(false);
|
|
63
|
+
const { locale } = useFormatSettings();
|
|
64
|
+
const selected: DateRange = {
|
|
65
|
+
from: parseCalendarDate(value?.from),
|
|
66
|
+
to: parseCalendarDate(value?.to),
|
|
67
|
+
};
|
|
68
|
+
const hasValue = Boolean(selected.from);
|
|
69
|
+
|
|
70
|
+
function commitRange(range: DateRange | undefined) {
|
|
71
|
+
if (!range?.from) {
|
|
72
|
+
onValueChange?.(undefined);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
onValueChange?.({
|
|
76
|
+
from: formatCalendarDate(range.from),
|
|
77
|
+
to: range.to ? formatCalendarDate(range.to) : undefined,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<div
|
|
83
|
+
data-slot="date-range-picker"
|
|
84
|
+
className={cn('flex min-w-0 items-center gap-1', className)}
|
|
85
|
+
{...props}
|
|
86
|
+
>
|
|
87
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
88
|
+
<PopoverTrigger
|
|
89
|
+
render={
|
|
90
|
+
<Button
|
|
91
|
+
type="button"
|
|
92
|
+
variant="outline"
|
|
93
|
+
disabled={disabled}
|
|
94
|
+
aria-invalid={invalid || undefined}
|
|
95
|
+
className="min-w-0 flex-1 justify-start font-normal"
|
|
96
|
+
/>
|
|
97
|
+
}
|
|
98
|
+
>
|
|
99
|
+
<CalendarRangeIcon />
|
|
100
|
+
<span className={cn('truncate', !hasValue && 'text-muted-foreground')}>
|
|
101
|
+
{selected.from ? formatDateRangeLabel(selected.from, selected.to, locale) : placeholder}
|
|
102
|
+
</span>
|
|
103
|
+
</PopoverTrigger>
|
|
104
|
+
<PopoverContent className="w-auto p-0" align="start">
|
|
105
|
+
<div className={cn('flex', presets.length > 0 && 'divide-x')}>
|
|
106
|
+
{presets.length > 0 ? (
|
|
107
|
+
<div className="flex w-40 flex-col gap-1 p-2">
|
|
108
|
+
{presets.map((preset) => (
|
|
109
|
+
<Button
|
|
110
|
+
key={preset.id}
|
|
111
|
+
type="button"
|
|
112
|
+
variant="ghost"
|
|
113
|
+
size="sm"
|
|
114
|
+
className="justify-start font-normal"
|
|
115
|
+
onClick={() => {
|
|
116
|
+
onValueChange?.(resolvePreset(preset.value));
|
|
117
|
+
setOpen(false);
|
|
118
|
+
}}
|
|
119
|
+
>
|
|
120
|
+
{preset.label}
|
|
121
|
+
</Button>
|
|
122
|
+
))}
|
|
123
|
+
</div>
|
|
124
|
+
) : null}
|
|
125
|
+
<Calendar
|
|
126
|
+
{...calendarProps}
|
|
127
|
+
mode="range"
|
|
128
|
+
selected={selected}
|
|
129
|
+
defaultMonth={selected.from}
|
|
130
|
+
onSelect={commitRange}
|
|
131
|
+
/>
|
|
132
|
+
</div>
|
|
133
|
+
</PopoverContent>
|
|
134
|
+
</Popover>
|
|
135
|
+
{clearable && hasValue ? (
|
|
136
|
+
<Button
|
|
137
|
+
type="button"
|
|
138
|
+
variant="ghost"
|
|
139
|
+
size="icon-sm"
|
|
140
|
+
disabled={disabled}
|
|
141
|
+
aria-label="Clear date range"
|
|
142
|
+
onClick={() => onValueChange?.(undefined)}
|
|
143
|
+
>
|
|
144
|
+
<XIcon />
|
|
145
|
+
</Button>
|
|
146
|
+
) : null}
|
|
147
|
+
</div>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export { DateRangePicker, type DateRangePickerProps, type DateRangePreset, type DateRangeValue };
|