@vendure-io/ui 1.3.0 → 2.0.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/README.md +5 -5
  2. package/package.json +9 -4
  3. package/src/components/{ui → atoms}/alert-dialog.tsx +4 -4
  4. package/src/components/{ui → atoms}/badge.tsx +6 -3
  5. package/src/components/{ui → atoms}/button-group.tsx +1 -2
  6. package/src/components/{ui → atoms}/button.tsx +8 -2
  7. package/src/components/{ui → atoms}/calendar.tsx +1 -1
  8. package/src/components/{ui → atoms}/card.tsx +2 -3
  9. package/src/components/{ui → atoms}/carousel.tsx +1 -1
  10. package/src/components/atoms/checkbox.tsx +29 -0
  11. package/src/components/{ui → atoms}/combobox.tsx +3 -4
  12. package/src/components/{ui → atoms}/command.tsx +2 -2
  13. package/src/components/{ui → atoms}/context-menu.tsx +1 -1
  14. package/src/components/{ui → atoms}/dialog.tsx +4 -4
  15. package/src/components/{ui → atoms}/drawer.tsx +3 -3
  16. package/src/components/{ui → atoms}/dropdown-menu.tsx +3 -2
  17. package/src/components/{ui → atoms}/empty.tsx +1 -1
  18. package/src/components/{ui → atoms}/field.tsx +2 -2
  19. package/src/components/{ui → atoms}/hover-card.tsx +1 -1
  20. package/src/components/{ui → atoms}/input-group.tsx +3 -4
  21. package/src/components/{ui → atoms}/input-otp.tsx +0 -1
  22. package/src/components/{ui → atoms}/input.tsx +0 -1
  23. package/src/components/{ui → atoms}/item.tsx +2 -2
  24. package/src/components/{ui → atoms}/menubar.tsx +3 -4
  25. package/src/components/{ui → atoms}/native-select.tsx +0 -1
  26. package/src/components/{ui → atoms}/navigation-menu.tsx +7 -4
  27. package/src/components/{ui → atoms}/pagination.tsx +1 -1
  28. package/src/components/{ui → atoms}/popover.tsx +2 -2
  29. package/src/components/{ui → atoms}/select.tsx +22 -4
  30. package/src/components/{ui → atoms}/sheet.tsx +4 -4
  31. package/src/components/{ui → atoms}/sidebar.tsx +6 -6
  32. package/src/components/atoms/skeleton.tsx +16 -0
  33. package/src/components/{ui → atoms}/slider.tsx +0 -1
  34. package/src/components/{ui → atoms}/sonner.tsx +1 -1
  35. package/src/components/{ui → atoms}/switch.tsx +0 -1
  36. package/src/components/{ui → atoms}/tabs.tsx +6 -4
  37. package/src/components/{ui → atoms}/textarea.tsx +0 -1
  38. package/src/components/{ui → atoms}/toggle-group.tsx +3 -3
  39. package/src/components/{ui → atoms}/toggle.tsx +0 -1
  40. package/src/components/{ui → atoms}/tooltip.tsx +2 -1
  41. package/src/components/molecules/anonymized-token.tsx +170 -0
  42. package/src/components/molecules/chip.tsx +85 -0
  43. package/src/components/{custom → molecules}/combobox-free-text.tsx +7 -3
  44. package/src/components/molecules/confirm-dialog.tsx +108 -0
  45. package/src/components/molecules/copyable-text.tsx +102 -0
  46. package/src/components/molecules/data-table/data-table-bulk-actions.tsx +69 -0
  47. package/src/components/molecules/data-table/data-table-column-header.tsx +61 -0
  48. package/src/components/molecules/data-table/data-table-filters.tsx +198 -0
  49. package/src/components/molecules/data-table/data-table-helpers.tsx +73 -0
  50. package/src/components/molecules/data-table/data-table-types.tsx +241 -0
  51. package/src/components/molecules/data-table/data-table-view-options.tsx +73 -0
  52. package/src/components/molecules/data-table/data-table.tsx +500 -0
  53. package/src/components/molecules/data-table/list-header.tsx +53 -0
  54. package/src/components/molecules/data-table/table-pagination.tsx +263 -0
  55. package/src/components/molecules/date-time.tsx +73 -0
  56. package/src/components/molecules/description-list.tsx +120 -0
  57. package/src/components/molecules/format-provider.tsx +46 -0
  58. package/src/components/molecules/id-chip.tsx +79 -0
  59. package/src/components/molecules/illustrations/access-denied.tsx +55 -0
  60. package/src/components/molecules/illustrations/empty-collection.tsx +66 -0
  61. package/src/components/molecules/illustrations/empty-database.tsx +44 -0
  62. package/src/components/molecules/illustrations/empty-media.tsx +68 -0
  63. package/src/components/molecules/illustrations/error.tsx +64 -0
  64. package/src/components/molecules/illustrations/first-run.tsx +55 -0
  65. package/src/components/molecules/illustrations/illustration-types.tsx +9 -0
  66. package/src/components/molecules/illustrations/no-activity.tsx +49 -0
  67. package/src/components/molecules/illustrations/no-deployments.tsx +49 -0
  68. package/src/components/molecules/illustrations/no-documents.tsx +67 -0
  69. package/src/components/molecules/illustrations/no-keys.tsx +50 -0
  70. package/src/components/molecules/illustrations/no-logs.tsx +53 -0
  71. package/src/components/molecules/illustrations/no-members.tsx +56 -0
  72. package/src/components/molecules/illustrations/no-notifications.tsx +52 -0
  73. package/src/components/molecules/illustrations/no-orders.tsx +48 -0
  74. package/src/components/molecules/illustrations/no-plugins.tsx +72 -0
  75. package/src/components/molecules/illustrations/no-results.tsx +69 -0
  76. package/src/components/molecules/illustrations/not-found.tsx +48 -0
  77. package/src/components/molecules/illustrations/offline.tsx +43 -0
  78. package/src/components/molecules/illustrations/pending-approval.tsx +56 -0
  79. package/src/components/molecules/illustrations/upload-dropzone.tsx +59 -0
  80. package/src/components/molecules/money.tsx +91 -0
  81. package/src/components/{custom → molecules}/multi-select.tsx +2 -2
  82. package/src/components/molecules/page-header.tsx +80 -0
  83. package/src/components/molecules/password-input.tsx +47 -0
  84. package/src/components/molecules/relative-time.tsx +81 -0
  85. package/src/components/molecules/stat-card.tsx +134 -0
  86. package/src/components/molecules/state-views/empty-state.tsx +75 -0
  87. package/src/components/molecules/state-views/error-state.tsx +91 -0
  88. package/src/components/molecules/state-views/loading-state.tsx +80 -0
  89. package/src/components/molecules/status-badge.tsx +100 -0
  90. package/src/hooks/use-copy.ts +51 -0
  91. package/src/lib/base-ui.ts +1 -1
  92. package/src/lib/format-relative.ts +41 -0
  93. package/src/lib/state-dictionary.ts +137 -0
  94. package/src/components/ui/checkbox.tsx +0 -30
  95. package/src/components/ui/skeleton.tsx +0 -13
  96. /package/src/components/{ui → atoms}/accordion.tsx +0 -0
  97. /package/src/components/{ui → atoms}/alert.tsx +0 -0
  98. /package/src/components/{ui → atoms}/aspect-ratio.tsx +0 -0
  99. /package/src/components/{ui → atoms}/avatar.tsx +0 -0
  100. /package/src/components/{ui → atoms}/breadcrumb.tsx +0 -0
  101. /package/src/components/{ui → atoms}/chart.tsx +0 -0
  102. /package/src/components/{ui → atoms}/collapsible.tsx +0 -0
  103. /package/src/components/{ui → atoms}/direction.tsx +0 -0
  104. /package/src/components/{ui → atoms}/kbd.tsx +0 -0
  105. /package/src/components/{ui → atoms}/label.tsx +0 -0
  106. /package/src/components/{ui → atoms}/progress.tsx +0 -0
  107. /package/src/components/{ui → atoms}/radio-group.tsx +0 -0
  108. /package/src/components/{ui → atoms}/resizable.tsx +0 -0
  109. /package/src/components/{ui → atoms}/scroll-area.tsx +0 -0
  110. /package/src/components/{ui → atoms}/separator.tsx +0 -0
  111. /package/src/components/{ui → atoms}/spinner.tsx +0 -0
  112. /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
+ }
@@ -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/ui/*`
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