@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.
- package/README.md +5 -5
- package/package.json +9 -4
- 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,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 };
|