@vendure-io/ui 2.0.0-beta.0 → 2.0.0-beta.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -3
- package/package.json +7 -7
- package/src/components/atoms/button.tsx +5 -1
- package/src/components/atoms/card.tsx +1 -1
- package/src/components/atoms/scroll-area.tsx +4 -1
- package/src/components/atoms/sidebar.tsx +26 -11
- package/src/components/atoms/table.tsx +1 -1
- package/src/components/molecules/anonymized-token.tsx +2 -2
- package/src/components/molecules/app-shell.tsx +110 -0
- package/src/components/molecules/code-block.tsx +996 -0
- package/src/components/molecules/copy-feedback-provider.tsx +33 -0
- package/src/components/molecules/copyable-text.tsx +30 -7
- package/src/components/molecules/data-table/data-table.tsx +87 -85
- package/src/components/molecules/date-picker.tsx +117 -0
- package/src/components/molecules/date-range-picker.tsx +151 -0
- package/src/components/molecules/date-time-picker.tsx +131 -0
- package/src/components/molecules/file-dropzone.tsx +261 -0
- package/src/components/molecules/id-chip.tsx +1 -1
- package/src/components/molecules/skip-link.tsx +36 -0
- package/src/components/molecules/state-views/loading-state.tsx +7 -3
- package/src/lib/date-value.ts +48 -0
- package/src/lib/highlight.ts +141 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Button } from '@vendure-io/ui/components/atoms/button';
|
|
4
|
+
import { Calendar } from '@vendure-io/ui/components/atoms/calendar';
|
|
5
|
+
import { Input } from '@vendure-io/ui/components/atoms/input';
|
|
6
|
+
import { Popover, PopoverContent, PopoverTrigger } from '@vendure-io/ui/components/atoms/popover';
|
|
7
|
+
import { useFormatSettings } from '@vendure-io/ui/components/molecules/format-provider';
|
|
8
|
+
import { formatDateLabel, parseInstant } from '@vendure-io/ui/lib/date-value';
|
|
9
|
+
import { cn } from '@vendure-io/ui/lib/utils';
|
|
10
|
+
import { CalendarClockIcon, XIcon } from 'lucide-react';
|
|
11
|
+
import * as React from 'react';
|
|
12
|
+
|
|
13
|
+
type CalendarProps = Omit<
|
|
14
|
+
React.ComponentProps<typeof Calendar>,
|
|
15
|
+
'mode' | 'selected' | 'onSelect' | 'defaultMonth'
|
|
16
|
+
>;
|
|
17
|
+
|
|
18
|
+
interface DateTimePickerProps
|
|
19
|
+
extends Omit<React.ComponentProps<'div'>, 'defaultValue' | 'onChange'> {
|
|
20
|
+
/** ISO 8601 instant. Calendar and time controls edit it in the user's local zone. */
|
|
21
|
+
value?: string;
|
|
22
|
+
onValueChange?: (value: string | undefined) => void;
|
|
23
|
+
placeholder?: React.ReactNode;
|
|
24
|
+
clearable?: boolean;
|
|
25
|
+
disabled?: boolean;
|
|
26
|
+
invalid?: boolean;
|
|
27
|
+
name?: string;
|
|
28
|
+
id?: string;
|
|
29
|
+
calendarProps?: CalendarProps;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function DateTimePicker({
|
|
33
|
+
value,
|
|
34
|
+
onValueChange,
|
|
35
|
+
placeholder = 'Pick a date and time',
|
|
36
|
+
clearable = true,
|
|
37
|
+
disabled,
|
|
38
|
+
invalid,
|
|
39
|
+
name,
|
|
40
|
+
id,
|
|
41
|
+
calendarProps,
|
|
42
|
+
className,
|
|
43
|
+
...props
|
|
44
|
+
}: DateTimePickerProps) {
|
|
45
|
+
const [open, setOpen] = React.useState(false);
|
|
46
|
+
const { locale } = useFormatSettings();
|
|
47
|
+
const selected = parseInstant(value);
|
|
48
|
+
const timeValue = selected
|
|
49
|
+
? `${String(selected.getHours()).padStart(2, '0')}:${String(selected.getMinutes()).padStart(2, '0')}`
|
|
50
|
+
: '';
|
|
51
|
+
|
|
52
|
+
function commitDate(date: Date | undefined) {
|
|
53
|
+
if (!date) return;
|
|
54
|
+
const next = new Date(date);
|
|
55
|
+
next.setHours(selected?.getHours() ?? 0, selected?.getMinutes() ?? 0, 0, 0);
|
|
56
|
+
onValueChange?.(next.toISOString());
|
|
57
|
+
setOpen(false);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function commitTime(time: string) {
|
|
61
|
+
if (!time) return;
|
|
62
|
+
const [hoursText, minutesText] = time.split(':');
|
|
63
|
+
const hours = Number(hoursText);
|
|
64
|
+
const minutes = Number(minutesText);
|
|
65
|
+
if (!Number.isFinite(hours) || !Number.isFinite(minutes)) return;
|
|
66
|
+
const next = selected ? new Date(selected) : new Date();
|
|
67
|
+
next.setHours(hours, minutes, 0, 0);
|
|
68
|
+
onValueChange?.(next.toISOString());
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<div
|
|
73
|
+
data-slot="date-time-picker"
|
|
74
|
+
className={cn('flex min-w-0 items-center gap-2', className)}
|
|
75
|
+
{...props}
|
|
76
|
+
>
|
|
77
|
+
{name ? <input type="hidden" name={name} value={value ?? ''} /> : null}
|
|
78
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
79
|
+
<PopoverTrigger
|
|
80
|
+
render={
|
|
81
|
+
<Button
|
|
82
|
+
id={id}
|
|
83
|
+
type="button"
|
|
84
|
+
variant="outline"
|
|
85
|
+
disabled={disabled}
|
|
86
|
+
aria-invalid={invalid || undefined}
|
|
87
|
+
className="min-w-0 flex-1 justify-start font-normal"
|
|
88
|
+
/>
|
|
89
|
+
}
|
|
90
|
+
>
|
|
91
|
+
<CalendarClockIcon />
|
|
92
|
+
<span className={cn('truncate', !selected && 'text-muted-foreground')}>
|
|
93
|
+
{selected ? formatDateLabel(selected, locale) : placeholder}
|
|
94
|
+
</span>
|
|
95
|
+
</PopoverTrigger>
|
|
96
|
+
<PopoverContent className="w-auto p-0" align="start">
|
|
97
|
+
<Calendar
|
|
98
|
+
{...calendarProps}
|
|
99
|
+
mode="single"
|
|
100
|
+
selected={selected}
|
|
101
|
+
defaultMonth={selected}
|
|
102
|
+
onSelect={commitDate}
|
|
103
|
+
/>
|
|
104
|
+
</PopoverContent>
|
|
105
|
+
</Popover>
|
|
106
|
+
<Input
|
|
107
|
+
type="time"
|
|
108
|
+
aria-label="Time"
|
|
109
|
+
aria-invalid={invalid || undefined}
|
|
110
|
+
value={timeValue}
|
|
111
|
+
disabled={disabled || !selected}
|
|
112
|
+
className="w-28"
|
|
113
|
+
onChange={(event) => commitTime(event.currentTarget.value)}
|
|
114
|
+
/>
|
|
115
|
+
{clearable && selected ? (
|
|
116
|
+
<Button
|
|
117
|
+
type="button"
|
|
118
|
+
variant="ghost"
|
|
119
|
+
size="icon-sm"
|
|
120
|
+
disabled={disabled}
|
|
121
|
+
aria-label="Clear date and time"
|
|
122
|
+
onClick={() => onValueChange?.(undefined)}
|
|
123
|
+
>
|
|
124
|
+
<XIcon />
|
|
125
|
+
</Button>
|
|
126
|
+
) : null}
|
|
127
|
+
</div>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export { DateTimePicker, type DateTimePickerProps };
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Button } from '@vendure-io/ui/components/atoms/button';
|
|
4
|
+
import { cn } from '@vendure-io/ui/lib/utils';
|
|
5
|
+
import { FileIcon, UploadCloudIcon, XIcon } from 'lucide-react';
|
|
6
|
+
import * as React from 'react';
|
|
7
|
+
|
|
8
|
+
type FileValidationResult = string | readonly string[] | null | undefined;
|
|
9
|
+
type FileValidator = (file: File) => FileValidationResult | Promise<FileValidationResult>;
|
|
10
|
+
|
|
11
|
+
interface FileRejection {
|
|
12
|
+
file: File;
|
|
13
|
+
messages: string[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface FileDropzoneProps extends Omit<React.ComponentProps<'div'>, 'onChange'> {
|
|
17
|
+
value?: readonly File[];
|
|
18
|
+
onValueChange?: (files: File[]) => void;
|
|
19
|
+
onRejected?: (rejections: FileRejection[]) => void;
|
|
20
|
+
accept?: string;
|
|
21
|
+
multiple?: boolean;
|
|
22
|
+
maxFiles?: number;
|
|
23
|
+
maxSize?: number;
|
|
24
|
+
validateFile?: FileValidator;
|
|
25
|
+
disabled?: boolean;
|
|
26
|
+
required?: boolean;
|
|
27
|
+
name?: string;
|
|
28
|
+
id?: string;
|
|
29
|
+
label?: React.ReactNode;
|
|
30
|
+
description?: React.ReactNode;
|
|
31
|
+
emptyLabel?: React.ReactNode;
|
|
32
|
+
dragLabel?: React.ReactNode;
|
|
33
|
+
renderFile?: (file: File, remove: () => void) => React.ReactNode;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function fileMatchesAccept(file: File, accept: string | undefined): boolean {
|
|
37
|
+
if (!accept?.trim()) return true;
|
|
38
|
+
const fileName = file.name.toLowerCase();
|
|
39
|
+
const mime = file.type.toLowerCase();
|
|
40
|
+
return accept.split(',').some((rawRule) => {
|
|
41
|
+
const rule = rawRule.trim().toLowerCase();
|
|
42
|
+
if (!rule) return false;
|
|
43
|
+
if (rule.startsWith('.')) return fileName.endsWith(rule);
|
|
44
|
+
if (rule.endsWith('/*')) return mime.startsWith(rule.slice(0, -1));
|
|
45
|
+
return mime === rule;
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function formatFileSize(bytes: number): string {
|
|
50
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
51
|
+
if (bytes < 1024 ** 2) return `${Math.round(bytes / 1024)} KB`;
|
|
52
|
+
return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function normalizeValidationResult(result: FileValidationResult): string[] {
|
|
56
|
+
if (!result) return [];
|
|
57
|
+
return typeof result === 'string' ? [result] : [...result];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Controlled, transport-agnostic file input. It owns accessible picking,
|
|
62
|
+
* drag-and-drop, limits, validation, errors, and removal; consumers own upload
|
|
63
|
+
* persistence and can replace the selected-file rendering with `renderFile`.
|
|
64
|
+
*/
|
|
65
|
+
function FileDropzone({
|
|
66
|
+
value = [],
|
|
67
|
+
onValueChange,
|
|
68
|
+
onRejected,
|
|
69
|
+
accept,
|
|
70
|
+
multiple = false,
|
|
71
|
+
maxFiles = multiple ? Number.POSITIVE_INFINITY : 1,
|
|
72
|
+
maxSize,
|
|
73
|
+
validateFile,
|
|
74
|
+
disabled,
|
|
75
|
+
required,
|
|
76
|
+
name,
|
|
77
|
+
id: providedId,
|
|
78
|
+
label = 'Upload files',
|
|
79
|
+
description,
|
|
80
|
+
emptyLabel = 'Drag and drop files here, or choose files',
|
|
81
|
+
dragLabel = 'Drop files to add them',
|
|
82
|
+
renderFile,
|
|
83
|
+
className,
|
|
84
|
+
...props
|
|
85
|
+
}: FileDropzoneProps) {
|
|
86
|
+
const generatedId = React.useId();
|
|
87
|
+
const id = providedId ?? generatedId;
|
|
88
|
+
const descriptionId = `${id}-description`;
|
|
89
|
+
const errorId = `${id}-error`;
|
|
90
|
+
const [dragging, setDragging] = React.useState(false);
|
|
91
|
+
const [validating, setValidating] = React.useState(false);
|
|
92
|
+
const [rejections, setRejections] = React.useState<FileRejection[]>([]);
|
|
93
|
+
const dragDepth = React.useRef(0);
|
|
94
|
+
|
|
95
|
+
async function addFiles(incoming: readonly File[]) {
|
|
96
|
+
if (disabled || incoming.length === 0) return;
|
|
97
|
+
setValidating(true);
|
|
98
|
+
const accepted: File[] = [];
|
|
99
|
+
const rejected: FileRejection[] = [];
|
|
100
|
+
const remaining = Math.max(0, maxFiles - (multiple ? value.length : 0));
|
|
101
|
+
|
|
102
|
+
for (const file of incoming.slice(0, remaining)) {
|
|
103
|
+
const messages: string[] = [];
|
|
104
|
+
if (!fileMatchesAccept(file, accept)) messages.push('This file type is not accepted.');
|
|
105
|
+
if (maxSize !== undefined && file.size > maxSize) {
|
|
106
|
+
messages.push(`File size must be ${formatFileSize(maxSize)} or smaller.`);
|
|
107
|
+
}
|
|
108
|
+
if (validateFile) {
|
|
109
|
+
try {
|
|
110
|
+
messages.push(...normalizeValidationResult(await validateFile(file)));
|
|
111
|
+
} catch {
|
|
112
|
+
messages.push('This file could not be validated.');
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (messages.length > 0) rejected.push({ file, messages });
|
|
116
|
+
else accepted.push(file);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (incoming.length > remaining) {
|
|
120
|
+
for (const file of incoming.slice(remaining)) {
|
|
121
|
+
rejected.push({ file, messages: [`You can select up to ${maxFiles} files.`] });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
setRejections(rejected);
|
|
126
|
+
setValidating(false);
|
|
127
|
+
if (rejected.length > 0) onRejected?.(rejected);
|
|
128
|
+
if (accepted.length > 0) {
|
|
129
|
+
onValueChange?.(multiple ? [...value, ...accepted] : accepted.slice(0, 1));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function removeFile(index: number) {
|
|
134
|
+
onValueChange?.(value.filter((_, fileIndex) => fileIndex !== index));
|
|
135
|
+
setRejections([]);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return (
|
|
139
|
+
<div data-slot="file-dropzone" className={cn('flex flex-col gap-3', className)} {...props}>
|
|
140
|
+
<div>
|
|
141
|
+
<label htmlFor={id} className="text-sm font-medium">
|
|
142
|
+
{label}
|
|
143
|
+
{required ? <span className="text-destructive"> *</span> : null}
|
|
144
|
+
</label>
|
|
145
|
+
{description ? (
|
|
146
|
+
<p id={descriptionId} className="text-muted-foreground mt-1 text-sm">
|
|
147
|
+
{description}
|
|
148
|
+
</p>
|
|
149
|
+
) : null}
|
|
150
|
+
</div>
|
|
151
|
+
|
|
152
|
+
<input
|
|
153
|
+
id={id}
|
|
154
|
+
name={name}
|
|
155
|
+
type="file"
|
|
156
|
+
accept={accept}
|
|
157
|
+
multiple={multiple}
|
|
158
|
+
required={required && value.length === 0}
|
|
159
|
+
disabled={disabled || validating}
|
|
160
|
+
aria-describedby={
|
|
161
|
+
cn(description && descriptionId, rejections.length > 0 && errorId) || undefined
|
|
162
|
+
}
|
|
163
|
+
className="peer sr-only"
|
|
164
|
+
onChange={(event) => {
|
|
165
|
+
const files = Array.from(event.currentTarget.files ?? []);
|
|
166
|
+
event.currentTarget.value = '';
|
|
167
|
+
void addFiles(files);
|
|
168
|
+
}}
|
|
169
|
+
/>
|
|
170
|
+
|
|
171
|
+
<label
|
|
172
|
+
htmlFor={id}
|
|
173
|
+
data-slot="file-dropzone-target"
|
|
174
|
+
data-dragging={dragging || undefined}
|
|
175
|
+
data-disabled={disabled || validating || undefined}
|
|
176
|
+
onDragEnter={(event) => {
|
|
177
|
+
event.preventDefault();
|
|
178
|
+
if (disabled) return;
|
|
179
|
+
dragDepth.current += 1;
|
|
180
|
+
setDragging(true);
|
|
181
|
+
}}
|
|
182
|
+
onDragOver={(event) => {
|
|
183
|
+
event.preventDefault();
|
|
184
|
+
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy';
|
|
185
|
+
}}
|
|
186
|
+
onDragLeave={(event) => {
|
|
187
|
+
event.preventDefault();
|
|
188
|
+
dragDepth.current = Math.max(0, dragDepth.current - 1);
|
|
189
|
+
if (dragDepth.current === 0) setDragging(false);
|
|
190
|
+
}}
|
|
191
|
+
onDrop={(event) => {
|
|
192
|
+
event.preventDefault();
|
|
193
|
+
dragDepth.current = 0;
|
|
194
|
+
setDragging(false);
|
|
195
|
+
void addFiles(Array.from(event.dataTransfer.files));
|
|
196
|
+
}}
|
|
197
|
+
className={cn(
|
|
198
|
+
'border-border text-muted-foreground hover:border-foreground/40 flex min-h-32 cursor-pointer flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed px-6 py-8 text-center transition-[border-color,background-color,color] duration-(--transition-duration-fast) ease-(--ease-out)',
|
|
199
|
+
'peer-focus-visible:border-ring peer-focus-visible:ring-3 peer-focus-visible:ring-ring/50',
|
|
200
|
+
'data-dragging:border-foreground data-dragging:bg-accent data-dragging:text-accent-foreground',
|
|
201
|
+
'data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-disabled:opacity-50',
|
|
202
|
+
)}
|
|
203
|
+
>
|
|
204
|
+
<UploadCloudIcon className="size-7" />
|
|
205
|
+
<span className="text-foreground text-sm font-medium">
|
|
206
|
+
{validating ? 'Checking files…' : dragging ? dragLabel : emptyLabel}
|
|
207
|
+
</span>
|
|
208
|
+
{accept ? <span className="text-xs">Accepted: {accept}</span> : null}
|
|
209
|
+
</label>
|
|
210
|
+
|
|
211
|
+
{value.length > 0 ? (
|
|
212
|
+
<ul data-slot="file-dropzone-files" className="flex flex-col gap-2">
|
|
213
|
+
{value.map((file, index) => (
|
|
214
|
+
<li key={`${file.name}-${file.size}-${file.lastModified}`}>
|
|
215
|
+
{renderFile ? (
|
|
216
|
+
renderFile(file, () => removeFile(index))
|
|
217
|
+
) : (
|
|
218
|
+
<div className="bg-muted/60 flex min-w-0 items-center gap-3 rounded-lg border px-3 py-2">
|
|
219
|
+
<FileIcon className="text-muted-foreground size-4 shrink-0" />
|
|
220
|
+
<div className="min-w-0 flex-1">
|
|
221
|
+
<p className="truncate text-sm font-medium">{file.name}</p>
|
|
222
|
+
<p className="text-muted-foreground text-xs">{formatFileSize(file.size)}</p>
|
|
223
|
+
</div>
|
|
224
|
+
<Button
|
|
225
|
+
type="button"
|
|
226
|
+
variant="ghost"
|
|
227
|
+
size="icon-xs"
|
|
228
|
+
disabled={disabled}
|
|
229
|
+
aria-label={`Remove ${file.name}`}
|
|
230
|
+
onClick={() => removeFile(index)}
|
|
231
|
+
>
|
|
232
|
+
<XIcon />
|
|
233
|
+
</Button>
|
|
234
|
+
</div>
|
|
235
|
+
)}
|
|
236
|
+
</li>
|
|
237
|
+
))}
|
|
238
|
+
</ul>
|
|
239
|
+
) : null}
|
|
240
|
+
|
|
241
|
+
{rejections.length > 0 ? (
|
|
242
|
+
<div id={errorId} role="alert" className="text-destructive text-sm">
|
|
243
|
+
{rejections.map(({ file, messages }) => (
|
|
244
|
+
<p key={`${file.name}-${file.size}`}>
|
|
245
|
+
<span className="font-medium">{file.name}:</span> {messages.join(' ')}
|
|
246
|
+
</p>
|
|
247
|
+
))}
|
|
248
|
+
</div>
|
|
249
|
+
) : null}
|
|
250
|
+
</div>
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export {
|
|
255
|
+
FileDropzone,
|
|
256
|
+
fileMatchesAccept,
|
|
257
|
+
formatFileSize,
|
|
258
|
+
type FileDropzoneProps,
|
|
259
|
+
type FileRejection,
|
|
260
|
+
type FileValidator,
|
|
261
|
+
};
|
|
@@ -40,7 +40,7 @@ interface IdChipProps {
|
|
|
40
40
|
/** Render the copy affordance. @default true */
|
|
41
41
|
copyable?: boolean;
|
|
42
42
|
className?: string;
|
|
43
|
-
/** Called after a successful copy. Wire your toast here — the DS never toasts. */
|
|
43
|
+
/** Called after a successful copy. Wire your toast here — the DS never toasts. Falls back to `CopyFeedbackProvider`. */
|
|
44
44
|
onCopied?: () => void;
|
|
45
45
|
}
|
|
46
46
|
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { cn } from '@vendure-io/ui/lib/utils';
|
|
2
|
+
import type * as React from 'react';
|
|
3
|
+
|
|
4
|
+
interface SkipLinkProps extends React.ComponentProps<'a'> {
|
|
5
|
+
/** The main-content element to focus. Include the leading `#`. */
|
|
6
|
+
href?: `#${string}`;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Keyboard-only escape hatch for repeated application chrome. Place it as the
|
|
11
|
+
* first focusable element in the document and point it at `AppShellMain` (whose
|
|
12
|
+
* default id is `main-content`). Native anchor behavior keeps this server-safe
|
|
13
|
+
* and works before React hydrates.
|
|
14
|
+
*/
|
|
15
|
+
function SkipLink({
|
|
16
|
+
className,
|
|
17
|
+
href = '#main-content',
|
|
18
|
+
children = 'Skip to main content',
|
|
19
|
+
...props
|
|
20
|
+
}: SkipLinkProps) {
|
|
21
|
+
return (
|
|
22
|
+
<a
|
|
23
|
+
data-slot="skip-link"
|
|
24
|
+
href={href}
|
|
25
|
+
className={cn(
|
|
26
|
+
'bg-primary text-primary-foreground fixed top-3 left-3 z-[100] -translate-y-20 rounded-md px-3 py-2 text-sm font-medium opacity-0 outline-none transition-[transform,opacity] duration-(--transition-duration-fast) ease-(--ease-out) focus-visible:translate-y-0 focus-visible:opacity-100 focus-visible:ring-3 focus-visible:ring-ring/50 motion-reduce:transition-none',
|
|
27
|
+
className,
|
|
28
|
+
)}
|
|
29
|
+
{...props}
|
|
30
|
+
>
|
|
31
|
+
{children}
|
|
32
|
+
</a>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export { SkipLink, type SkipLinkProps };
|
|
@@ -24,13 +24,16 @@ export interface LoadingStateProps
|
|
|
24
24
|
rows?: number;
|
|
25
25
|
/** Classes applied to each skeleton row (e.g. `h-10` for tighter lists). */
|
|
26
26
|
rowClassName?: string;
|
|
27
|
-
/** Visible label. An sr-only
|
|
27
|
+
/** Visible label. An sr-only `srLabel` is always rendered for assistive tech. */
|
|
28
28
|
label?: React.ReactNode;
|
|
29
|
+
/** Sr-only label announced to assistive tech. Override for host i18n. @default "Loading…" */
|
|
30
|
+
srLabel?: string;
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
/**
|
|
32
34
|
* Placeholder shell for in-flight list/detail queries. Renders as an
|
|
33
|
-
* `aria-live` `<output>` with an always-present sr-only
|
|
35
|
+
* `aria-live` `<output>` with an always-present sr-only `srLabel` (defaults to
|
|
36
|
+
* "Loading…", overridable for host i18n).
|
|
34
37
|
* `skeleton` (default) draws N shimmer rows; `spinner` centers the `Spinner`
|
|
35
38
|
* atom for compact or unknown-height regions.
|
|
36
39
|
*/
|
|
@@ -39,6 +42,7 @@ function LoadingState({
|
|
|
39
42
|
rows = 5,
|
|
40
43
|
rowClassName,
|
|
41
44
|
label,
|
|
45
|
+
srLabel = 'Loading…',
|
|
42
46
|
className,
|
|
43
47
|
...props
|
|
44
48
|
}: LoadingStateProps) {
|
|
@@ -49,7 +53,7 @@ function LoadingState({
|
|
|
49
53
|
className={cn(loadingStateVariants({ variant }), className)}
|
|
50
54
|
{...props}
|
|
51
55
|
>
|
|
52
|
-
<span className="sr-only">
|
|
56
|
+
<span className="sr-only">{srLabel}</span>
|
|
53
57
|
{variant === 'spinner' ? (
|
|
54
58
|
<>
|
|
55
59
|
{/* aria-hidden overrides the atom's role="status": the <output> is
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
const CALENDAR_DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
2
|
+
|
|
3
|
+
/** Parse `YYYY-MM-DD` into a local calendar date without a UTC shift. */
|
|
4
|
+
function parseCalendarDate(value: string | null | undefined): Date | undefined {
|
|
5
|
+
if (!value) return undefined;
|
|
6
|
+
const match = CALENDAR_DATE_PATTERN.exec(value);
|
|
7
|
+
if (!match) return undefined;
|
|
8
|
+
const year = Number(match[1]);
|
|
9
|
+
const month = Number(match[2]);
|
|
10
|
+
const day = Number(match[3]);
|
|
11
|
+
const date = new Date(year, month - 1, day);
|
|
12
|
+
if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) {
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
return date;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Serialize a local calendar date as `YYYY-MM-DD`. */
|
|
19
|
+
function formatCalendarDate(date: Date): string {
|
|
20
|
+
const year = date.getFullYear();
|
|
21
|
+
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
22
|
+
const day = String(date.getDate()).padStart(2, '0');
|
|
23
|
+
return `${year}-${month}-${day}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function parseInstant(value: string | null | undefined): Date | undefined {
|
|
27
|
+
if (!value) return undefined;
|
|
28
|
+
const date = new Date(value);
|
|
29
|
+
return Number.isNaN(date.getTime()) ? undefined : date;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function formatDateLabel(date: Date, locale?: string): string {
|
|
33
|
+
return new Intl.DateTimeFormat(locale, { dateStyle: 'medium' }).format(date);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function formatDateRangeLabel(from: Date, to: Date | undefined, locale?: string): string {
|
|
37
|
+
if (!to || from.getTime() === to.getTime()) return formatDateLabel(from, locale);
|
|
38
|
+
const formatter = new Intl.DateTimeFormat(locale, { dateStyle: 'medium' });
|
|
39
|
+
return formatter.formatRange(from, to);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export {
|
|
43
|
+
formatCalendarDate,
|
|
44
|
+
formatDateLabel,
|
|
45
|
+
formatDateRangeLabel,
|
|
46
|
+
parseCalendarDate,
|
|
47
|
+
parseInstant,
|
|
48
|
+
};
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import {
|
|
2
|
+
transformerNotationDiff,
|
|
3
|
+
transformerNotationErrorLevel,
|
|
4
|
+
transformerNotationFocus,
|
|
5
|
+
transformerNotationHighlight,
|
|
6
|
+
transformerNotationWordHighlight,
|
|
7
|
+
} from '@shikijs/transformers';
|
|
8
|
+
import { createHighlighterCore } from 'shiki/core';
|
|
9
|
+
import { createJavaScriptRegexEngine } from 'shiki/engine/javascript';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The design system's Shiki setup: a lazy shared highlighter with the DS themes
|
|
13
|
+
* (github-light / github-dark-default), a fixed grammar set, and the `[!code ...]`
|
|
14
|
+
* notation transformers. Exported so consumers rendering highlighted HTML outside
|
|
15
|
+
* of `CodeBlock` (e.g. custom docs pipelines) reuse the exact same setup instead
|
|
16
|
+
* of duplicating it — and get the lazy per-language chunks instead of the full
|
|
17
|
+
* Shiki bundle.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const languageLoaders = {
|
|
21
|
+
bash: () => import('@shikijs/langs/bash').then((module) => module.default),
|
|
22
|
+
css: () => import('@shikijs/langs/css').then((module) => module.default),
|
|
23
|
+
dotenv: () => import('@shikijs/langs/dotenv').then((module) => module.default),
|
|
24
|
+
graphql: () => import('@shikijs/langs/graphql').then((module) => module.default),
|
|
25
|
+
html: () => import('@shikijs/langs/html').then((module) => module.default),
|
|
26
|
+
ini: () => import('@shikijs/langs/ini').then((module) => module.default),
|
|
27
|
+
javascript: () => import('@shikijs/langs/javascript').then((module) => module.default),
|
|
28
|
+
json: () => import('@shikijs/langs/json').then((module) => module.default),
|
|
29
|
+
jsonc: () => import('@shikijs/langs/jsonc').then((module) => module.default),
|
|
30
|
+
jsx: () => import('@shikijs/langs/jsx').then((module) => module.default),
|
|
31
|
+
markdown: () => import('@shikijs/langs/markdown').then((module) => module.default),
|
|
32
|
+
mdx: () => import('@shikijs/langs/mdx').then((module) => module.default),
|
|
33
|
+
python: () => import('@shikijs/langs/python').then((module) => module.default),
|
|
34
|
+
shellscript: () => import('@shikijs/langs/shellscript').then((module) => module.default),
|
|
35
|
+
sql: () => import('@shikijs/langs/sql').then((module) => module.default),
|
|
36
|
+
tsx: () => import('@shikijs/langs/tsx').then((module) => module.default),
|
|
37
|
+
typescript: () => import('@shikijs/langs/typescript').then((module) => module.default),
|
|
38
|
+
yaml: () => import('@shikijs/langs/yaml').then((module) => module.default),
|
|
39
|
+
} as const;
|
|
40
|
+
|
|
41
|
+
type SupportedLanguage = keyof typeof languageLoaders;
|
|
42
|
+
|
|
43
|
+
const themeLoaders = [
|
|
44
|
+
() => import('@shikijs/themes/github-light').then((module) => module.default),
|
|
45
|
+
() => import('@shikijs/themes/github-dark-default').then((module) => module.default),
|
|
46
|
+
] as const;
|
|
47
|
+
|
|
48
|
+
let highlighterPromise: ReturnType<typeof createHighlighterCore> | null = null;
|
|
49
|
+
|
|
50
|
+
function getHighlighter(): ReturnType<typeof createHighlighterCore> {
|
|
51
|
+
highlighterPromise ??= Promise.all([
|
|
52
|
+
Promise.all(themeLoaders.map((loadTheme) => loadTheme())),
|
|
53
|
+
Promise.all(Object.values(languageLoaders).map((loadLanguage) => loadLanguage())),
|
|
54
|
+
]).then(([themes, languages]) =>
|
|
55
|
+
createHighlighterCore({
|
|
56
|
+
themes,
|
|
57
|
+
langs: languages.flat(),
|
|
58
|
+
engine: createJavaScriptRegexEngine(),
|
|
59
|
+
}),
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
return highlighterPromise;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const highlightedCodeCache = new Map<string, Promise<string>>();
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Highlight code using Shiki with all transformers.
|
|
69
|
+
* Uses Shiki's native notation for highlighting:
|
|
70
|
+
* - // [!code highlight] - highlight a line (use language-appropriate comment)
|
|
71
|
+
* - // [!code ++] / // [!code --] - diff highlighting
|
|
72
|
+
* - // [!code focus] - focus mode (blur other lines)
|
|
73
|
+
* - // [!code word:myVar] - highlight specific word
|
|
74
|
+
* - // [!code error] / // [!code warning] - error levels
|
|
75
|
+
*/
|
|
76
|
+
async function highlightCode(code: string, language: SupportedLanguage): Promise<string> {
|
|
77
|
+
const cacheKey = `${language}:${code}`;
|
|
78
|
+
const cached = highlightedCodeCache.get(cacheKey);
|
|
79
|
+
if (cached) return cached;
|
|
80
|
+
|
|
81
|
+
const highlighted = getHighlighter().then((highlighter) =>
|
|
82
|
+
highlighter.codeToHtml(code, {
|
|
83
|
+
lang: language,
|
|
84
|
+
themes: {
|
|
85
|
+
light: 'github-light',
|
|
86
|
+
dark: 'github-dark-default',
|
|
87
|
+
},
|
|
88
|
+
transformers: [
|
|
89
|
+
transformerNotationDiff({ matchAlgorithm: 'v3' }),
|
|
90
|
+
transformerNotationHighlight({ matchAlgorithm: 'v3' }),
|
|
91
|
+
transformerNotationWordHighlight({ matchAlgorithm: 'v3' }),
|
|
92
|
+
transformerNotationFocus({ matchAlgorithm: 'v3' }),
|
|
93
|
+
transformerNotationErrorLevel({ matchAlgorithm: 'v3' }),
|
|
94
|
+
],
|
|
95
|
+
}),
|
|
96
|
+
);
|
|
97
|
+
highlightedCodeCache.set(cacheKey, highlighted);
|
|
98
|
+
return highlighted;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Language aliases that map to Shiki's bundled language names
|
|
103
|
+
*/
|
|
104
|
+
const LANGUAGE_ALIASES: Record<string, SupportedLanguage> = {
|
|
105
|
+
env: 'dotenv',
|
|
106
|
+
js: 'javascript',
|
|
107
|
+
ts: 'typescript',
|
|
108
|
+
sh: 'bash',
|
|
109
|
+
shell: 'bash',
|
|
110
|
+
yml: 'yaml',
|
|
111
|
+
py: 'python',
|
|
112
|
+
md: 'markdown',
|
|
113
|
+
plaintext: 'ini',
|
|
114
|
+
text: 'ini',
|
|
115
|
+
chroma: 'ini',
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Normalize language identifier for Shiki/BundledLanguage.
|
|
120
|
+
* Falls back to 'ini' for unsupported languages (minimal highlighting).
|
|
121
|
+
*/
|
|
122
|
+
function normalizeLanguage(lang?: string): SupportedLanguage {
|
|
123
|
+
const normalized = lang?.toLowerCase() || 'ini';
|
|
124
|
+
|
|
125
|
+
// Check for aliases first
|
|
126
|
+
const alias = LANGUAGE_ALIASES[normalized];
|
|
127
|
+
if (alias) {
|
|
128
|
+
return alias;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Check if the language is one of the explicitly bundled grammars.
|
|
132
|
+
if (normalized in languageLoaders) {
|
|
133
|
+
return normalized as SupportedLanguage;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Fallback to 'ini' for unsupported languages (has minimal highlighting)
|
|
137
|
+
return 'ini';
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export { getHighlighter, highlightCode, normalizeLanguage };
|
|
141
|
+
export type { SupportedLanguage };
|