@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,263 @@
1
+ 'use client';
2
+
3
+ import { Button } from '@vendure-io/ui/components/atoms/button';
4
+ import {
5
+ Select,
6
+ SelectContent,
7
+ SelectGroup,
8
+ SelectItem,
9
+ SelectTrigger,
10
+ SelectValue,
11
+ } from '@vendure-io/ui/components/atoms/select';
12
+ import { cn } from '@vendure-io/ui/lib/utils';
13
+ import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
14
+ import type * as React from 'react';
15
+
16
+ // The list-page pagination footer: range text on one side, optional page-size
17
+ // selector and prev/next controls on the other. TanStack-free and
18
+ // fetch-agnostic — driven by value props, not a table instance.
19
+ //
20
+ // Capability-props contract: a feature renders only if its prop is wired. The
21
+ // page-size selector appears only with `onPageSizeChange`; prev/next appear only
22
+ // with `onPageChange` or `getPageHref`. There are no feature flags and no
23
+ // disabled placeholder UI for capabilities the consumer didn't opt into.
24
+
25
+ /** max(1, …) so an empty result still reports a single page, never zero pages. */
26
+ function getPageCount(totalItems: number, pageSize: number): number {
27
+ return Math.max(1, Math.ceil(totalItems / pageSize));
28
+ }
29
+
30
+ /** Clamp a possibly out-of-range page into `[1, pageCount]`. */
31
+ function clampPage(page: number, pageCount: number): number {
32
+ return Math.min(Math.max(page, 1), pageCount);
33
+ }
34
+
35
+ /**
36
+ * The 1-based item range shown on the current page. Empty results collapse to
37
+ * `{ 0, 0 }` so the caller can render "0 of 0" rather than a nonsensical "1–0".
38
+ * The last partial page clamps its end to `totalItems` (e.g. "126–132 of 132").
39
+ */
40
+ function getRange(
41
+ page: number,
42
+ pageSize: number,
43
+ totalItems: number,
44
+ ): { start: number; end: number } {
45
+ if (totalItems <= 0) return { start: 0, end: 0 };
46
+ const clamped = clampPage(page, getPageCount(totalItems, pageSize));
47
+ const start = (clamped - 1) * pageSize + 1;
48
+ const end = Math.min(clamped * pageSize, totalItems);
49
+ return { start, end };
50
+ }
51
+
52
+ /** Default range string: "0 of 0" when empty, else "start–end of total" (en dash). */
53
+ function defaultFormatRange({
54
+ start,
55
+ end,
56
+ total,
57
+ }: {
58
+ start: number;
59
+ end: number;
60
+ total: number;
61
+ }): string {
62
+ return total <= 0 ? '0 of 0' : `${start}–${end} of ${total}`;
63
+ }
64
+
65
+ interface TablePaginationProps extends React.ComponentProps<'div'> {
66
+ /**
67
+ * 1-based current page. The footer clamps this into range for what it
68
+ * *displays* only — it does not own page state. If `page` can end up past the
69
+ * last page (e.g. after the page size grows), reset it in your own handler;
70
+ * see `onPageSizeChange`.
71
+ */
72
+ page: number;
73
+ /** Items per page — also scales the range math. */
74
+ pageSize: number;
75
+ /** Total items across all pages, used for the range text and last-page detection. */
76
+ totalItems: number;
77
+ /**
78
+ * Wires button-mode prev/next: renders `<button>`s that call this with the
79
+ * target page. Ignored for navigation rendering when `getPageHref` is set.
80
+ */
81
+ onPageChange?: (page: number) => void;
82
+ /**
83
+ * Its presence is the only thing that renders the page-size selector. Called
84
+ * with the chosen size. You own page state: growing the size can leave `page`
85
+ * pointing past the new last page, so reset it here (typically `setPage(1)`).
86
+ * The footer clamps only its own display, never your state.
87
+ */
88
+ onPageSizeChange?: (size: number) => void;
89
+ /** Options offered by the page-size selector. */
90
+ pageSizeOptions?: number[];
91
+ /**
92
+ * Anchor mode: prev/next render as real `<a href>` elements for full-page
93
+ * navigation (RSC-friendly). When set, it wins over `onPageChange`. A disabled
94
+ * edge renders a non-link with `aria-disabled` rather than an `<a>`.
95
+ */
96
+ getPageHref?: (page: number) => string;
97
+ /**
98
+ * Override the range text. Receives the clamped 1-based range and total, and
99
+ * defaults to "start–end of total" ("0 of 0" when empty). A function, not a
100
+ * word-swap prop, so it survives non-English word order.
101
+ */
102
+ formatRange?: (range: { start: number; end: number; total: number }) => string;
103
+ /** Accessible name for the prev/next navigation landmark. */
104
+ navLabel?: string;
105
+ /** Accessible name for the previous-page control. */
106
+ previousLabel?: string;
107
+ /** Accessible name for the next-page control. */
108
+ nextLabel?: string;
109
+ /** Visible + accessible name for the page-size selector. */
110
+ pageSizeLabel?: string;
111
+ }
112
+
113
+ function TablePagination({
114
+ page,
115
+ pageSize,
116
+ totalItems,
117
+ onPageChange,
118
+ onPageSizeChange,
119
+ pageSizeOptions = [10, 25, 50, 100],
120
+ getPageHref,
121
+ formatRange = defaultFormatRange,
122
+ navLabel = 'Pagination',
123
+ previousLabel = 'Go to previous page',
124
+ nextLabel = 'Go to next page',
125
+ pageSizeLabel = 'Items per page',
126
+ className,
127
+ ...props
128
+ }: TablePaginationProps) {
129
+ const pageCount = getPageCount(totalItems, pageSize);
130
+ const current = clampPage(page, pageCount);
131
+ const { start, end } = getRange(page, pageSize, totalItems);
132
+
133
+ const rangeText = formatRange({ start, end, total: totalItems });
134
+
135
+ const anchorMode = Boolean(getPageHref);
136
+ const showNav = anchorMode || Boolean(onPageChange);
137
+ const isFirst = current <= 1;
138
+ const isLast = current >= pageCount;
139
+
140
+ return (
141
+ <div
142
+ data-slot="table-pagination"
143
+ className={cn('flex items-center justify-between gap-4', className)}
144
+ {...props}
145
+ >
146
+ <span className="text-muted-foreground text-sm tabular-nums">{rangeText}</span>
147
+
148
+ {(onPageSizeChange || showNav) && (
149
+ <div className="flex items-center gap-4">
150
+ {onPageSizeChange && (
151
+ <div className="flex items-center gap-2">
152
+ <span className="text-muted-foreground text-sm">{pageSizeLabel}</span>
153
+ <Select value={pageSize} onValueChange={(value) => onPageSizeChange(Number(value))}>
154
+ <SelectTrigger size="sm" aria-label={pageSizeLabel} className="w-fit">
155
+ <SelectValue />
156
+ </SelectTrigger>
157
+ <SelectContent>
158
+ <SelectGroup>
159
+ {pageSizeOptions.map((size) => (
160
+ <SelectItem key={size} value={size}>
161
+ {size}
162
+ </SelectItem>
163
+ ))}
164
+ </SelectGroup>
165
+ </SelectContent>
166
+ </Select>
167
+ </div>
168
+ )}
169
+
170
+ {showNav && (
171
+ // Only the prev/next group is a navigation landmark — not the range
172
+ // text or size selector. Mirrors the shadcn pagination atom's
173
+ // role="navigation" + aria-label.
174
+ <nav aria-label={navLabel} className="flex items-center gap-1">
175
+ <NavButton
176
+ slot="table-pagination-previous"
177
+ anchor={anchorMode}
178
+ disabled={isFirst}
179
+ label={previousLabel}
180
+ href={anchorMode && !isFirst ? getPageHref?.(current - 1) : undefined}
181
+ onClick={onPageChange ? () => onPageChange(current - 1) : undefined}
182
+ >
183
+ <ChevronLeftIcon />
184
+ </NavButton>
185
+ <NavButton
186
+ slot="table-pagination-next"
187
+ anchor={anchorMode}
188
+ disabled={isLast}
189
+ label={nextLabel}
190
+ href={anchorMode && !isLast ? getPageHref?.(current + 1) : undefined}
191
+ onClick={onPageChange ? () => onPageChange(current + 1) : undefined}
192
+ >
193
+ <ChevronRightIcon />
194
+ </NavButton>
195
+ </nav>
196
+ )}
197
+ </div>
198
+ )}
199
+ </div>
200
+ );
201
+ }
202
+
203
+ function NavButton({
204
+ slot,
205
+ anchor,
206
+ href,
207
+ onClick,
208
+ disabled,
209
+ label,
210
+ children,
211
+ }: {
212
+ slot: string;
213
+ anchor: boolean;
214
+ href?: string;
215
+ onClick?: () => void;
216
+ disabled: boolean;
217
+ label: string;
218
+ children: React.ReactNode;
219
+ }) {
220
+ const shared = {
221
+ variant: 'outline' as const,
222
+ size: 'icon-sm' as const,
223
+ 'data-slot': slot,
224
+ };
225
+
226
+ if (anchor) {
227
+ // A disabled edge is a non-link (span) so it isn't focusable or followable.
228
+ // The icon lives inside the render element (rather than as a Button child) so
229
+ // the anchor's content is statically visible to the a11y linter. No aria-label
230
+ // on the span — naming a role-less, non-interactive element is prohibited.
231
+ if (disabled) {
232
+ return (
233
+ <Button
234
+ {...shared}
235
+ aria-disabled
236
+ nativeButton={false}
237
+ className="pointer-events-none opacity-50"
238
+ render={<span>{children}</span>}
239
+ />
240
+ );
241
+ }
242
+ return (
243
+ <Button
244
+ {...shared}
245
+ aria-label={label}
246
+ nativeButton={false}
247
+ render={
248
+ <a href={href} aria-label={label}>
249
+ {children}
250
+ </a>
251
+ }
252
+ />
253
+ );
254
+ }
255
+
256
+ return (
257
+ <Button {...shared} aria-label={label} disabled={disabled} onClick={onClick}>
258
+ {children}
259
+ </Button>
260
+ );
261
+ }
262
+
263
+ export { TablePagination, type TablePaginationProps };
@@ -0,0 +1,73 @@
1
+ 'use client';
2
+
3
+ import { useFormatSettings } from '@vendure-io/ui/components/molecules/format-provider';
4
+ import type { ComponentProps, ReactNode } from 'react';
5
+
6
+ function toDate(value: string | number | Date | null | undefined): Date | null {
7
+ if (value === null || value === undefined) return null;
8
+ const date = value instanceof Date ? value : new Date(value);
9
+ return Number.isNaN(date.getTime()) ? null : date;
10
+ }
11
+
12
+ interface DateTimeProps extends Omit<ComponentProps<'time'>, 'dateTime'> {
13
+ value: string | number | Date | null | undefined;
14
+ locale?: string;
15
+ /** `Intl` date preset. Default `'medium'`. */
16
+ dateStyle?: Intl.DateTimeFormatOptions['dateStyle'];
17
+ /** `Intl` time preset. Omit for a date-only rendering. */
18
+ timeStyle?: Intl.DateTimeFormatOptions['timeStyle'];
19
+ timeZone?: string;
20
+ /** Escape hatch — full control, ignores `dateStyle`/`timeStyle`. */
21
+ formatOptions?: Intl.DateTimeFormatOptions;
22
+ /** Rendered for nullish/invalid input. Default `'—'`. */
23
+ fallback?: ReactNode;
24
+ }
25
+
26
+ /**
27
+ * A single semantic `<time>` — the one presentation everyone shares. Callers
28
+ * that want the old two-line date-over-muted-time stack compose it themselves.
29
+ *
30
+ * SSR note: output depends on locale/timeZone, so server and client renders can
31
+ * disagree when either falls back to the runtime default. SSR consumers should
32
+ * pin both via `FormatProvider` (or props) to avoid hydration mismatches.
33
+ */
34
+ function DateTime({
35
+ value,
36
+ locale,
37
+ dateStyle,
38
+ timeStyle,
39
+ timeZone,
40
+ formatOptions,
41
+ fallback = '—',
42
+ ...props
43
+ }: DateTimeProps) {
44
+ const settings = useFormatSettings();
45
+ const date = toDate(value);
46
+ if (!date) return <>{fallback}</>;
47
+
48
+ // Explicit wins over context: an escape-hatch `formatOptions.timeZone` must
49
+ // not be clobbered by the provider's zone.
50
+ const resolvedTimeZone = timeZone ?? formatOptions?.timeZone ?? settings.timeZone;
51
+ const options: Intl.DateTimeFormatOptions = {
52
+ ...(formatOptions ?? {
53
+ dateStyle: dateStyle ?? 'medium',
54
+ ...(timeStyle ? { timeStyle } : {}),
55
+ }),
56
+ ...(resolvedTimeZone ? { timeZone: resolvedTimeZone } : {}),
57
+ };
58
+
59
+ let formatted: string;
60
+ try {
61
+ formatted = new Intl.DateTimeFormat(locale ?? settings.locale, options).format(date);
62
+ } catch {
63
+ return <>{fallback}</>;
64
+ }
65
+
66
+ return (
67
+ <time data-slot="date-time" dateTime={date.toISOString()} {...props}>
68
+ {formatted}
69
+ </time>
70
+ );
71
+ }
72
+
73
+ export { DateTime, type DateTimeProps };
@@ -0,0 +1,120 @@
1
+ 'use client';
2
+
3
+ import { cn } from '@vendure-io/ui/lib/utils';
4
+ import { cva, type VariantProps } from 'class-variance-authority';
5
+ import { type ComponentProps, createContext, type ReactNode, useContext } from 'react';
6
+
7
+ // A read-only label/value list with valid `dl`/`dt`/`dd` semantics — the
8
+ // display counterpart to the form-oriented `field.tsx` atom. In horizontal and
9
+ // (at container width) responsive mode, each item collapses to `display:
10
+ // contents` so its `dt`/`dd` land directly in the list's two-column grid.
11
+ type Orientation = 'vertical' | 'horizontal' | 'responsive';
12
+
13
+ const descriptionListVariants = cva('text-sm', {
14
+ variants: {
15
+ orientation: {
16
+ vertical: 'flex flex-col gap-4',
17
+ horizontal: 'grid grid-cols-[auto_1fr] gap-x-6 gap-y-3',
18
+ responsive: 'flex flex-col gap-4 @md:grid @md:grid-cols-[auto_1fr] @md:gap-x-6 @md:gap-y-3',
19
+ },
20
+ },
21
+ defaultVariants: {
22
+ orientation: 'vertical',
23
+ },
24
+ });
25
+
26
+ const DescriptionListContext = createContext<Orientation>('vertical');
27
+
28
+ function DescriptionList({
29
+ className,
30
+ orientation = 'vertical',
31
+ ...props
32
+ }: ComponentProps<'dl'> & VariantProps<typeof descriptionListVariants>) {
33
+ const list = (
34
+ <dl
35
+ data-slot="description-list"
36
+ data-orientation={orientation}
37
+ className={cn(descriptionListVariants({ orientation }), className)}
38
+ {...props}
39
+ />
40
+ );
41
+ return (
42
+ <DescriptionListContext.Provider value={orientation ?? 'vertical'}>
43
+ {orientation === 'responsive' ? (
44
+ // An element cannot respond to its own container query: if the dl
45
+ // carried `@container`, its `@md:grid` would resolve against the nearest
46
+ // ANCESTOR container while the items' `@md:contents` resolve against the
47
+ // dl — and the two can disagree (narrow side panel inside a wide page).
48
+ // A wrapper div is the container, so the dl and every item query the
49
+ // same box.
50
+ <div data-slot="description-list-container" className="@container">
51
+ {list}
52
+ </div>
53
+ ) : (
54
+ list
55
+ )}
56
+ </DescriptionListContext.Provider>
57
+ );
58
+ }
59
+
60
+ function DescriptionItem({ className, ...props }: ComponentProps<'div'>) {
61
+ const orientation = useContext(DescriptionListContext);
62
+ return (
63
+ <div
64
+ data-slot="description-item"
65
+ className={cn(
66
+ orientation === 'horizontal' && 'contents',
67
+ orientation === 'responsive' && 'flex flex-col gap-0.5 @md:contents',
68
+ orientation === 'vertical' && 'flex flex-col gap-0.5',
69
+ className,
70
+ )}
71
+ {...props}
72
+ />
73
+ );
74
+ }
75
+
76
+ function DescriptionTerm({ className, ...props }: ComponentProps<'dt'>) {
77
+ return (
78
+ <dt
79
+ data-slot="description-term"
80
+ className={cn('text-muted-foreground', className)}
81
+ {...props}
82
+ />
83
+ );
84
+ }
85
+
86
+ function DescriptionDetail({ className, ...props }: ComponentProps<'dd'>) {
87
+ return (
88
+ <dd
89
+ data-slot="description-detail"
90
+ className={cn('font-medium break-words', className)}
91
+ {...props}
92
+ />
93
+ );
94
+ }
95
+
96
+ interface DescriptionListItemProps extends ComponentProps<'div'> {
97
+ label: ReactNode;
98
+ /** The value, deliberately children rather than a `value` prop. */
99
+ children: ReactNode;
100
+ }
101
+
102
+ // Sugar covering ~95% of found call sites: a term/detail pair in one line.
103
+ function DescriptionListItem({ label, children, ...props }: DescriptionListItemProps) {
104
+ return (
105
+ <DescriptionItem {...props}>
106
+ <DescriptionTerm>{label}</DescriptionTerm>
107
+ <DescriptionDetail>{children}</DescriptionDetail>
108
+ </DescriptionItem>
109
+ );
110
+ }
111
+
112
+ export {
113
+ DescriptionList,
114
+ DescriptionItem,
115
+ DescriptionTerm,
116
+ DescriptionDetail,
117
+ DescriptionListItem,
118
+ descriptionListVariants,
119
+ type DescriptionListItemProps,
120
+ };
@@ -0,0 +1,46 @@
1
+ 'use client';
2
+
3
+ import { createContext, type ReactNode, useContext, useMemo } from 'react';
4
+
5
+ // The design system cannot depend on any app's i18n. Formatters resolve their
6
+ // locale/currency in a fixed order — explicit prop → this context → `undefined`
7
+ // (a valid Intl argument that falls back to the runtime default locale) — so a
8
+ // bare `<Money>` or `<DateTime>` works with zero setup, and an app that mounts
9
+ // `FormatProvider` (e.g. from its own locale + server config) gets consistent
10
+ // output everywhere without threading props.
11
+ interface FormatContextValue {
12
+ /** BCP 47 locale tag, e.g. `en-GB`. */
13
+ locale?: string;
14
+ /** IANA time zone, e.g. `Europe/Berlin`. */
15
+ timeZone?: string;
16
+ /** Default ISO 4217 currency code used by `<Money>` when none is passed. */
17
+ currency?: string;
18
+ /**
19
+ * Number of minor-unit digits money integers are stored with (Vendure's
20
+ * `moneyStrategyPrecision`). `undefined` → each currency's own Intl fraction
21
+ * digits (2 for USD, 0 for JPY, 3 for BHD).
22
+ */
23
+ currencyPrecision?: number;
24
+ }
25
+
26
+ const FormatContext = createContext<FormatContextValue>({});
27
+
28
+ function FormatProvider({
29
+ children,
30
+ locale,
31
+ timeZone,
32
+ currency,
33
+ currencyPrecision,
34
+ }: FormatContextValue & { children: ReactNode }) {
35
+ const value = useMemo(
36
+ () => ({ locale, timeZone, currency, currencyPrecision }),
37
+ [locale, timeZone, currency, currencyPrecision],
38
+ );
39
+ return <FormatContext.Provider value={value}>{children}</FormatContext.Provider>;
40
+ }
41
+
42
+ function useFormatSettings(): FormatContextValue {
43
+ return useContext(FormatContext);
44
+ }
45
+
46
+ export { FormatProvider, useFormatSettings, type FormatContextValue };
@@ -0,0 +1,79 @@
1
+ 'use client';
2
+
3
+ import { CopyButton } from '@vendure-io/ui/components/molecules/copyable-text';
4
+ import { cn } from '@vendure-io/ui/lib/utils';
5
+
6
+ type TruncateMode = 'middle' | 'start' | 'none';
7
+
8
+ /**
9
+ * Shared chrome for the identifier-chip family (`IdChip`, `AnonymizedToken`): a
10
+ * bordered, `bg-muted`, monospace `text-xs` pill sized to its content. Kept in
11
+ * one place so the two chips can't drift apart — only their *contents* differ
12
+ * (a plain value vs a blurred, reveal-on-hover secret).
13
+ */
14
+ function chipChromeClass(copyable: boolean, className?: string): string {
15
+ return cn(
16
+ 'inline-flex w-fit items-center gap-1 rounded-md border bg-muted py-0.5 pl-1.5 font-mono text-xs',
17
+ copyable ? 'pr-0.5' : 'pr-1.5',
18
+ className,
19
+ );
20
+ }
21
+
22
+ // Middle truncation renders 13 chars (8 + ellipsis + 4), so anything at or
23
+ // below that length would lose information without saving space — skip it.
24
+ const TRUNCATE_THRESHOLD = 13;
25
+
26
+ function truncateId(value: string, mode: TruncateMode): string {
27
+ if (mode === 'none' || value.length <= TRUNCATE_THRESHOLD) return value;
28
+ // Middle keeps a recognizable head + tail (proven `AnonymizedToken` pattern);
29
+ // start drops the (often constant) prefix and keeps the distinguishing tail.
30
+ return mode === 'middle' ? `${value.slice(0, 8)}…${value.slice(-4)}` : `…${value.slice(-8)}`;
31
+ }
32
+
33
+ interface IdChipProps {
34
+ /** The full value — always copied in full and revealed on hover via `title`. */
35
+ value: string;
36
+ /** Override the rendered text (e.g. a longer prefix). Bypasses `truncate`. */
37
+ display?: string;
38
+ /** How to shorten the rendered value. @default 'middle' */
39
+ truncate?: TruncateMode;
40
+ /** Render the copy affordance. @default true */
41
+ copyable?: boolean;
42
+ className?: string;
43
+ /** Called after a successful copy. Wire your toast here — the DS never toasts. */
44
+ onCopied?: () => void;
45
+ }
46
+
47
+ /**
48
+ * A bordered, monospace chip for IDs/tokens with a built-in copy affordance.
49
+ * The rendered text is truncated for density while the full value is copied and
50
+ * available on hover (`title`).
51
+ */
52
+ function IdChip({
53
+ value,
54
+ display,
55
+ truncate = 'middle',
56
+ copyable = true,
57
+ className,
58
+ onCopied,
59
+ }: IdChipProps) {
60
+ const text = display ?? truncateId(value, truncate);
61
+
62
+ return (
63
+ <span data-slot="id-chip" title={value} className={chipChromeClass(copyable, className)}>
64
+ <span className="truncate">{text}</span>
65
+ {/* 16px visual button to match the chip's density, but the ::after inset
66
+ extends the hit target to 24px for the WCAG 2.2 AA minimum. */}
67
+ {copyable ? (
68
+ <CopyButton
69
+ value={value}
70
+ onCopied={onCopied}
71
+ className="relative size-4 after:absolute after:-inset-1"
72
+ />
73
+ ) : null}
74
+ </span>
75
+ );
76
+ }
77
+
78
+ export { chipChromeClass, IdChip };
79
+ export type { IdChipProps };
@@ -0,0 +1,55 @@
1
+ import type { IllustrationProps } from '@vendure-io/ui/components/molecules/illustrations/illustration-types';
2
+ import { cn } from '@vendure-io/ui/lib/utils';
3
+
4
+ /**
5
+ * A closed padlock centered on a shield, roped off by a dashed barrier strung
6
+ * between two posts. For permission/authorization failures — pass as
7
+ * `illustration` to `ErrorState` alongside a "Go back" or "Request access"
8
+ * action, not a retry. Not for generic load failures (use `ErrorIllustration`)
9
+ * or missing resources (use `NotFoundIllustration`).
10
+ */
11
+ function AccessDeniedIllustration({ className, size = 160 }: IllustrationProps) {
12
+ return (
13
+ <svg
14
+ viewBox="0 0 160 120"
15
+ width={size}
16
+ height={(size * 120) / 160}
17
+ fill="none"
18
+ strokeWidth={1.5}
19
+ strokeLinecap="round"
20
+ strokeLinejoin="round"
21
+ aria-hidden="true"
22
+ focusable="false"
23
+ className={cn('shrink-0', className)}
24
+ >
25
+ <ellipse cx="80" cy="104" rx="28" ry="5" className="fill-muted" />
26
+
27
+ {/* barrier posts + dashed rope, blocking the way forward */}
28
+ <line x1="44" y1="80" x2="44" y2="98" className="stroke-muted-foreground" />
29
+ <line x1="116" y1="80" x2="116" y2="98" className="stroke-muted-foreground" />
30
+ <path d="M44,82 Q80,96 116,82" className="stroke-border" strokeDasharray="3 4" />
31
+
32
+ {/* shield */}
33
+ <path
34
+ d="M80,24 L102,32 L102,56 C102,74 92,86 80,92 C68,86 58,74 58,56 L58,32 Z"
35
+ className="fill-surface stroke-muted-foreground"
36
+ />
37
+
38
+ {/* padlock: solid shackle + body */}
39
+ <path d="M74,56 v-7 a6,6 0 0,1 12,0 v7" className="stroke-muted-foreground" />
40
+ <rect
41
+ x="70"
42
+ y="56"
43
+ width="20"
44
+ height="17"
45
+ rx="3"
46
+ className="fill-surface stroke-muted-foreground"
47
+ />
48
+
49
+ {/* keyhole — the one brand accent */}
50
+ <circle cx="80" cy="63" r="2" className="fill-brand" />
51
+ </svg>
52
+ );
53
+ }
54
+
55
+ export { AccessDeniedIllustration };