@vendure-io/ui 2.0.0-beta.3 → 2.0.0-beta.5
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 +3 -7
- package/src/components/atoms/button.tsx +4 -0
- package/src/components/atoms/scroll-area.tsx +4 -1
- package/src/components/molecules/app-shell.tsx +34 -6
- 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/skip-link.tsx +36 -0
- package/src/lib/date-value.ts +48 -0
package/README.md
CHANGED
|
@@ -4,8 +4,8 @@ React component library for Vendure, built on [shadcn/ui](https://ui.shadcn.com/
|
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
7
|
-
```
|
|
8
|
-
|
|
7
|
+
```sh
|
|
8
|
+
bun add @vendure-io/ui
|
|
9
9
|
```
|
|
10
10
|
|
|
11
11
|
### Peer dependencies
|
|
@@ -13,7 +13,7 @@ npm install @vendure-io/ui
|
|
|
13
13
|
- `react` >= 19
|
|
14
14
|
- `react-dom` >= 19
|
|
15
15
|
|
|
16
|
-
Optional peers: `next`, `next-themes
|
|
16
|
+
Optional peers: `next`, `next-themes`
|
|
17
17
|
|
|
18
18
|
## Usage
|
|
19
19
|
|
|
@@ -32,6 +32,9 @@ import { cn } from "@vendure-io/ui/lib/utils";
|
|
|
32
32
|
| `@vendure-io/ui/lib/base-ui` | `@base-ui/react` primitive namespaces (see below) |
|
|
33
33
|
| `@vendure-io/ui/hooks/*` | Shared React hooks |
|
|
34
34
|
|
|
35
|
+
See the repository's [component guide](../../docs/ui-components.md) for the full
|
|
36
|
+
atom and molecule inventory.
|
|
37
|
+
|
|
35
38
|
## Customizing wrapper components
|
|
36
39
|
|
|
37
40
|
The components under `components/atoms/*` are thin wrappers around [`@base-ui/react`](https://base-ui.com/) primitives. To override a single subcomponent (e.g. swap `DialogTitle` for one with a different font) while re-using the rest, import the underlying primitive namespace from `@vendure-io/ui/lib/base-ui` rather than from `@base-ui/react` directly:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vendure-io/ui",
|
|
3
|
-
"version": "2.0.0-beta.
|
|
3
|
+
"version": "2.0.0-beta.5",
|
|
4
4
|
"description": "React component library for Vendure, built on shadcn/ui and Tailwind v4",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE.md",
|
|
6
6
|
"homepage": "https://github.com/vendurehq/design/tree/main/packages/ui",
|
|
@@ -33,8 +33,7 @@
|
|
|
33
33
|
"react": ">=19",
|
|
34
34
|
"react-dom": ">=19",
|
|
35
35
|
"next": ">=14",
|
|
36
|
-
"next-themes": "^0.4.6"
|
|
37
|
-
"react-hook-form": ">=7"
|
|
36
|
+
"next-themes": "^0.4.6"
|
|
38
37
|
},
|
|
39
38
|
"peerDependenciesMeta": {
|
|
40
39
|
"next": {
|
|
@@ -42,15 +41,12 @@
|
|
|
42
41
|
},
|
|
43
42
|
"next-themes": {
|
|
44
43
|
"optional": true
|
|
45
|
-
},
|
|
46
|
-
"react-hook-form": {
|
|
47
|
-
"optional": true
|
|
48
44
|
}
|
|
49
45
|
},
|
|
50
46
|
"dependencies": {
|
|
51
47
|
"@base-ui/react": "^1.2.0",
|
|
52
48
|
"@tanstack/react-table": "^8.21.3",
|
|
53
|
-
"@vendure-io/design-tokens": "^2.0.0-beta.
|
|
49
|
+
"@vendure-io/design-tokens": "^2.0.0-beta.5",
|
|
54
50
|
"class-variance-authority": "^0.7.1",
|
|
55
51
|
"clsx": "^2.1.1",
|
|
56
52
|
"cmdk": "^1.1.1",
|
|
@@ -11,6 +11,10 @@ const buttonVariants = cva(
|
|
|
11
11
|
variants: {
|
|
12
12
|
variant: {
|
|
13
13
|
default: "bg-primary text-primary-foreground hover:bg-primary/80",
|
|
14
|
+
// Identity/marketing CTA only ("Start free trial", "Upgrade to Cloud"):
|
|
15
|
+
// a deliberate brand moment, not a louder `default` primary. Brand is a
|
|
16
|
+
// signature, not a wallpaper; see Button guidance before reaching for it.
|
|
17
|
+
brand: "bg-brand text-brand-foreground hover:bg-brand/80",
|
|
14
18
|
outline:
|
|
15
19
|
"border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground",
|
|
16
20
|
secondary:
|
|
@@ -39,7 +39,10 @@ function ScrollBar({
|
|
|
39
39
|
data-orientation={orientation}
|
|
40
40
|
orientation={orientation}
|
|
41
41
|
className={cn(
|
|
42
|
-
|
|
42
|
+
// Overlay scrollbars: invisible at rest, fade in while the pointer is over
|
|
43
|
+
// the scroll area (data-hovering) or during active scrolling (data-scrolling).
|
|
44
|
+
// Base UI omits data-scrolling on programmatic scrolls, so route changes don't flash.
|
|
45
|
+
"flex touch-none p-px select-none opacity-0 transition-opacity data-hovering:opacity-100 data-scrolling:opacity-100 data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
|
|
43
46
|
className
|
|
44
47
|
)}
|
|
45
48
|
{...props}
|
|
@@ -15,13 +15,16 @@ import type * as React from 'react';
|
|
|
15
15
|
// the step instead. That asymmetry is intentional, not a gap.
|
|
16
16
|
//
|
|
17
17
|
// The sidebar slot is deliberately unopinionated: drop the full `Sidebar` atom in
|
|
18
|
-
// for a real app, or use `AppShellSidebar` for a lightweight rail.
|
|
18
|
+
// for a real app, or use `AppShellSidebar` for a lightweight rail. Content owns a
|
|
19
|
+
// header + main anatomy so every consumer gets the same scroll boundary and skip
|
|
20
|
+
// link target without coupling the design system to a router or auth provider. The
|
|
21
|
+
// shell is viewport-bound so the canvas inset remains visible while main scrolls.
|
|
19
22
|
|
|
20
23
|
function AppShell({ className, ...props }: React.ComponentProps<'div'>) {
|
|
21
24
|
return (
|
|
22
25
|
<div
|
|
23
26
|
data-slot="app-shell"
|
|
24
|
-
className={cn('bg-background text-foreground flex
|
|
27
|
+
className={cn('bg-background text-foreground flex h-svh w-full overflow-hidden', className)}
|
|
25
28
|
{...props}
|
|
26
29
|
/>
|
|
27
30
|
);
|
|
@@ -40,16 +43,29 @@ function AppShellSidebar({ className, ...props }: React.ComponentProps<'aside'>)
|
|
|
40
43
|
);
|
|
41
44
|
}
|
|
42
45
|
|
|
43
|
-
function AppShellContent({ className, ...props }: React.ComponentProps<'
|
|
46
|
+
function AppShellContent({ className, ...props }: React.ComponentProps<'div'>) {
|
|
44
47
|
return (
|
|
45
|
-
<
|
|
48
|
+
<div
|
|
46
49
|
data-slot="app-shell-content"
|
|
47
50
|
className={cn(
|
|
48
51
|
// The missing tier: step up to --surface and inset from the canvas so the
|
|
49
52
|
// shell frames the pane on all sides, including a gutter between the
|
|
50
53
|
// sidebar and the pane. Flat by design: the border carries the tier in
|
|
51
54
|
// light mode (see AGENTS.md "flat look"), so no drop shadow.
|
|
52
|
-
'bg-surface m-3 flex flex-1 flex-col overflow-hidden rounded-xl border border-border/60',
|
|
55
|
+
'bg-surface m-3 flex min-w-0 flex-1 flex-col overflow-hidden rounded-xl border border-border/60',
|
|
56
|
+
className,
|
|
57
|
+
)}
|
|
58
|
+
{...props}
|
|
59
|
+
/>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function AppShellHeader({ className, ...props }: React.ComponentProps<'header'>) {
|
|
64
|
+
return (
|
|
65
|
+
<header
|
|
66
|
+
data-slot="app-shell-header"
|
|
67
|
+
className={cn(
|
|
68
|
+
'flex h-12 shrink-0 items-center gap-2 border-b border-border/60 px-4',
|
|
53
69
|
className,
|
|
54
70
|
)}
|
|
55
71
|
{...props}
|
|
@@ -57,4 +73,16 @@ function AppShellContent({ className, ...props }: React.ComponentProps<'main'>)
|
|
|
57
73
|
);
|
|
58
74
|
}
|
|
59
75
|
|
|
60
|
-
|
|
76
|
+
function AppShellMain({ className, id = 'main-content', ...props }: React.ComponentProps<'main'>) {
|
|
77
|
+
return (
|
|
78
|
+
<main
|
|
79
|
+
id={id}
|
|
80
|
+
tabIndex={-1}
|
|
81
|
+
data-slot="app-shell-main"
|
|
82
|
+
className={cn('min-h-0 min-w-0 flex-1 overflow-auto outline-none', className)}
|
|
83
|
+
{...props}
|
|
84
|
+
/>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export { AppShell, AppShellSidebar, AppShellContent, AppShellHeader, AppShellMain };
|
|
@@ -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 };
|
|
@@ -0,0 +1,131 @@
|
|
|
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 { Input } from '@vendure-io/ui/components/atoms/input';
|
|
6
|
+
import { Popover, PopoverContent, PopoverTrigger } from '@vendure-io/ui/components/atoms/popover';
|
|
7
|
+
import { useFormatSettings } from '@vendure-io/ui/components/molecules/format-provider';
|
|
8
|
+
import { formatDateLabel, parseInstant } from '@vendure-io/ui/lib/date-value';
|
|
9
|
+
import { cn } from '@vendure-io/ui/lib/utils';
|
|
10
|
+
import { CalendarClockIcon, XIcon } from 'lucide-react';
|
|
11
|
+
import * as React from 'react';
|
|
12
|
+
|
|
13
|
+
type CalendarProps = Omit<
|
|
14
|
+
React.ComponentProps<typeof Calendar>,
|
|
15
|
+
'mode' | 'selected' | 'onSelect' | 'defaultMonth'
|
|
16
|
+
>;
|
|
17
|
+
|
|
18
|
+
interface DateTimePickerProps
|
|
19
|
+
extends Omit<React.ComponentProps<'div'>, 'defaultValue' | 'onChange'> {
|
|
20
|
+
/** ISO 8601 instant. Calendar and time controls edit it in the user's local zone. */
|
|
21
|
+
value?: string;
|
|
22
|
+
onValueChange?: (value: string | undefined) => void;
|
|
23
|
+
placeholder?: React.ReactNode;
|
|
24
|
+
clearable?: boolean;
|
|
25
|
+
disabled?: boolean;
|
|
26
|
+
invalid?: boolean;
|
|
27
|
+
name?: string;
|
|
28
|
+
id?: string;
|
|
29
|
+
calendarProps?: CalendarProps;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function DateTimePicker({
|
|
33
|
+
value,
|
|
34
|
+
onValueChange,
|
|
35
|
+
placeholder = 'Pick a date and time',
|
|
36
|
+
clearable = true,
|
|
37
|
+
disabled,
|
|
38
|
+
invalid,
|
|
39
|
+
name,
|
|
40
|
+
id,
|
|
41
|
+
calendarProps,
|
|
42
|
+
className,
|
|
43
|
+
...props
|
|
44
|
+
}: DateTimePickerProps) {
|
|
45
|
+
const [open, setOpen] = React.useState(false);
|
|
46
|
+
const { locale } = useFormatSettings();
|
|
47
|
+
const selected = parseInstant(value);
|
|
48
|
+
const timeValue = selected
|
|
49
|
+
? `${String(selected.getHours()).padStart(2, '0')}:${String(selected.getMinutes()).padStart(2, '0')}`
|
|
50
|
+
: '';
|
|
51
|
+
|
|
52
|
+
function commitDate(date: Date | undefined) {
|
|
53
|
+
if (!date) return;
|
|
54
|
+
const next = new Date(date);
|
|
55
|
+
next.setHours(selected?.getHours() ?? 0, selected?.getMinutes() ?? 0, 0, 0);
|
|
56
|
+
onValueChange?.(next.toISOString());
|
|
57
|
+
setOpen(false);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function commitTime(time: string) {
|
|
61
|
+
if (!time) return;
|
|
62
|
+
const [hoursText, minutesText] = time.split(':');
|
|
63
|
+
const hours = Number(hoursText);
|
|
64
|
+
const minutes = Number(minutesText);
|
|
65
|
+
if (!Number.isFinite(hours) || !Number.isFinite(minutes)) return;
|
|
66
|
+
const next = selected ? new Date(selected) : new Date();
|
|
67
|
+
next.setHours(hours, minutes, 0, 0);
|
|
68
|
+
onValueChange?.(next.toISOString());
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<div
|
|
73
|
+
data-slot="date-time-picker"
|
|
74
|
+
className={cn('flex min-w-0 items-center gap-2', className)}
|
|
75
|
+
{...props}
|
|
76
|
+
>
|
|
77
|
+
{name ? <input type="hidden" name={name} value={value ?? ''} /> : null}
|
|
78
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
79
|
+
<PopoverTrigger
|
|
80
|
+
render={
|
|
81
|
+
<Button
|
|
82
|
+
id={id}
|
|
83
|
+
type="button"
|
|
84
|
+
variant="outline"
|
|
85
|
+
disabled={disabled}
|
|
86
|
+
aria-invalid={invalid || undefined}
|
|
87
|
+
className="min-w-0 flex-1 justify-start font-normal"
|
|
88
|
+
/>
|
|
89
|
+
}
|
|
90
|
+
>
|
|
91
|
+
<CalendarClockIcon />
|
|
92
|
+
<span className={cn('truncate', !selected && 'text-muted-foreground')}>
|
|
93
|
+
{selected ? formatDateLabel(selected, locale) : placeholder}
|
|
94
|
+
</span>
|
|
95
|
+
</PopoverTrigger>
|
|
96
|
+
<PopoverContent className="w-auto p-0" align="start">
|
|
97
|
+
<Calendar
|
|
98
|
+
{...calendarProps}
|
|
99
|
+
mode="single"
|
|
100
|
+
selected={selected}
|
|
101
|
+
defaultMonth={selected}
|
|
102
|
+
onSelect={commitDate}
|
|
103
|
+
/>
|
|
104
|
+
</PopoverContent>
|
|
105
|
+
</Popover>
|
|
106
|
+
<Input
|
|
107
|
+
type="time"
|
|
108
|
+
aria-label="Time"
|
|
109
|
+
aria-invalid={invalid || undefined}
|
|
110
|
+
value={timeValue}
|
|
111
|
+
disabled={disabled || !selected}
|
|
112
|
+
className="w-28"
|
|
113
|
+
onChange={(event) => commitTime(event.currentTarget.value)}
|
|
114
|
+
/>
|
|
115
|
+
{clearable && selected ? (
|
|
116
|
+
<Button
|
|
117
|
+
type="button"
|
|
118
|
+
variant="ghost"
|
|
119
|
+
size="icon-sm"
|
|
120
|
+
disabled={disabled}
|
|
121
|
+
aria-label="Clear date and time"
|
|
122
|
+
onClick={() => onValueChange?.(undefined)}
|
|
123
|
+
>
|
|
124
|
+
<XIcon />
|
|
125
|
+
</Button>
|
|
126
|
+
) : null}
|
|
127
|
+
</div>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export { DateTimePicker, type DateTimePickerProps };
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Button } from '@vendure-io/ui/components/atoms/button';
|
|
4
|
+
import { cn } from '@vendure-io/ui/lib/utils';
|
|
5
|
+
import { FileIcon, UploadCloudIcon, XIcon } from 'lucide-react';
|
|
6
|
+
import * as React from 'react';
|
|
7
|
+
|
|
8
|
+
type FileValidationResult = string | readonly string[] | null | undefined;
|
|
9
|
+
type FileValidator = (file: File) => FileValidationResult | Promise<FileValidationResult>;
|
|
10
|
+
|
|
11
|
+
interface FileRejection {
|
|
12
|
+
file: File;
|
|
13
|
+
messages: string[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface FileDropzoneProps extends Omit<React.ComponentProps<'div'>, 'onChange'> {
|
|
17
|
+
value?: readonly File[];
|
|
18
|
+
onValueChange?: (files: File[]) => void;
|
|
19
|
+
onRejected?: (rejections: FileRejection[]) => void;
|
|
20
|
+
accept?: string;
|
|
21
|
+
multiple?: boolean;
|
|
22
|
+
maxFiles?: number;
|
|
23
|
+
maxSize?: number;
|
|
24
|
+
validateFile?: FileValidator;
|
|
25
|
+
disabled?: boolean;
|
|
26
|
+
required?: boolean;
|
|
27
|
+
name?: string;
|
|
28
|
+
id?: string;
|
|
29
|
+
label?: React.ReactNode;
|
|
30
|
+
description?: React.ReactNode;
|
|
31
|
+
emptyLabel?: React.ReactNode;
|
|
32
|
+
dragLabel?: React.ReactNode;
|
|
33
|
+
renderFile?: (file: File, remove: () => void) => React.ReactNode;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function fileMatchesAccept(file: File, accept: string | undefined): boolean {
|
|
37
|
+
if (!accept?.trim()) return true;
|
|
38
|
+
const fileName = file.name.toLowerCase();
|
|
39
|
+
const mime = file.type.toLowerCase();
|
|
40
|
+
return accept.split(',').some((rawRule) => {
|
|
41
|
+
const rule = rawRule.trim().toLowerCase();
|
|
42
|
+
if (!rule) return false;
|
|
43
|
+
if (rule.startsWith('.')) return fileName.endsWith(rule);
|
|
44
|
+
if (rule.endsWith('/*')) return mime.startsWith(rule.slice(0, -1));
|
|
45
|
+
return mime === rule;
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function formatFileSize(bytes: number): string {
|
|
50
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
51
|
+
if (bytes < 1024 ** 2) return `${Math.round(bytes / 1024)} KB`;
|
|
52
|
+
return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function normalizeValidationResult(result: FileValidationResult): string[] {
|
|
56
|
+
if (!result) return [];
|
|
57
|
+
return typeof result === 'string' ? [result] : [...result];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Controlled, transport-agnostic file input. It owns accessible picking,
|
|
62
|
+
* drag-and-drop, limits, validation, errors, and removal; consumers own upload
|
|
63
|
+
* persistence and can replace the selected-file rendering with `renderFile`.
|
|
64
|
+
*/
|
|
65
|
+
function FileDropzone({
|
|
66
|
+
value = [],
|
|
67
|
+
onValueChange,
|
|
68
|
+
onRejected,
|
|
69
|
+
accept,
|
|
70
|
+
multiple = false,
|
|
71
|
+
maxFiles = multiple ? Number.POSITIVE_INFINITY : 1,
|
|
72
|
+
maxSize,
|
|
73
|
+
validateFile,
|
|
74
|
+
disabled,
|
|
75
|
+
required,
|
|
76
|
+
name,
|
|
77
|
+
id: providedId,
|
|
78
|
+
label = 'Upload files',
|
|
79
|
+
description,
|
|
80
|
+
emptyLabel = 'Drag and drop files here, or choose files',
|
|
81
|
+
dragLabel = 'Drop files to add them',
|
|
82
|
+
renderFile,
|
|
83
|
+
className,
|
|
84
|
+
...props
|
|
85
|
+
}: FileDropzoneProps) {
|
|
86
|
+
const generatedId = React.useId();
|
|
87
|
+
const id = providedId ?? generatedId;
|
|
88
|
+
const descriptionId = `${id}-description`;
|
|
89
|
+
const errorId = `${id}-error`;
|
|
90
|
+
const [dragging, setDragging] = React.useState(false);
|
|
91
|
+
const [validating, setValidating] = React.useState(false);
|
|
92
|
+
const [rejections, setRejections] = React.useState<FileRejection[]>([]);
|
|
93
|
+
const dragDepth = React.useRef(0);
|
|
94
|
+
|
|
95
|
+
async function addFiles(incoming: readonly File[]) {
|
|
96
|
+
if (disabled || incoming.length === 0) return;
|
|
97
|
+
setValidating(true);
|
|
98
|
+
const accepted: File[] = [];
|
|
99
|
+
const rejected: FileRejection[] = [];
|
|
100
|
+
const remaining = Math.max(0, maxFiles - (multiple ? value.length : 0));
|
|
101
|
+
|
|
102
|
+
for (const file of incoming.slice(0, remaining)) {
|
|
103
|
+
const messages: string[] = [];
|
|
104
|
+
if (!fileMatchesAccept(file, accept)) messages.push('This file type is not accepted.');
|
|
105
|
+
if (maxSize !== undefined && file.size > maxSize) {
|
|
106
|
+
messages.push(`File size must be ${formatFileSize(maxSize)} or smaller.`);
|
|
107
|
+
}
|
|
108
|
+
if (validateFile) {
|
|
109
|
+
try {
|
|
110
|
+
messages.push(...normalizeValidationResult(await validateFile(file)));
|
|
111
|
+
} catch {
|
|
112
|
+
messages.push('This file could not be validated.');
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (messages.length > 0) rejected.push({ file, messages });
|
|
116
|
+
else accepted.push(file);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (incoming.length > remaining) {
|
|
120
|
+
for (const file of incoming.slice(remaining)) {
|
|
121
|
+
rejected.push({ file, messages: [`You can select up to ${maxFiles} files.`] });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
setRejections(rejected);
|
|
126
|
+
setValidating(false);
|
|
127
|
+
if (rejected.length > 0) onRejected?.(rejected);
|
|
128
|
+
if (accepted.length > 0) {
|
|
129
|
+
onValueChange?.(multiple ? [...value, ...accepted] : accepted.slice(0, 1));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function removeFile(index: number) {
|
|
134
|
+
onValueChange?.(value.filter((_, fileIndex) => fileIndex !== index));
|
|
135
|
+
setRejections([]);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return (
|
|
139
|
+
<div data-slot="file-dropzone" className={cn('flex flex-col gap-3', className)} {...props}>
|
|
140
|
+
<div>
|
|
141
|
+
<label htmlFor={id} className="text-sm font-medium">
|
|
142
|
+
{label}
|
|
143
|
+
{required ? <span className="text-destructive"> *</span> : null}
|
|
144
|
+
</label>
|
|
145
|
+
{description ? (
|
|
146
|
+
<p id={descriptionId} className="text-muted-foreground mt-1 text-sm">
|
|
147
|
+
{description}
|
|
148
|
+
</p>
|
|
149
|
+
) : null}
|
|
150
|
+
</div>
|
|
151
|
+
|
|
152
|
+
<input
|
|
153
|
+
id={id}
|
|
154
|
+
name={name}
|
|
155
|
+
type="file"
|
|
156
|
+
accept={accept}
|
|
157
|
+
multiple={multiple}
|
|
158
|
+
required={required && value.length === 0}
|
|
159
|
+
disabled={disabled || validating}
|
|
160
|
+
aria-describedby={
|
|
161
|
+
cn(description && descriptionId, rejections.length > 0 && errorId) || undefined
|
|
162
|
+
}
|
|
163
|
+
className="peer sr-only"
|
|
164
|
+
onChange={(event) => {
|
|
165
|
+
const files = Array.from(event.currentTarget.files ?? []);
|
|
166
|
+
event.currentTarget.value = '';
|
|
167
|
+
void addFiles(files);
|
|
168
|
+
}}
|
|
169
|
+
/>
|
|
170
|
+
|
|
171
|
+
<label
|
|
172
|
+
htmlFor={id}
|
|
173
|
+
data-slot="file-dropzone-target"
|
|
174
|
+
data-dragging={dragging || undefined}
|
|
175
|
+
data-disabled={disabled || validating || undefined}
|
|
176
|
+
onDragEnter={(event) => {
|
|
177
|
+
event.preventDefault();
|
|
178
|
+
if (disabled) return;
|
|
179
|
+
dragDepth.current += 1;
|
|
180
|
+
setDragging(true);
|
|
181
|
+
}}
|
|
182
|
+
onDragOver={(event) => {
|
|
183
|
+
event.preventDefault();
|
|
184
|
+
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy';
|
|
185
|
+
}}
|
|
186
|
+
onDragLeave={(event) => {
|
|
187
|
+
event.preventDefault();
|
|
188
|
+
dragDepth.current = Math.max(0, dragDepth.current - 1);
|
|
189
|
+
if (dragDepth.current === 0) setDragging(false);
|
|
190
|
+
}}
|
|
191
|
+
onDrop={(event) => {
|
|
192
|
+
event.preventDefault();
|
|
193
|
+
dragDepth.current = 0;
|
|
194
|
+
setDragging(false);
|
|
195
|
+
void addFiles(Array.from(event.dataTransfer.files));
|
|
196
|
+
}}
|
|
197
|
+
className={cn(
|
|
198
|
+
'border-border text-muted-foreground hover:border-foreground/40 flex min-h-32 cursor-pointer flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed px-6 py-8 text-center transition-[border-color,background-color,color] duration-(--transition-duration-fast) ease-(--ease-out)',
|
|
199
|
+
'peer-focus-visible:border-ring peer-focus-visible:ring-3 peer-focus-visible:ring-ring/50',
|
|
200
|
+
'data-dragging:border-foreground data-dragging:bg-accent data-dragging:text-accent-foreground',
|
|
201
|
+
'data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-disabled:opacity-50',
|
|
202
|
+
)}
|
|
203
|
+
>
|
|
204
|
+
<UploadCloudIcon className="size-7" />
|
|
205
|
+
<span className="text-foreground text-sm font-medium">
|
|
206
|
+
{validating ? 'Checking files…' : dragging ? dragLabel : emptyLabel}
|
|
207
|
+
</span>
|
|
208
|
+
{accept ? <span className="text-xs">Accepted: {accept}</span> : null}
|
|
209
|
+
</label>
|
|
210
|
+
|
|
211
|
+
{value.length > 0 ? (
|
|
212
|
+
<ul data-slot="file-dropzone-files" className="flex flex-col gap-2">
|
|
213
|
+
{value.map((file, index) => (
|
|
214
|
+
<li key={`${file.name}-${file.size}-${file.lastModified}`}>
|
|
215
|
+
{renderFile ? (
|
|
216
|
+
renderFile(file, () => removeFile(index))
|
|
217
|
+
) : (
|
|
218
|
+
<div className="bg-muted/60 flex min-w-0 items-center gap-3 rounded-lg border px-3 py-2">
|
|
219
|
+
<FileIcon className="text-muted-foreground size-4 shrink-0" />
|
|
220
|
+
<div className="min-w-0 flex-1">
|
|
221
|
+
<p className="truncate text-sm font-medium">{file.name}</p>
|
|
222
|
+
<p className="text-muted-foreground text-xs">{formatFileSize(file.size)}</p>
|
|
223
|
+
</div>
|
|
224
|
+
<Button
|
|
225
|
+
type="button"
|
|
226
|
+
variant="ghost"
|
|
227
|
+
size="icon-xs"
|
|
228
|
+
disabled={disabled}
|
|
229
|
+
aria-label={`Remove ${file.name}`}
|
|
230
|
+
onClick={() => removeFile(index)}
|
|
231
|
+
>
|
|
232
|
+
<XIcon />
|
|
233
|
+
</Button>
|
|
234
|
+
</div>
|
|
235
|
+
)}
|
|
236
|
+
</li>
|
|
237
|
+
))}
|
|
238
|
+
</ul>
|
|
239
|
+
) : null}
|
|
240
|
+
|
|
241
|
+
{rejections.length > 0 ? (
|
|
242
|
+
<div id={errorId} role="alert" className="text-destructive text-sm">
|
|
243
|
+
{rejections.map(({ file, messages }) => (
|
|
244
|
+
<p key={`${file.name}-${file.size}`}>
|
|
245
|
+
<span className="font-medium">{file.name}:</span> {messages.join(' ')}
|
|
246
|
+
</p>
|
|
247
|
+
))}
|
|
248
|
+
</div>
|
|
249
|
+
) : null}
|
|
250
|
+
</div>
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export {
|
|
255
|
+
FileDropzone,
|
|
256
|
+
fileMatchesAccept,
|
|
257
|
+
formatFileSize,
|
|
258
|
+
type FileDropzoneProps,
|
|
259
|
+
type FileRejection,
|
|
260
|
+
type FileValidator,
|
|
261
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { cn } from '@vendure-io/ui/lib/utils';
|
|
2
|
+
import type * as React from 'react';
|
|
3
|
+
|
|
4
|
+
interface SkipLinkProps extends React.ComponentProps<'a'> {
|
|
5
|
+
/** The main-content element to focus. Include the leading `#`. */
|
|
6
|
+
href?: `#${string}`;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Keyboard-only escape hatch for repeated application chrome. Place it as the
|
|
11
|
+
* first focusable element in the document and point it at `AppShellMain` (whose
|
|
12
|
+
* default id is `main-content`). Native anchor behavior keeps this server-safe
|
|
13
|
+
* and works before React hydrates.
|
|
14
|
+
*/
|
|
15
|
+
function SkipLink({
|
|
16
|
+
className,
|
|
17
|
+
href = '#main-content',
|
|
18
|
+
children = 'Skip to main content',
|
|
19
|
+
...props
|
|
20
|
+
}: SkipLinkProps) {
|
|
21
|
+
return (
|
|
22
|
+
<a
|
|
23
|
+
data-slot="skip-link"
|
|
24
|
+
href={href}
|
|
25
|
+
className={cn(
|
|
26
|
+
'bg-primary text-primary-foreground fixed top-3 left-3 z-[100] -translate-y-20 rounded-md px-3 py-2 text-sm font-medium opacity-0 outline-none transition-[transform,opacity] duration-(--transition-duration-fast) ease-(--ease-out) focus-visible:translate-y-0 focus-visible:opacity-100 focus-visible:ring-3 focus-visible:ring-ring/50 motion-reduce:transition-none',
|
|
27
|
+
className,
|
|
28
|
+
)}
|
|
29
|
+
{...props}
|
|
30
|
+
>
|
|
31
|
+
{children}
|
|
32
|
+
</a>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export { SkipLink, type SkipLinkProps };
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
const CALENDAR_DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
2
|
+
|
|
3
|
+
/** Parse `YYYY-MM-DD` into a local calendar date without a UTC shift. */
|
|
4
|
+
function parseCalendarDate(value: string | null | undefined): Date | undefined {
|
|
5
|
+
if (!value) return undefined;
|
|
6
|
+
const match = CALENDAR_DATE_PATTERN.exec(value);
|
|
7
|
+
if (!match) return undefined;
|
|
8
|
+
const year = Number(match[1]);
|
|
9
|
+
const month = Number(match[2]);
|
|
10
|
+
const day = Number(match[3]);
|
|
11
|
+
const date = new Date(year, month - 1, day);
|
|
12
|
+
if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) {
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
return date;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Serialize a local calendar date as `YYYY-MM-DD`. */
|
|
19
|
+
function formatCalendarDate(date: Date): string {
|
|
20
|
+
const year = date.getFullYear();
|
|
21
|
+
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
22
|
+
const day = String(date.getDate()).padStart(2, '0');
|
|
23
|
+
return `${year}-${month}-${day}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function parseInstant(value: string | null | undefined): Date | undefined {
|
|
27
|
+
if (!value) return undefined;
|
|
28
|
+
const date = new Date(value);
|
|
29
|
+
return Number.isNaN(date.getTime()) ? undefined : date;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function formatDateLabel(date: Date, locale?: string): string {
|
|
33
|
+
return new Intl.DateTimeFormat(locale, { dateStyle: 'medium' }).format(date);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function formatDateRangeLabel(from: Date, to: Date | undefined, locale?: string): string {
|
|
37
|
+
if (!to || from.getTime() === to.getTime()) return formatDateLabel(from, locale);
|
|
38
|
+
const formatter = new Intl.DateTimeFormat(locale, { dateStyle: 'medium' });
|
|
39
|
+
return formatter.formatRange(from, to);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export {
|
|
43
|
+
formatCalendarDate,
|
|
44
|
+
formatDateLabel,
|
|
45
|
+
formatDateRangeLabel,
|
|
46
|
+
parseCalendarDate,
|
|
47
|
+
parseInstant,
|
|
48
|
+
};
|