@vendure-io/ui 1.3.0 → 2.0.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -5
- package/package.json +10 -5
- package/src/components/{ui → atoms}/alert-dialog.tsx +4 -4
- package/src/components/{ui → atoms}/badge.tsx +6 -3
- package/src/components/{ui → atoms}/button-group.tsx +1 -2
- package/src/components/{ui → atoms}/button.tsx +8 -2
- package/src/components/{ui → atoms}/calendar.tsx +1 -1
- package/src/components/{ui → atoms}/card.tsx +2 -3
- package/src/components/{ui → atoms}/carousel.tsx +1 -1
- package/src/components/atoms/checkbox.tsx +29 -0
- package/src/components/{ui → atoms}/combobox.tsx +3 -4
- package/src/components/{ui → atoms}/command.tsx +2 -2
- package/src/components/{ui → atoms}/context-menu.tsx +1 -1
- package/src/components/{ui → atoms}/dialog.tsx +4 -4
- package/src/components/{ui → atoms}/drawer.tsx +3 -3
- package/src/components/{ui → atoms}/dropdown-menu.tsx +3 -2
- package/src/components/{ui → atoms}/empty.tsx +1 -1
- package/src/components/{ui → atoms}/field.tsx +2 -2
- package/src/components/{ui → atoms}/hover-card.tsx +1 -1
- package/src/components/{ui → atoms}/input-group.tsx +3 -4
- package/src/components/{ui → atoms}/input-otp.tsx +0 -1
- package/src/components/{ui → atoms}/input.tsx +0 -1
- package/src/components/{ui → atoms}/item.tsx +2 -2
- package/src/components/{ui → atoms}/menubar.tsx +3 -4
- package/src/components/{ui → atoms}/native-select.tsx +0 -1
- package/src/components/{ui → atoms}/navigation-menu.tsx +7 -4
- package/src/components/{ui → atoms}/pagination.tsx +1 -1
- package/src/components/{ui → atoms}/popover.tsx +2 -2
- package/src/components/{ui → atoms}/select.tsx +22 -4
- package/src/components/{ui → atoms}/sheet.tsx +4 -4
- package/src/components/{ui → atoms}/sidebar.tsx +6 -6
- package/src/components/atoms/skeleton.tsx +16 -0
- package/src/components/{ui → atoms}/slider.tsx +0 -1
- package/src/components/{ui → atoms}/sonner.tsx +1 -1
- package/src/components/{ui → atoms}/switch.tsx +0 -1
- package/src/components/{ui → atoms}/tabs.tsx +6 -4
- package/src/components/{ui → atoms}/textarea.tsx +0 -1
- package/src/components/{ui → atoms}/toggle-group.tsx +3 -3
- package/src/components/{ui → atoms}/toggle.tsx +0 -1
- package/src/components/{ui → atoms}/tooltip.tsx +2 -1
- package/src/components/molecules/anonymized-token.tsx +170 -0
- package/src/components/molecules/chip.tsx +85 -0
- package/src/components/{custom → molecules}/combobox-free-text.tsx +7 -3
- package/src/components/molecules/confirm-dialog.tsx +108 -0
- package/src/components/molecules/copyable-text.tsx +102 -0
- package/src/components/molecules/data-table/data-table-bulk-actions.tsx +69 -0
- package/src/components/molecules/data-table/data-table-column-header.tsx +61 -0
- package/src/components/molecules/data-table/data-table-filters.tsx +198 -0
- package/src/components/molecules/data-table/data-table-helpers.tsx +73 -0
- package/src/components/molecules/data-table/data-table-types.tsx +241 -0
- package/src/components/molecules/data-table/data-table-view-options.tsx +73 -0
- package/src/components/molecules/data-table/data-table.tsx +500 -0
- package/src/components/molecules/data-table/list-header.tsx +53 -0
- package/src/components/molecules/data-table/table-pagination.tsx +263 -0
- package/src/components/molecules/date-time.tsx +73 -0
- package/src/components/molecules/description-list.tsx +120 -0
- package/src/components/molecules/format-provider.tsx +46 -0
- package/src/components/molecules/id-chip.tsx +79 -0
- package/src/components/molecules/illustrations/access-denied.tsx +55 -0
- package/src/components/molecules/illustrations/empty-collection.tsx +66 -0
- package/src/components/molecules/illustrations/empty-database.tsx +44 -0
- package/src/components/molecules/illustrations/empty-media.tsx +68 -0
- package/src/components/molecules/illustrations/error.tsx +64 -0
- package/src/components/molecules/illustrations/first-run.tsx +55 -0
- package/src/components/molecules/illustrations/illustration-types.tsx +9 -0
- package/src/components/molecules/illustrations/no-activity.tsx +49 -0
- package/src/components/molecules/illustrations/no-deployments.tsx +49 -0
- package/src/components/molecules/illustrations/no-documents.tsx +67 -0
- package/src/components/molecules/illustrations/no-keys.tsx +50 -0
- package/src/components/molecules/illustrations/no-logs.tsx +53 -0
- package/src/components/molecules/illustrations/no-members.tsx +56 -0
- package/src/components/molecules/illustrations/no-notifications.tsx +52 -0
- package/src/components/molecules/illustrations/no-orders.tsx +48 -0
- package/src/components/molecules/illustrations/no-plugins.tsx +72 -0
- package/src/components/molecules/illustrations/no-results.tsx +69 -0
- package/src/components/molecules/illustrations/not-found.tsx +48 -0
- package/src/components/molecules/illustrations/offline.tsx +43 -0
- package/src/components/molecules/illustrations/pending-approval.tsx +56 -0
- package/src/components/molecules/illustrations/upload-dropzone.tsx +59 -0
- package/src/components/molecules/money.tsx +91 -0
- package/src/components/{custom → molecules}/multi-select.tsx +2 -2
- package/src/components/molecules/page-header.tsx +80 -0
- package/src/components/molecules/password-input.tsx +47 -0
- package/src/components/molecules/relative-time.tsx +81 -0
- package/src/components/molecules/stat-card.tsx +134 -0
- package/src/components/molecules/state-views/empty-state.tsx +75 -0
- package/src/components/molecules/state-views/error-state.tsx +91 -0
- package/src/components/molecules/state-views/loading-state.tsx +80 -0
- package/src/components/molecules/status-badge.tsx +100 -0
- package/src/hooks/use-copy.ts +51 -0
- package/src/lib/base-ui.ts +1 -1
- package/src/lib/format-relative.ts +41 -0
- package/src/lib/state-dictionary.ts +137 -0
- package/src/components/ui/checkbox.tsx +0 -30
- package/src/components/ui/skeleton.tsx +0 -13
- /package/src/components/{ui → atoms}/accordion.tsx +0 -0
- /package/src/components/{ui → atoms}/alert.tsx +0 -0
- /package/src/components/{ui → atoms}/aspect-ratio.tsx +0 -0
- /package/src/components/{ui → atoms}/avatar.tsx +0 -0
- /package/src/components/{ui → atoms}/breadcrumb.tsx +0 -0
- /package/src/components/{ui → atoms}/chart.tsx +0 -0
- /package/src/components/{ui → atoms}/collapsible.tsx +0 -0
- /package/src/components/{ui → atoms}/direction.tsx +0 -0
- /package/src/components/{ui → atoms}/kbd.tsx +0 -0
- /package/src/components/{ui → atoms}/label.tsx +0 -0
- /package/src/components/{ui → atoms}/progress.tsx +0 -0
- /package/src/components/{ui → atoms}/radio-group.tsx +0 -0
- /package/src/components/{ui → atoms}/resizable.tsx +0 -0
- /package/src/components/{ui → atoms}/scroll-area.tsx +0 -0
- /package/src/components/{ui → atoms}/separator.tsx +0 -0
- /package/src/components/{ui → atoms}/spinner.tsx +0 -0
- /package/src/components/{ui → atoms}/table.tsx +0 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
InputGroup,
|
|
5
|
+
InputGroupAddon,
|
|
6
|
+
InputGroupButton,
|
|
7
|
+
InputGroupInput,
|
|
8
|
+
} from '@vendure-io/ui/components/atoms/input-group';
|
|
9
|
+
import { Eye, EyeOff } from 'lucide-react';
|
|
10
|
+
import * as React from 'react';
|
|
11
|
+
|
|
12
|
+
export interface PasswordInputProps extends Omit<React.ComponentProps<'input'>, 'type'> {
|
|
13
|
+
/** Accessible label for the reveal toggle while the password is hidden. */
|
|
14
|
+
showPasswordLabel?: string;
|
|
15
|
+
/** Accessible label for the reveal toggle while the password is shown. */
|
|
16
|
+
hidePasswordLabel?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A password field with a show/hide toggle, composed from the InputGroup atoms.
|
|
21
|
+
* Toggle labels are props so consumers can localize them — the design system
|
|
22
|
+
* carries no i18n runtime of its own.
|
|
23
|
+
*/
|
|
24
|
+
function PasswordInput({
|
|
25
|
+
showPasswordLabel = 'Show password',
|
|
26
|
+
hidePasswordLabel = 'Hide password',
|
|
27
|
+
...props
|
|
28
|
+
}: PasswordInputProps) {
|
|
29
|
+
const [showPassword, setShowPassword] = React.useState(false);
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<InputGroup>
|
|
33
|
+
<InputGroupInput type={showPassword ? 'text' : 'password'} {...props} />
|
|
34
|
+
<InputGroupAddon align="inline-end">
|
|
35
|
+
<InputGroupButton
|
|
36
|
+
size="icon-xs"
|
|
37
|
+
onClick={() => setShowPassword((prev) => !prev)}
|
|
38
|
+
aria-label={showPassword ? hidePasswordLabel : showPasswordLabel}
|
|
39
|
+
>
|
|
40
|
+
{showPassword ? <EyeOff /> : <Eye />}
|
|
41
|
+
</InputGroupButton>
|
|
42
|
+
</InputGroupAddon>
|
|
43
|
+
</InputGroup>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export { PasswordInput };
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useFormatSettings } from '@vendure-io/ui/components/molecules/format-provider';
|
|
4
|
+
import { type FormatRelativeOptions, formatRelative } from '@vendure-io/ui/lib/format-relative';
|
|
5
|
+
import { type ComponentProps, type ReactNode, useEffect, useState } from 'react';
|
|
6
|
+
|
|
7
|
+
function toDate(value: string | number | Date | null | undefined): Date | null {
|
|
8
|
+
if (value === null || value === undefined) return null;
|
|
9
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
10
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface RelativeTimeProps extends Omit<ComponentProps<'time'>, 'dateTime'> {
|
|
14
|
+
value: string | number | Date | null | undefined;
|
|
15
|
+
locale?: string;
|
|
16
|
+
numeric?: 'auto' | 'always';
|
|
17
|
+
unitStyle?: Intl.RelativeTimeFormatOptions['style'];
|
|
18
|
+
/**
|
|
19
|
+
* Self-refresh cadence in ms so a long-open view ages "just now" → "2 minutes
|
|
20
|
+
* ago". Default `60_000`. Pass `false` to disable the timer when the caller
|
|
21
|
+
* already re-renders (e.g. a polling query drives it).
|
|
22
|
+
*/
|
|
23
|
+
updateInterval?: number | false;
|
|
24
|
+
/** Rendered for nullish/invalid input. Default `'—'`. */
|
|
25
|
+
fallback?: ReactNode;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* SSR note: output is time- and locale-dependent, so the server render and the
|
|
30
|
+
* first client render can disagree. The `<time>` carries
|
|
31
|
+
* `suppressHydrationWarning` and re-formats once right after mount; SSR
|
|
32
|
+
* consumers should still pin `locale`/`timeZone` via `FormatProvider` (or
|
|
33
|
+
* props) so the absolute `title` matches too.
|
|
34
|
+
*/
|
|
35
|
+
function RelativeTime({
|
|
36
|
+
value,
|
|
37
|
+
locale,
|
|
38
|
+
numeric,
|
|
39
|
+
unitStyle,
|
|
40
|
+
updateInterval = 60_000,
|
|
41
|
+
fallback = '—',
|
|
42
|
+
...props
|
|
43
|
+
}: RelativeTimeProps) {
|
|
44
|
+
const settings = useFormatSettings();
|
|
45
|
+
// A bare counter to force a re-format on each tick; the value itself is unused.
|
|
46
|
+
const [, tick] = useState(0);
|
|
47
|
+
|
|
48
|
+
const date = toDate(value);
|
|
49
|
+
const hasDate = date !== null;
|
|
50
|
+
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (!hasDate) return;
|
|
53
|
+
// SSR'd text can already be stale by the time hydration runs, so re-format
|
|
54
|
+
// once immediately — this also covers `updateInterval={false}`, which
|
|
55
|
+
// otherwise never refreshes on its own.
|
|
56
|
+
tick((n) => n + 1);
|
|
57
|
+
if (updateInterval === false) return;
|
|
58
|
+
const id = setInterval(() => tick((n) => n + 1), updateInterval);
|
|
59
|
+
return () => clearInterval(id);
|
|
60
|
+
}, [updateInterval, hasDate]);
|
|
61
|
+
|
|
62
|
+
if (!date) return <>{fallback}</>;
|
|
63
|
+
|
|
64
|
+
const resolvedLocale = locale ?? settings.locale;
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<time
|
|
68
|
+
data-slot="relative-time"
|
|
69
|
+
dateTime={date.toISOString()}
|
|
70
|
+
title={date.toLocaleString(resolvedLocale, { timeZone: settings.timeZone })}
|
|
71
|
+
suppressHydrationWarning
|
|
72
|
+
{...props}
|
|
73
|
+
>
|
|
74
|
+
{formatRelative(date, { locale: resolvedLocale, numeric, unitStyle })}
|
|
75
|
+
</time>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Re-exported so the spike's import surface (molecules/relative-time) keeps
|
|
80
|
+
// working; the implementation lives in lib/ per the JSX-or-lib rule.
|
|
81
|
+
export { RelativeTime, formatRelative, type RelativeTimeProps, type FormatRelativeOptions };
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { Card, CardAction, CardContent, CardHeader } from '@vendure-io/ui/components/atoms/card';
|
|
2
|
+
import { Skeleton } from '@vendure-io/ui/components/atoms/skeleton';
|
|
3
|
+
import { cva } from 'class-variance-authority';
|
|
4
|
+
import { ArrowDown, ArrowUp } from 'lucide-react';
|
|
5
|
+
import type { ComponentProps, ReactNode } from 'react';
|
|
6
|
+
|
|
7
|
+
// Delta tone follows consequence, not direction: `goodWhen` says which way is
|
|
8
|
+
// good for this metric (revenue up vs. refund-rate down), and the token is
|
|
9
|
+
// derived from that — never a hardcoded green/red. Per ADR 0003: good →
|
|
10
|
+
// success, bad → destructive, flat → muted.
|
|
11
|
+
const statCardDeltaVariants = cva('inline-flex items-center gap-0.5 text-xs font-medium', {
|
|
12
|
+
variants: {
|
|
13
|
+
tone: {
|
|
14
|
+
good: 'text-success',
|
|
15
|
+
bad: 'text-destructive',
|
|
16
|
+
flat: 'text-muted-foreground',
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
defaultVariants: {
|
|
20
|
+
tone: 'flat',
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
interface StatCardDelta {
|
|
25
|
+
/** Raw percentage change, e.g. `-3.2`. Sign drives the arrow and tone. */
|
|
26
|
+
value: number;
|
|
27
|
+
/** Suffix after the number. Default `'%'`. */
|
|
28
|
+
label?: string;
|
|
29
|
+
/** Which direction is a good outcome for this metric. Default `'up'`. */
|
|
30
|
+
goodWhen?: 'up' | 'down';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface StatCardProps extends ComponentProps<typeof Card> {
|
|
34
|
+
label: ReactNode;
|
|
35
|
+
/** Pre-formatted display value — pass `<Money>`, `<AnimatedNumber>`, a string. */
|
|
36
|
+
value: ReactNode;
|
|
37
|
+
/** Secondary line, e.g. "vs. previous period". */
|
|
38
|
+
description?: ReactNode;
|
|
39
|
+
icon?: ReactNode;
|
|
40
|
+
delta?: StatCardDelta;
|
|
41
|
+
/** Optional sparkline/chart slot. */
|
|
42
|
+
chart?: ReactNode;
|
|
43
|
+
isLoading?: boolean;
|
|
44
|
+
/** Header-right slot, e.g. a menu or timeframe select. */
|
|
45
|
+
action?: ReactNode;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function StatCardDeltaBadge({ value, label = '%', goodWhen = 'up' }: StatCardDelta) {
|
|
49
|
+
const direction = value > 0 ? 'up' : value < 0 ? 'down' : 'flat';
|
|
50
|
+
const tone = direction === 'flat' ? 'flat' : direction === goodWhen ? 'good' : 'bad';
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<span data-slot="stat-card-delta" className={statCardDeltaVariants({ tone })}>
|
|
54
|
+
{direction === 'up' ? (
|
|
55
|
+
<ArrowUp aria-hidden="true" className="size-3" />
|
|
56
|
+
) : direction === 'down' ? (
|
|
57
|
+
<ArrowDown aria-hidden="true" className="size-3" />
|
|
58
|
+
) : null}
|
|
59
|
+
{/* Math.abs strips the sign and the arrow is visual-only, so screen
|
|
60
|
+
readers need the direction spelled out. */}
|
|
61
|
+
{direction !== 'flat' ? (
|
|
62
|
+
<span className="sr-only">{direction === 'up' ? 'up' : 'down'} </span>
|
|
63
|
+
) : null}
|
|
64
|
+
{Math.abs(value).toFixed(1)}
|
|
65
|
+
{label}
|
|
66
|
+
</span>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function StatCard({
|
|
71
|
+
label,
|
|
72
|
+
value,
|
|
73
|
+
description,
|
|
74
|
+
icon,
|
|
75
|
+
delta,
|
|
76
|
+
chart,
|
|
77
|
+
isLoading = false,
|
|
78
|
+
action,
|
|
79
|
+
className,
|
|
80
|
+
...props
|
|
81
|
+
}: StatCardProps) {
|
|
82
|
+
if (isLoading) {
|
|
83
|
+
return (
|
|
84
|
+
<Card data-slot="stat-card" className={className} {...props}>
|
|
85
|
+
<CardHeader>
|
|
86
|
+
<Skeleton className="h-4 w-24" />
|
|
87
|
+
</CardHeader>
|
|
88
|
+
<CardContent className="flex flex-col gap-2">
|
|
89
|
+
<Skeleton className="h-8 w-32" />
|
|
90
|
+
<Skeleton className="h-4 w-40" />
|
|
91
|
+
</CardContent>
|
|
92
|
+
</Card>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<Card data-slot="stat-card" className={className} {...props}>
|
|
98
|
+
<CardHeader>
|
|
99
|
+
<div
|
|
100
|
+
data-slot="stat-card-label"
|
|
101
|
+
className="text-muted-foreground flex items-center gap-1.5 text-sm font-medium [&_svg:not([class*='size-'])]:size-4"
|
|
102
|
+
>
|
|
103
|
+
{icon ? (
|
|
104
|
+
<span data-slot="stat-card-icon" className="inline-flex">
|
|
105
|
+
{icon}
|
|
106
|
+
</span>
|
|
107
|
+
) : null}
|
|
108
|
+
{label}
|
|
109
|
+
</div>
|
|
110
|
+
{action ? <CardAction>{action}</CardAction> : null}
|
|
111
|
+
</CardHeader>
|
|
112
|
+
<CardContent className="flex flex-col gap-1">
|
|
113
|
+
<div className="flex items-baseline gap-2">
|
|
114
|
+
<span data-slot="stat-card-value" className="text-2xl font-semibold tabular-nums">
|
|
115
|
+
{value}
|
|
116
|
+
</span>
|
|
117
|
+
{delta ? <StatCardDeltaBadge {...delta} /> : null}
|
|
118
|
+
</div>
|
|
119
|
+
{description ? (
|
|
120
|
+
<p data-slot="stat-card-description" className="text-muted-foreground text-sm">
|
|
121
|
+
{description}
|
|
122
|
+
</p>
|
|
123
|
+
) : null}
|
|
124
|
+
{chart ? (
|
|
125
|
+
<div data-slot="stat-card-chart" className="mt-2">
|
|
126
|
+
{chart}
|
|
127
|
+
</div>
|
|
128
|
+
) : null}
|
|
129
|
+
</CardContent>
|
|
130
|
+
</Card>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export { StatCard, statCardDeltaVariants, type StatCardProps, type StatCardDelta };
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Empty,
|
|
3
|
+
EmptyContent,
|
|
4
|
+
EmptyDescription,
|
|
5
|
+
EmptyHeader,
|
|
6
|
+
EmptyMedia,
|
|
7
|
+
EmptyTitle,
|
|
8
|
+
} from '@vendure-io/ui/components/atoms/empty';
|
|
9
|
+
import { EmptyCollectionIllustration } from '@vendure-io/ui/components/molecules/illustrations/empty-collection';
|
|
10
|
+
import { cn } from '@vendure-io/ui/lib/utils';
|
|
11
|
+
|
|
12
|
+
// `title` is redefined as `ReactNode`, so drop the native string `title` attr.
|
|
13
|
+
export interface EmptyStateProps extends Omit<React.ComponentProps<typeof Empty>, 'title'> {
|
|
14
|
+
/** Leading icon element, rendered in the muted icon chip. Ignored once `illustration` renders (see below). */
|
|
15
|
+
icon?: React.ReactNode;
|
|
16
|
+
/**
|
|
17
|
+
* Illustration rendered above the title, in place of `icon`. Defaults to
|
|
18
|
+
* `EmptyCollectionIllustration`. Pass a different illustration to match the
|
|
19
|
+
* scenario, or `illustration={null}` to fall back to `icon` (or nothing).
|
|
20
|
+
*/
|
|
21
|
+
illustration?: React.ReactNode | null;
|
|
22
|
+
/** The headline. */
|
|
23
|
+
title: React.ReactNode;
|
|
24
|
+
/** Optional supporting copy. */
|
|
25
|
+
description?: React.ReactNode;
|
|
26
|
+
/** Actions/CTA — rendered in the content slot below the header. */
|
|
27
|
+
children?: React.ReactNode;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The canonical "there's nothing here yet" panel. A thin wrapper over the
|
|
32
|
+
* `Empty` atom's header/media/title/description/content stack that three
|
|
33
|
+
* codebases (custom-ui, ops-admin, dashboard-common) kept re-assembling inline.
|
|
34
|
+
*
|
|
35
|
+
* The `Empty` atom ships `border-dashed` but no `border` width so it stays
|
|
36
|
+
* shadcn-aligned; the molecule adds `border` to draw the dashed box every donor
|
|
37
|
+
* implementation had (spike decision).
|
|
38
|
+
*/
|
|
39
|
+
function EmptyState({
|
|
40
|
+
icon,
|
|
41
|
+
illustration,
|
|
42
|
+
title,
|
|
43
|
+
description,
|
|
44
|
+
children,
|
|
45
|
+
className,
|
|
46
|
+
...props
|
|
47
|
+
}: EmptyStateProps) {
|
|
48
|
+
// Precedence: an explicit `illustration` (including `null`, to opt out)
|
|
49
|
+
// always wins; otherwise an explicit `icon` keeps rendering exactly as
|
|
50
|
+
// before. Only when neither is touched does the default illustration fill
|
|
51
|
+
// the media slot, so existing icon-only consumers see no change.
|
|
52
|
+
const resolvedIllustration =
|
|
53
|
+
illustration !== undefined ? (
|
|
54
|
+
illustration
|
|
55
|
+
) : icon === undefined ? (
|
|
56
|
+
<EmptyCollectionIllustration />
|
|
57
|
+
) : null;
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<Empty data-slot="empty-state" className={cn('border', className)} {...props}>
|
|
61
|
+
<EmptyHeader>
|
|
62
|
+
{resolvedIllustration ? (
|
|
63
|
+
<EmptyMedia>{resolvedIllustration}</EmptyMedia>
|
|
64
|
+
) : icon ? (
|
|
65
|
+
<EmptyMedia variant="icon">{icon}</EmptyMedia>
|
|
66
|
+
) : null}
|
|
67
|
+
<EmptyTitle>{title}</EmptyTitle>
|
|
68
|
+
{description ? <EmptyDescription>{description}</EmptyDescription> : null}
|
|
69
|
+
</EmptyHeader>
|
|
70
|
+
{children ? <EmptyContent>{children}</EmptyContent> : null}
|
|
71
|
+
</Empty>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export { EmptyState };
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { Button } from '@vendure-io/ui/components/atoms/button';
|
|
2
|
+
import {
|
|
3
|
+
Empty,
|
|
4
|
+
EmptyContent,
|
|
5
|
+
EmptyDescription,
|
|
6
|
+
EmptyHeader,
|
|
7
|
+
EmptyMedia,
|
|
8
|
+
EmptyTitle,
|
|
9
|
+
} from '@vendure-io/ui/components/atoms/empty';
|
|
10
|
+
import { ErrorIllustration } from '@vendure-io/ui/components/molecules/illustrations/error';
|
|
11
|
+
import { cn } from '@vendure-io/ui/lib/utils';
|
|
12
|
+
import { TriangleAlertIcon } from 'lucide-react';
|
|
13
|
+
|
|
14
|
+
// `title` is redefined as `ReactNode`, so drop the native string `title` attr.
|
|
15
|
+
export interface ErrorStateProps extends Omit<React.ComponentProps<typeof Empty>, 'title'> {
|
|
16
|
+
/**
|
|
17
|
+
* Leading icon, tinted `text-destructive`. Defaults to an alert triangle.
|
|
18
|
+
* Ignored once `illustration` renders (see below).
|
|
19
|
+
*/
|
|
20
|
+
icon?: React.ReactNode;
|
|
21
|
+
/**
|
|
22
|
+
* Illustration rendered above the title, in place of `icon`. Defaults to
|
|
23
|
+
* `ErrorIllustration`. Pass a different illustration (e.g. `NotFoundIllustration`
|
|
24
|
+
* for a 404), or `illustration={null}` to fall back to `icon`.
|
|
25
|
+
*/
|
|
26
|
+
illustration?: React.ReactNode | null;
|
|
27
|
+
/** The headline. Defaults to "Something went wrong". */
|
|
28
|
+
title?: React.ReactNode;
|
|
29
|
+
/** Optional supporting copy. */
|
|
30
|
+
description?: React.ReactNode;
|
|
31
|
+
/** When provided, renders a "Try again" button that calls this handler. */
|
|
32
|
+
onRetry?: () => void;
|
|
33
|
+
/** Label for the retry button. Defaults to "Try again". */
|
|
34
|
+
retryLabel?: React.ReactNode;
|
|
35
|
+
/** Extra actions (e.g. a not-found "Go back" link) rendered beside retry. */
|
|
36
|
+
children?: React.ReactNode;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The one canonical error presentation: a centered box that carries semantics
|
|
41
|
+
* no atom has — `role="alert"`, a destructive-tinted icon (or illustration),
|
|
42
|
+
* and an optional retry CTA. Same anatomy as `EmptyState` so failed and empty
|
|
43
|
+
* states line up.
|
|
44
|
+
*/
|
|
45
|
+
function ErrorState({
|
|
46
|
+
icon,
|
|
47
|
+
illustration,
|
|
48
|
+
title = 'Something went wrong',
|
|
49
|
+
description,
|
|
50
|
+
onRetry,
|
|
51
|
+
retryLabel = 'Try again',
|
|
52
|
+
children,
|
|
53
|
+
className,
|
|
54
|
+
...props
|
|
55
|
+
}: ErrorStateProps) {
|
|
56
|
+
// Precedence: an explicit `illustration` (including `null`, to opt out)
|
|
57
|
+
// always wins; otherwise an explicit `icon` (including `null`) keeps
|
|
58
|
+
// rendering exactly as before. Only when neither is touched does the
|
|
59
|
+
// default illustration replace the default triangle icon.
|
|
60
|
+
const resolvedIllustration =
|
|
61
|
+
illustration !== undefined ? illustration : icon === undefined ? <ErrorIllustration /> : null;
|
|
62
|
+
const resolvedIcon = icon === undefined ? <TriangleAlertIcon /> : icon;
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<Empty role="alert" data-slot="error-state" className={cn('border', className)} {...props}>
|
|
66
|
+
<EmptyHeader>
|
|
67
|
+
{resolvedIllustration ? (
|
|
68
|
+
<EmptyMedia>{resolvedIllustration}</EmptyMedia>
|
|
69
|
+
) : resolvedIcon ? (
|
|
70
|
+
<EmptyMedia variant="icon" className="text-destructive">
|
|
71
|
+
{resolvedIcon}
|
|
72
|
+
</EmptyMedia>
|
|
73
|
+
) : null}
|
|
74
|
+
<EmptyTitle>{title}</EmptyTitle>
|
|
75
|
+
{description ? <EmptyDescription>{description}</EmptyDescription> : null}
|
|
76
|
+
</EmptyHeader>
|
|
77
|
+
{onRetry || children ? (
|
|
78
|
+
<EmptyContent>
|
|
79
|
+
{onRetry ? (
|
|
80
|
+
<Button variant="outline" size="sm" onClick={onRetry}>
|
|
81
|
+
{retryLabel}
|
|
82
|
+
</Button>
|
|
83
|
+
) : null}
|
|
84
|
+
{children}
|
|
85
|
+
</EmptyContent>
|
|
86
|
+
) : null}
|
|
87
|
+
</Empty>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export { ErrorState };
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { Skeleton } from '@vendure-io/ui/components/atoms/skeleton';
|
|
2
|
+
import { Spinner } from '@vendure-io/ui/components/atoms/spinner';
|
|
3
|
+
import { cn } from '@vendure-io/ui/lib/utils';
|
|
4
|
+
import { cva, type VariantProps } from 'class-variance-authority';
|
|
5
|
+
|
|
6
|
+
const loadingStateVariants = cva('flex w-full min-w-0 flex-col gap-3', {
|
|
7
|
+
variants: {
|
|
8
|
+
variant: {
|
|
9
|
+
skeleton: '',
|
|
10
|
+
spinner: 'text-muted-foreground items-center justify-center gap-2 py-8',
|
|
11
|
+
},
|
|
12
|
+
},
|
|
13
|
+
defaultVariants: {
|
|
14
|
+
variant: 'skeleton',
|
|
15
|
+
},
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
// `React.ComponentProps<'output'>` (not `'div'` as the spike sketch wrote) so
|
|
19
|
+
// the spread props type-check against the `<output>` we render.
|
|
20
|
+
export interface LoadingStateProps
|
|
21
|
+
extends React.ComponentProps<'output'>,
|
|
22
|
+
VariantProps<typeof loadingStateVariants> {
|
|
23
|
+
/** Number of skeleton rows. `skeleton` variant only; defaults to 5. */
|
|
24
|
+
rows?: number;
|
|
25
|
+
/** Classes applied to each skeleton row (e.g. `h-10` for tighter lists). */
|
|
26
|
+
rowClassName?: string;
|
|
27
|
+
/** Visible label. An sr-only "Loading…" is always rendered for assistive tech. */
|
|
28
|
+
label?: React.ReactNode;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Placeholder shell for in-flight list/detail queries. Renders as an
|
|
33
|
+
* `aria-live` `<output>` with an always-present sr-only "Loading…" label.
|
|
34
|
+
* `skeleton` (default) draws N shimmer rows; `spinner` centers the `Spinner`
|
|
35
|
+
* atom for compact or unknown-height regions.
|
|
36
|
+
*/
|
|
37
|
+
function LoadingState({
|
|
38
|
+
variant = 'skeleton',
|
|
39
|
+
rows = 5,
|
|
40
|
+
rowClassName,
|
|
41
|
+
label,
|
|
42
|
+
className,
|
|
43
|
+
...props
|
|
44
|
+
}: LoadingStateProps) {
|
|
45
|
+
return (
|
|
46
|
+
<output
|
|
47
|
+
data-slot="loading-state"
|
|
48
|
+
aria-live="polite"
|
|
49
|
+
className={cn(loadingStateVariants({ variant }), className)}
|
|
50
|
+
{...props}
|
|
51
|
+
>
|
|
52
|
+
<span className="sr-only">Loading…</span>
|
|
53
|
+
{variant === 'spinner' ? (
|
|
54
|
+
<>
|
|
55
|
+
{/* aria-hidden overrides the atom's role="status": the <output> is
|
|
56
|
+
already the live region, so the spinner must not double-announce. */}
|
|
57
|
+
<Spinner aria-hidden="true" />
|
|
58
|
+
{label ? (
|
|
59
|
+
<span data-slot="loading-state-label" className="text-muted-foreground text-sm">
|
|
60
|
+
{label}
|
|
61
|
+
</span>
|
|
62
|
+
) : null}
|
|
63
|
+
</>
|
|
64
|
+
) : (
|
|
65
|
+
<>
|
|
66
|
+
{label ? (
|
|
67
|
+
<span data-slot="loading-state-label" className="text-muted-foreground text-sm">
|
|
68
|
+
{label}
|
|
69
|
+
</span>
|
|
70
|
+
) : null}
|
|
71
|
+
{Array.from({ length: rows }).map((_, i) => (
|
|
72
|
+
<Skeleton key={i} className={cn('h-14 w-full rounded-md', rowClassName)} />
|
|
73
|
+
))}
|
|
74
|
+
</>
|
|
75
|
+
)}
|
|
76
|
+
</output>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export { LoadingState, loadingStateVariants };
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { mergeProps } from '@base-ui/react/merge-props';
|
|
2
|
+
import { useRender } from '@base-ui/react/use-render';
|
|
3
|
+
import type { Tone } from '@vendure-io/ui/lib/state-dictionary';
|
|
4
|
+
import { cn } from '@vendure-io/ui/lib/utils';
|
|
5
|
+
import { cva } from 'class-variance-authority';
|
|
6
|
+
|
|
7
|
+
// Subtle-only, always. Tinted background + readable foreground + matching
|
|
8
|
+
// border, per tone — the treatment every hand-rolled status chip was reaching
|
|
9
|
+
// for. There is no solid mode: solid/brand chips stay on the base `Badge`
|
|
10
|
+
// (accent rationing applied at the API level).
|
|
11
|
+
//
|
|
12
|
+
// Slot = tone, with two aliases: `critical` renders via the `destructive`
|
|
13
|
+
// slots (the token keeps its shadcn-compatible name) and `progress` renders
|
|
14
|
+
// via the `neutral` slots with an info-colored pulsing dot — a chip that is
|
|
15
|
+
// otherwise identical to `info` is not scannable; the neutral body says "no
|
|
16
|
+
// outcome yet" while the dot says "in motion". Class strings are static per
|
|
17
|
+
// tone so Tailwind's scanner picks them up.
|
|
18
|
+
//
|
|
19
|
+
// Metrics mirror `Badge` (h-5, pill radius, text-xs) so a StatusBadge and a
|
|
20
|
+
// Badge sit side by side on the same row of a table.
|
|
21
|
+
const statusBadgeVariants = cva(
|
|
22
|
+
'inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1.5 overflow-hidden whitespace-nowrap rounded-4xl border px-2 py-0.5 text-xs font-medium',
|
|
23
|
+
{
|
|
24
|
+
variants: {
|
|
25
|
+
tone: {
|
|
26
|
+
neutral: 'bg-neutral-subtle text-neutral-subtle-foreground border-neutral-border',
|
|
27
|
+
info: 'bg-info-subtle text-info-subtle-foreground border-info-border',
|
|
28
|
+
success: 'bg-success-subtle text-success-subtle-foreground border-success-border',
|
|
29
|
+
warning: 'bg-warning-subtle text-warning-subtle-foreground border-warning-border',
|
|
30
|
+
critical:
|
|
31
|
+
'bg-destructive-subtle text-destructive-subtle-foreground border-destructive-border',
|
|
32
|
+
progress: 'bg-neutral-subtle text-neutral-subtle-foreground border-neutral-border',
|
|
33
|
+
} satisfies Record<Tone, string>,
|
|
34
|
+
},
|
|
35
|
+
defaultVariants: {
|
|
36
|
+
tone: 'neutral',
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
interface StatusBadgeProps extends useRender.ComponentProps<'span'> {
|
|
42
|
+
/** Semantic tone. Defaults to `neutral`. */
|
|
43
|
+
tone?: Tone;
|
|
44
|
+
/**
|
|
45
|
+
* Show a leading dot. Off by default (calm on detail pages); opt in for dense
|
|
46
|
+
* tables. `progress` always shows a dot regardless of this prop, and that dot
|
|
47
|
+
* pulses (pausing under `prefers-reduced-motion`) — it replaces ad-hoc
|
|
48
|
+
* spinners.
|
|
49
|
+
*/
|
|
50
|
+
dot?: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function StatusBadge({
|
|
54
|
+
className,
|
|
55
|
+
tone = 'neutral',
|
|
56
|
+
dot = false,
|
|
57
|
+
render,
|
|
58
|
+
children,
|
|
59
|
+
...props
|
|
60
|
+
}: StatusBadgeProps) {
|
|
61
|
+
const showDot = dot || tone === 'progress';
|
|
62
|
+
|
|
63
|
+
return useRender({
|
|
64
|
+
defaultTagName: 'span',
|
|
65
|
+
props: mergeProps<'span'>(
|
|
66
|
+
{
|
|
67
|
+
className: cn(statusBadgeVariants({ tone }), className),
|
|
68
|
+
},
|
|
69
|
+
props,
|
|
70
|
+
{
|
|
71
|
+
// The component never transforms or translates the label — children win.
|
|
72
|
+
// A consumer passes their translated string; `defaultLabel` from the
|
|
73
|
+
// dictionary is a fallback the caller resolves, not something rendered here.
|
|
74
|
+
children: (
|
|
75
|
+
<>
|
|
76
|
+
{showDot ? (
|
|
77
|
+
<span
|
|
78
|
+
aria-hidden
|
|
79
|
+
className={cn(
|
|
80
|
+
'size-1.5 shrink-0 rounded-full',
|
|
81
|
+
tone === 'progress'
|
|
82
|
+
? 'bg-info-subtle-foreground motion-safe:animate-pulse'
|
|
83
|
+
: 'bg-current',
|
|
84
|
+
)}
|
|
85
|
+
/>
|
|
86
|
+
) : null}
|
|
87
|
+
{children}
|
|
88
|
+
</>
|
|
89
|
+
),
|
|
90
|
+
},
|
|
91
|
+
),
|
|
92
|
+
render,
|
|
93
|
+
state: {
|
|
94
|
+
slot: 'status-badge',
|
|
95
|
+
tone,
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export { StatusBadge, statusBadgeVariants };
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
interface UseCopyOptions {
|
|
6
|
+
/** How long `copied` stays true after a successful copy, in ms. */
|
|
7
|
+
timeout?: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface UseCopyReturn {
|
|
11
|
+
/** True for `timeout` ms after the last successful copy. */
|
|
12
|
+
copied: boolean;
|
|
13
|
+
/** Writes `text` to the clipboard. Resolves `true` on success, `false` on failure — never throws. */
|
|
14
|
+
copy: (text: string) => Promise<boolean>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Clipboard-copy hook with transient "copied" feedback. Inlines the
|
|
19
|
+
* `navigator.clipboard` call so the design system takes no third-party
|
|
20
|
+
* clipboard dependency, and never couples to a toast — surface success/failure
|
|
21
|
+
* through the returned `copied` flag (or the `CopyButton` callbacks).
|
|
22
|
+
*/
|
|
23
|
+
export function useCopy({ timeout = 2000 }: UseCopyOptions = {}): UseCopyReturn {
|
|
24
|
+
const [copied, setCopied] = useState(false);
|
|
25
|
+
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
26
|
+
|
|
27
|
+
useEffect(
|
|
28
|
+
() => () => {
|
|
29
|
+
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
|
30
|
+
},
|
|
31
|
+
[],
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
const copy = useCallback(
|
|
35
|
+
async (text: string): Promise<boolean> => {
|
|
36
|
+
try {
|
|
37
|
+
await navigator.clipboard.writeText(text);
|
|
38
|
+
setCopied(true);
|
|
39
|
+
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
|
40
|
+
timeoutRef.current = setTimeout(() => setCopied(false), timeout);
|
|
41
|
+
return true;
|
|
42
|
+
} catch {
|
|
43
|
+
setCopied(false);
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
[timeout],
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
return { copied, copy };
|
|
51
|
+
}
|
package/src/lib/base-ui.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Re-exports of `@base-ui/react` primitive namespaces.
|
|
3
3
|
*
|
|
4
|
-
* Why this exists: the wrapper components in `@vendure-io/ui/components/
|
|
4
|
+
* Why this exists: the wrapper components in `@vendure-io/ui/components/atoms/*`
|
|
5
5
|
* are intentionally thin layers over `@base-ui/react` primitives. Consumers
|
|
6
6
|
* (notably `@vendure/dashboard`) often need to override a single subcomponent
|
|
7
7
|
* — e.g. swap `DialogTitle` for one that uses a different font — while
|