@marimo-team/islands 0.25.1-dev44 → 0.25.1-dev45

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 (26) hide show
  1. package/dist/{ConnectedDataExplorerComponent-CwMXfDQR.js → ConnectedDataExplorerComponent-C2d9ysUI.js} +5 -6
  2. package/dist/{ErrorBoundary-VUuyxCWc.js → ErrorBoundary-pBYdeBnS.js} +1 -1
  3. package/dist/{chat-ui-DLSF8a04.js → chat-ui-fVyVOhYN.js} +4 -4
  4. package/dist/{code-visibility-A4d1wosh.js → code-visibility-BtQ8kX30.js} +1 -1
  5. package/dist/{common-p1O4ctAw.js → common-CT8H5t1V.js} +6722 -6426
  6. package/dist/{data-grid-overlay-editor-CasvLZPZ.js → data-grid-overlay-editor-BLIYSMps.js} +1 -1
  7. package/dist/{dropdown-menu-CZgjYSAh.js → dropdown-menu-CZy06LAq.js} +12 -12
  8. package/dist/{feedback-button-DrJQ0USU.js → feedback-button-Oixw7nP7.js} +2 -2
  9. package/dist/{glide-data-editor-C55qOCCK.js → glide-data-editor-u2Z6Gofm.js} +5 -5
  10. package/dist/{globals-CUD2Xo__.js → globals-JfO03feH.js} +1 -1
  11. package/dist/{html-to-image-CGVgroef.js → html-to-image-CmkwfNkf.js} +1 -1
  12. package/dist/{useNumberFormatter-BfDpa21j.js → label-BslhXqL6.js} +108 -81
  13. package/dist/main.js +60 -54
  14. package/dist/{number-overlay-editor-CZb24BVz.js → number-overlay-editor-Da3rqdsN.js} +1 -1
  15. package/dist/{process-output-CGfg-JmZ.js → process-output-CYN5E-iO.js} +1 -1
  16. package/dist/{reveal-component-_Fc3Tdml.js → reveal-component-DvSplL6e.js} +3 -3
  17. package/dist/{spec-CAYwFeV6.js → spec-DQBRQSba.js} +123 -102
  18. package/dist/style.css +1 -1
  19. package/dist/{useDateFormatter-BRTPsUK7.js → useDateFormatter-BXQGu86d.js} +28 -49
  20. package/package.json +1 -1
  21. package/src/components/data-table/TableTopBar.tsx +2 -2
  22. package/src/components/data-table/__tests__/export-actions.test.tsx +662 -0
  23. package/src/components/data-table/data-table.tsx +1 -1
  24. package/src/components/data-table/export-actions.tsx +642 -176
  25. package/src/components/data-table/schemas.ts +25 -3
  26. package/dist/label-BJ49YTvk.js +0 -30
@@ -2,14 +2,19 @@
2
2
 
3
3
  import { useAtomValue } from "jotai";
4
4
  import {
5
- BracesIcon,
6
- BrickWallIcon,
5
+ AlertCircleIcon,
6
+ ChevronRightIcon,
7
+ ClipboardCopyIcon,
7
8
  DownloadIcon,
8
- FileTextIcon,
9
- TableIcon,
9
+ InfoIcon,
10
10
  } from "lucide-react";
11
11
  import React from "react";
12
12
  import { downloadSizeLimitAtom } from "./download-policy/atoms";
13
+ import type {
14
+ DownloadAsArgs,
15
+ DownloadAsOptions,
16
+ DownloadFormat,
17
+ } from "./schemas";
13
18
  import { logNever } from "@/utils/assertNever";
14
19
  import { cn } from "@/utils/cn";
15
20
  import { copyToClipboard } from "@/utils/copy";
@@ -21,66 +26,81 @@ import {
21
26
  jsonToMarkdown,
22
27
  } from "@/utils/json/json-parser";
23
28
  import { MissingPackagePrompt } from "../datasources/missing-package-prompt";
29
+ import { Alert, AlertDescription, AlertTitle } from "../ui/alert";
24
30
  import { Button } from "../ui/button";
25
31
  import {
26
- DropdownMenu,
27
- DropdownMenuContent,
28
- DropdownMenuItem,
29
- DropdownMenuLabel,
30
- DropdownMenuSeparator,
31
- DropdownMenuTrigger,
32
- } from "../ui/dropdown-menu";
32
+ Dialog,
33
+ DialogContent,
34
+ DialogDescription,
35
+ DialogHeader,
36
+ DialogTitle,
37
+ DialogTrigger,
38
+ } from "../ui/dialog";
39
+ import { Label } from "../ui/label";
40
+ import { NativeSelect } from "../ui/native-select";
41
+ import { Switch } from "../ui/switch";
33
42
  import { Tooltip } from "../ui/tooltip";
34
43
  import { toast } from "../ui/use-toast";
35
44
 
36
- const FILE_TYPES = {
37
- CSV: {
45
+ const EXPORT_OPTIONS = [
46
+ {
38
47
  label: "CSV",
39
48
  format: "csv",
40
49
  description: "Comma-separated values",
41
- icon: TableIcon,
50
+ canDownload: true,
51
+ canCopy: true,
42
52
  },
43
- JSON: {
53
+ {
54
+ label: "TSV",
55
+ format: "tsv",
56
+ description: "Best for Excel and Google Sheets",
57
+ canDownload: true,
58
+ canCopy: true,
59
+ },
60
+ {
44
61
  label: "JSON",
45
62
  format: "json",
46
63
  description: "Raw JSON data",
47
- icon: BracesIcon,
64
+ canDownload: true,
65
+ canCopy: true,
48
66
  },
49
- PARQUET: {
67
+ {
50
68
  label: "Parquet",
51
69
  format: "parquet",
52
70
  description: "Columnar binary format",
53
- icon: BrickWallIcon,
71
+ canDownload: true,
72
+ canCopy: false,
54
73
  },
55
- TSV: {
56
- label: "TSV",
57
- format: "tsv",
58
- description: "Best for Excel and Google Sheets",
59
- icon: TableIcon,
60
- },
61
- MARKDOWN: {
74
+ {
62
75
  label: "Markdown",
63
76
  format: "markdown",
64
77
  description: "Preserves hyperlinks and formatting",
65
- icon: FileTextIcon,
78
+ canDownload: false,
79
+ canCopy: true,
66
80
  },
67
- } as const;
68
-
69
- const downloadOptions = [
70
- FILE_TYPES.CSV,
71
- FILE_TYPES.TSV,
72
- FILE_TYPES.JSON,
73
- FILE_TYPES.PARQUET,
74
- ];
75
- const copyOptions = [
76
- FILE_TYPES.TSV,
77
- FILE_TYPES.JSON,
78
- FILE_TYPES.CSV,
79
- FILE_TYPES.MARKDOWN,
80
- ];
81
-
82
- type DownloadFormat = (typeof downloadOptions)[number]["format"];
83
- type CopyFormat = (typeof copyOptions)[number]["format"];
81
+ ] as const;
82
+
83
+ type ExportFormat = (typeof EXPORT_OPTIONS)[number]["format"];
84
+ type CopyFormat = Exclude<ExportFormat, "parquet">;
85
+ type ExportAction =
86
+ | { destination: "download"; format: DownloadFormat }
87
+ | { destination: "copy"; format: CopyFormat };
88
+
89
+ type ExportFailure =
90
+ | {
91
+ kind: "error";
92
+ title: string;
93
+ description: string;
94
+ action: ExportAction;
95
+ }
96
+ | {
97
+ kind: "missing-packages";
98
+ title: string;
99
+ description?: string | null;
100
+ packages: string[];
101
+ featureName: string;
102
+ action: ExportAction;
103
+ };
84
104
 
85
105
  // Each clipboard-copy format fetches from a backend download format, then
86
106
  // transforms the payload client-side as needed.
@@ -91,13 +111,117 @@ const COPY_SOURCE_FORMAT: Record<CopyFormat, DownloadFormat> = {
91
111
  markdown: "json",
92
112
  };
93
113
 
114
+ const SEPARATORS = [
115
+ { value: ",", label: "Comma" },
116
+ { value: ";", label: "Semicolon" },
117
+ { value: "|", label: "Pipe" },
118
+ ] as const;
119
+
120
+ const ENCODINGS = [
121
+ { value: "utf-8", label: "UTF-8" },
122
+ { value: "utf-8-sig", label: "UTF-8 with BOM" },
123
+ { value: "latin-1", label: "Latin-1" },
124
+ ] as const;
125
+
126
+ type Separator = (typeof SEPARATORS)[number]["value"];
127
+ type Encoding = (typeof ENCODINGS)[number]["value"];
128
+
129
+ interface ExportSettings {
130
+ csv: { separator: Separator; encoding: Encoding };
131
+ tsv: { encoding: Encoding };
132
+ json: { ensureAscii: boolean };
133
+ }
134
+
135
+ const DEFAULT_SETTINGS: ExportSettings = {
136
+ csv: { separator: ",", encoding: "utf-8" },
137
+ tsv: { encoding: "utf-8" },
138
+ json: { ensureAscii: true },
139
+ };
140
+
141
+ type ConfigurableFormat = keyof ExportSettings;
142
+
143
+ const isConfigurable = (format: ExportFormat): format is ConfigurableFormat =>
144
+ format in DEFAULT_SETTINGS;
145
+
146
+ /**
147
+ * Build the request options for one action. Only settings that differ from
148
+ * the defaults are sent, so the backend keeps its own defaults otherwise.
149
+ *
150
+ * Clipboard copies never send an encoding. The browser decodes the fetched
151
+ * payload as UTF-8, so any other encoding would corrupt the copied text.
152
+ */
153
+ function requestOptions(
154
+ settings: ExportSettings,
155
+ format: DownloadFormat,
156
+ destination: ExportAction["destination"],
157
+ ): DownloadAsOptions | undefined {
158
+ const options: DownloadAsOptions = {};
159
+ switch (format) {
160
+ case "csv":
161
+ if (settings.csv.separator !== DEFAULT_SETTINGS.csv.separator) {
162
+ options.separator = settings.csv.separator;
163
+ }
164
+ if (
165
+ destination === "download" &&
166
+ settings.csv.encoding !== DEFAULT_SETTINGS.csv.encoding
167
+ ) {
168
+ options.encoding = settings.csv.encoding;
169
+ }
170
+ break;
171
+ case "tsv":
172
+ if (
173
+ destination === "download" &&
174
+ settings.tsv.encoding !== DEFAULT_SETTINGS.tsv.encoding
175
+ ) {
176
+ options.encoding = settings.tsv.encoding;
177
+ }
178
+ break;
179
+ case "json":
180
+ if (settings.json.ensureAscii !== DEFAULT_SETTINGS.json.ensureAscii) {
181
+ options.ensure_ascii = settings.json.ensureAscii;
182
+ }
183
+ break;
184
+ case "parquet":
185
+ break;
186
+ default:
187
+ logNever(format);
188
+ }
189
+ return Object.keys(options).length > 0 ? options : undefined;
190
+ }
191
+
192
+ const labelForValue = (
193
+ items: ReadonlyArray<{ value: string; label: string }>,
194
+ value: string,
195
+ ): string => items.find((item) => item.value === value)?.label ?? value;
196
+
197
+ /** Short summary of the current settings, shown next to the format label. */
198
+ function settingsSummary(
199
+ settings: ExportSettings,
200
+ format: ExportFormat,
201
+ ): string | null {
202
+ switch (format) {
203
+ case "csv":
204
+ return [
205
+ labelForValue(SEPARATORS, settings.csv.separator),
206
+ labelForValue(ENCODINGS, settings.csv.encoding),
207
+ ].join(" · ");
208
+ case "tsv":
209
+ return labelForValue(ENCODINGS, settings.tsv.encoding);
210
+ case "json":
211
+ return settings.json.ensureAscii
212
+ ? "Escapes non-ASCII"
213
+ : "Keeps non-ASCII";
214
+ case "parquet":
215
+ case "markdown":
216
+ return null;
217
+ default:
218
+ logNever(format);
219
+ return null;
220
+ }
221
+ }
222
+
94
223
  export interface ExportActionProps {
95
- downloadAs: (req: { format: DownloadFormat }) => Promise<{
96
- url: string;
97
- filename: string;
98
- error?: string | null;
99
- missing_packages?: string[] | null;
100
- }>;
224
+ downloadAs: DownloadAsArgs;
101
225
  // JSON-serialized size of the currently-rendered data. Used together with
102
226
  // downloadSizeLimitAtom to disable the Export button when a host (e.g.,
103
227
  // marimo-lsp inside VS Code) declares a download size cap. Null/undefined
@@ -106,13 +230,27 @@ export interface ExportActionProps {
106
230
  sizeBytesIsLoading?: boolean;
107
231
  }
108
232
 
109
- const labelForDownloadFormat = (format: DownloadFormat): string =>
110
- downloadOptions.find((opt) => opt.format === format)?.label ?? format;
111
- const labelForCopyFormat = (format: CopyFormat): string =>
112
- copyOptions.find((opt) => opt.format === format)?.label ?? format;
233
+ const labelForFormat = (format: ExportFormat): string =>
234
+ EXPORT_OPTIONS.find((option) => option.format === format)?.label ?? format;
235
+
236
+ const failureTitleForAction = (action: ExportAction): string =>
237
+ action.destination === "download"
238
+ ? "Failed to download"
239
+ : "Failed to copy to clipboard";
240
+
241
+ const failureDescription = (error: unknown): string =>
242
+ typeof error === "string" ? error : prettyError(error);
113
243
 
114
- export const ExportMenu: React.FC<ExportActionProps> = (props) => {
115
- const [downloadMenuOpen, setDownloadMenuOpen] = React.useState(false);
244
+ export const ExportActions: React.FC<ExportActionProps> = (props) => {
245
+ const [exportDialogOpen, setExportDialogOpen] = React.useState(false);
246
+ const [failure, setFailure] = React.useState<ExportFailure | null>(null);
247
+ const [settings, setSettings] =
248
+ React.useState<ExportSettings>(DEFAULT_SETTINGS);
249
+ const [expandedFormat, setExpandedFormat] =
250
+ React.useState<ConfigurableFormat | null>(null);
251
+ const triggerRef = React.useRef<HTMLButtonElement>(null);
252
+ const dialogRef = React.useRef<HTMLDivElement>(null);
253
+ const latestActionId = React.useRef(0);
116
254
  const policy = useAtomValue(downloadSizeLimitAtom);
117
255
  const overLimit = !!(
118
256
  policy &&
@@ -128,13 +266,14 @@ export const ExportMenu: React.FC<ExportActionProps> = (props) => {
128
266
 
129
267
  const button = (
130
268
  <Button
269
+ ref={triggerRef}
131
270
  data-testid="export-button"
132
271
  size="xs"
133
272
  variant="text"
134
273
  disabled={disabled}
135
274
  className={cn(
136
275
  "print:hidden text-xs gap-1",
137
- downloadMenuOpen ? "text-primary" : "text-muted-foreground",
276
+ exportDialogOpen ? "text-primary" : "text-muted-foreground",
138
277
  )}
139
278
  >
140
279
  <DownloadIcon className="w-3.5 h-3.5" />
@@ -142,38 +281,60 @@ export const ExportMenu: React.FC<ExportActionProps> = (props) => {
142
281
  </Button>
143
282
  );
144
283
 
284
+ const handleDialogOpenChange = (open: boolean) => {
285
+ setExportDialogOpen(open);
286
+ if (!open) {
287
+ latestActionId.current += 1;
288
+ setFailure(null);
289
+ setExpandedFormat(null);
290
+ setSettings(DEFAULT_SETTINGS);
291
+ }
292
+ };
293
+
294
+ const beginAction = () => {
295
+ const actionId = latestActionId.current + 1;
296
+ latestActionId.current = actionId;
297
+ setFailure(null);
298
+ return actionId;
299
+ };
300
+
301
+ const setActionFailure = (actionId: number, nextFailure: ExportFailure) => {
302
+ if (actionId === latestActionId.current) {
303
+ setFailure(nextFailure);
304
+ }
305
+ };
306
+
145
307
  const resolveDownloadUrl = async (
146
308
  format: DownloadFormat,
147
- onRetry: () => void,
309
+ action: ExportAction,
310
+ actionId: number,
148
311
  ): Promise<{
149
312
  url: string;
150
313
  filename: string;
151
314
  } | null> => {
152
- let response: Awaited<ReturnType<typeof props.downloadAs>>;
153
- try {
154
- response = await props.downloadAs({ format });
155
- } catch (error) {
156
- toast({
157
- title: "Failed to download",
158
- description:
159
- error != null && typeof error === "object" && "message" in error
160
- ? String(error.message)
161
- : String(error),
315
+ const options = requestOptions(settings, format, action.destination);
316
+ const response = await props.downloadAs(
317
+ options ? { format, options } : { format },
318
+ );
319
+
320
+ if (response.missing_packages && response.missing_packages.length > 0) {
321
+ setActionFailure(actionId, {
322
+ kind: "missing-packages",
323
+ title: "Export failed",
324
+ packages: response.missing_packages,
325
+ featureName: `${labelForFormat(action.format)} export`,
326
+ description: response.error,
327
+ action,
162
328
  });
163
329
  return null;
164
330
  }
165
331
 
166
- if (response.missing_packages && response.missing_packages.length > 0) {
167
- toast({
168
- title: "Export failed",
169
- description: (
170
- <MissingPackagePrompt
171
- packages={response.missing_packages}
172
- featureName={`${labelForDownloadFormat(format)} export`}
173
- description={response.error}
174
- onInstall={onRetry}
175
- />
176
- ),
332
+ if (response.error) {
333
+ setActionFailure(actionId, {
334
+ kind: "error",
335
+ title: failureTitleForAction(action),
336
+ description: response.error,
337
+ action,
177
338
  });
178
339
  return null;
179
340
  }
@@ -185,49 +346,60 @@ export const ExportMenu: React.FC<ExportActionProps> = (props) => {
185
346
  };
186
347
 
187
348
  const handleDownload = async (format: DownloadFormat) => {
188
- const label = labelForDownloadFormat(format);
189
- const ok = await withLoadingToast(
190
- `Preparing ${label} export...`,
191
- async () => {
192
- const result = await resolveDownloadUrl(format, () => {
193
- void handleDownload(format);
194
- });
195
- if (!result) {
196
- return false;
197
- }
198
- const rawName = (result.filename ?? "").trim();
199
- const baseName = Filenames.withoutExtension(rawName) || "download";
200
- const downloadName = `${baseName}.${format}`;
201
- // Append ?download=1 so the server returns Content-Disposition: attachment.
202
- // This forces a save even when <a download> is ignored — e.g., inside
203
- // sandboxed iframes that lack `allow-downloads`. Skip for data: URLs
204
- // (used in pyodide/wasm) since query params would corrupt the payload.
205
- let downloadUrl = result.url;
206
- if (!downloadUrl.startsWith("data:")) {
207
- const separator = downloadUrl.includes("?") ? "&" : "?";
208
- const params = new URLSearchParams({
209
- download: "1",
210
- filename: downloadName,
211
- });
212
- downloadUrl = `${downloadUrl}${separator}${params.toString()}`;
213
- }
214
- downloadByURL(downloadUrl, downloadName);
215
- return true;
216
- },
217
- );
218
- if (ok) {
219
- toast({ title: `${label} download started` });
349
+ const action: ExportAction = { destination: "download", format };
350
+ const actionId = beginAction();
351
+ const label = labelForFormat(format);
352
+ try {
353
+ const ok = await withLoadingToast(
354
+ `Preparing ${label} export...`,
355
+ async () => {
356
+ const result = await resolveDownloadUrl(format, action, actionId);
357
+ if (!result) {
358
+ return false;
359
+ }
360
+ const rawName = (result.filename ?? "").trim();
361
+ const baseName = Filenames.withoutExtension(rawName) || "download";
362
+ const downloadName = `${baseName}.${format}`;
363
+ // Append ?download=1 so the server returns Content-Disposition: attachment.
364
+ // This forces a save even when <a download> is ignored — e.g., inside
365
+ // sandboxed iframes that lack `allow-downloads`. Skip for data: URLs
366
+ // (used in pyodide/wasm) since query params would corrupt the payload.
367
+ let downloadUrl = result.url;
368
+ if (!downloadUrl.startsWith("data:")) {
369
+ const separator = downloadUrl.includes("?") ? "&" : "?";
370
+ const params = new URLSearchParams({
371
+ download: "1",
372
+ filename: downloadName,
373
+ });
374
+ downloadUrl = `${downloadUrl}${separator}${params.toString()}`;
375
+ }
376
+ downloadByURL(downloadUrl, downloadName);
377
+ return true;
378
+ },
379
+ );
380
+ if (ok) {
381
+ toast({ title: `${label} download started` });
382
+ }
383
+ } catch (error) {
384
+ setActionFailure(actionId, {
385
+ kind: "error",
386
+ title: failureTitleForAction(action),
387
+ description: failureDescription(error),
388
+ action,
389
+ });
220
390
  }
221
391
  };
222
392
 
223
- const handleClipboardCopy = async (format: CopyFormat) => {
393
+ const handleClipboardCopy = async (
394
+ format: CopyFormat,
395
+ action: ExportAction,
396
+ actionId: number,
397
+ ) => {
224
398
  await withLoadingToast(
225
- `Preparing ${labelForCopyFormat(format)} for clipboard...`,
399
+ `Preparing ${labelForFormat(format)} for clipboard...`,
226
400
  async () => {
227
401
  const sourceFormat = COPY_SOURCE_FORMAT[format];
228
- const result = await resolveDownloadUrl(sourceFormat, () => {
229
- void handleClipboardCopy(format);
230
- });
402
+ const result = await resolveDownloadUrl(sourceFormat, action, actionId);
231
403
  if (!result) {
232
404
  return;
233
405
  }
@@ -257,79 +429,373 @@ export const ExportMenu: React.FC<ExportActionProps> = (props) => {
257
429
  toast({
258
430
  title: "Copied to clipboard",
259
431
  });
432
+ handleDialogOpenChange(false);
260
433
  },
261
434
  );
262
435
  };
263
436
 
437
+ const handleCopyAction = async (format: CopyFormat) => {
438
+ const action: ExportAction = { destination: "copy", format };
439
+ const actionId = beginAction();
440
+ try {
441
+ await handleClipboardCopy(format, action, actionId);
442
+ } catch (error) {
443
+ setActionFailure(actionId, {
444
+ kind: "error",
445
+ title: failureTitleForAction(action),
446
+ description: failureDescription(error),
447
+ action,
448
+ });
449
+ }
450
+ };
451
+
452
+ const retryAction = (action: ExportAction) => {
453
+ if (action.destination === "download") {
454
+ void handleDownload(action.format);
455
+ } else {
456
+ void handleCopyAction(action.format);
457
+ }
458
+ };
459
+
460
+ const toggleExpanded = (format: ExportFormat) => {
461
+ if (!isConfigurable(format)) {
462
+ return;
463
+ }
464
+ setExpandedFormat((current) => (current === format ? null : format));
465
+ };
466
+
264
467
  return (
265
- <DropdownMenu
266
- modal={false}
267
- open={downloadMenuOpen}
268
- onOpenChange={setDownloadMenuOpen}
269
- >
270
- <Tooltip
271
- content={tooltipContent}
272
- open={downloadMenuOpen ? false : undefined}
273
- >
274
- <DropdownMenuTrigger asChild={true} disabled={disabled}>
275
- <span tabIndex={disabled ? 0 : -1} className="inline-flex">
468
+ <Dialog open={exportDialogOpen} onOpenChange={handleDialogOpenChange}>
469
+ <Tooltip content={tooltipContent}>
470
+ {disabled ? (
471
+ // Keep the host-limit reason reachable when the nested button is disabled.
472
+ // oxlint-disable-next-line jsx-a11y/no-noninteractive-tabindex
473
+ <span tabIndex={0} className="inline-flex">
276
474
  {button}
277
475
  </span>
278
- </DropdownMenuTrigger>
476
+ ) : (
477
+ <DialogTrigger asChild={true}>{button}</DialogTrigger>
478
+ )}
279
479
  </Tooltip>
280
- <DropdownMenuContent side="bottom" className="print:hidden">
281
- <DropdownMenuLabel className="text-xs text-muted-foreground">
282
- Download
283
- </DropdownMenuLabel>
284
- {downloadOptions.map((option) => (
285
- <DropdownMenuItem
286
- key={option.label}
287
- onSelect={() => {
288
- void handleDownload(option.format);
289
- }}
290
- >
291
- <option.icon className="mo-dropdown-icon" />
292
- <div className="flex flex-col">
293
- <span>{option.label}</span>
294
- <span className="text-xs text-muted-foreground">
295
- {option.description}
296
- </span>
297
- </div>
298
- </DropdownMenuItem>
299
- ))}
300
- <DropdownMenuSeparator />
301
- <DropdownMenuLabel className="text-xs text-muted-foreground">
302
- Copy to clipboard
303
- </DropdownMenuLabel>
304
- {copyOptions.map((option) => (
305
- <DropdownMenuItem
306
- key={option.label}
307
- onSelect={async () => {
308
- try {
309
- await handleClipboardCopy(option.format);
310
- } catch (error) {
311
- toast({
312
- title: "Failed to copy to clipboard",
313
- description: prettyError(error),
314
- variant: "danger",
315
- });
316
- }
317
- }}
318
- >
319
- <option.icon className="mo-dropdown-icon" />
320
- <div className="flex flex-col">
321
- <span>{option.label}</span>
322
- <span className="text-xs text-muted-foreground">
323
- {option.description}
324
- </span>
325
- </div>
326
- </DropdownMenuItem>
480
+ <DialogContent
481
+ ref={dialogRef}
482
+ className="print:hidden gap-4 sm:max-w-[540px]"
483
+ onOpenAutoFocus={(event) => {
484
+ event.preventDefault();
485
+ dialogRef.current?.focus();
486
+ }}
487
+ onCloseAutoFocus={(event) => {
488
+ event.preventDefault();
489
+ triggerRef.current?.focus();
490
+ }}
491
+ onKeyDown={(event) => {
492
+ if (event.key === "Escape") {
493
+ handleDialogOpenChange(false);
494
+ }
495
+ }}
496
+ >
497
+ <DialogHeader>
498
+ <DialogTitle>Export table</DialogTitle>
499
+ <DialogDescription>
500
+ Download a file or copy to the clipboard.
501
+ </DialogDescription>
502
+ </DialogHeader>
503
+ <ul
504
+ aria-label="Export formats"
505
+ className="list-none overflow-hidden rounded-md border divide-y"
506
+ >
507
+ {EXPORT_OPTIONS.map((option) => {
508
+ const configurableFormat = isConfigurable(option.format)
509
+ ? option.format
510
+ : null;
511
+ const expanded =
512
+ configurableFormat !== null &&
513
+ expandedFormat === configurableFormat;
514
+ const panelId = `export-options-${option.format}`;
515
+ const rowFailure =
516
+ failure?.action.format === option.format ? failure : null;
517
+ const summary = settingsSummary(settings, option.format);
518
+ return (
519
+ <li
520
+ key={option.format}
521
+ data-testid={`export-row-${option.format}`}
522
+ className={cn(expanded && "bg-muted/40")}
523
+ >
524
+ <div className="grid min-h-[60px] grid-cols-[28px_1fr_auto] items-center gap-x-1 px-2.5 py-2 hover:bg-accent/50">
525
+ {configurableFormat ? (
526
+ <Button
527
+ type="button"
528
+ variant="ghost"
529
+ size="icon"
530
+ aria-label={`${option.label} options`}
531
+ aria-expanded={expanded}
532
+ aria-controls={panelId}
533
+ onClick={() => toggleExpanded(option.format)}
534
+ >
535
+ <ChevronRightIcon
536
+ className={cn(
537
+ "h-4 w-4 text-muted-foreground transition-transform",
538
+ expanded && "rotate-90",
539
+ )}
540
+ />
541
+ </Button>
542
+ ) : (
543
+ <span aria-hidden={true} className="h-6 w-6" />
544
+ )}
545
+ <div className="grid gap-1 pl-1">
546
+ <span className="flex items-baseline gap-x-2">
547
+ <span className="text-sm font-medium leading-tight">
548
+ {option.label}
549
+ </span>
550
+ {summary && (
551
+ <span
552
+ data-testid={`export-summary-${option.format}`}
553
+ className="text-xs text-muted-foreground"
554
+ >
555
+ {summary}
556
+ </span>
557
+ )}
558
+ </span>
559
+ <span className="text-xs text-muted-foreground leading-tight">
560
+ {option.description}
561
+ </span>
562
+ </div>
563
+ <div className="flex items-center gap-0.5">
564
+ <Tooltip
565
+ content={
566
+ option.canDownload
567
+ ? `Download ${option.label}`
568
+ : `${option.label} is available for copy only.`
569
+ }
570
+ >
571
+ <span className="inline-flex">
572
+ <Button
573
+ type="button"
574
+ variant="ghost"
575
+ size="icon"
576
+ aria-label={`Download ${option.label}`}
577
+ disabled={!option.canDownload}
578
+ onClick={
579
+ option.canDownload
580
+ ? () => {
581
+ void handleDownload(option.format);
582
+ }
583
+ : undefined
584
+ }
585
+ >
586
+ <DownloadIcon className="h-4 w-4" />
587
+ </Button>
588
+ </span>
589
+ </Tooltip>
590
+ <Tooltip
591
+ content={
592
+ option.canCopy
593
+ ? `Copy ${option.label}`
594
+ : `${option.label} is available for download only.`
595
+ }
596
+ >
597
+ <span className="inline-flex">
598
+ <Button
599
+ type="button"
600
+ variant="ghost"
601
+ size="icon"
602
+ aria-label={`Copy ${option.label}`}
603
+ disabled={!option.canCopy}
604
+ onClick={
605
+ option.canCopy
606
+ ? () => {
607
+ void handleCopyAction(option.format);
608
+ }
609
+ : undefined
610
+ }
611
+ >
612
+ <ClipboardCopyIcon className="h-4 w-4" />
613
+ </Button>
614
+ </span>
615
+ </Tooltip>
616
+ </div>
617
+ </div>
618
+ {expanded && configurableFormat && (
619
+ <fieldset
620
+ id={panelId}
621
+ className="grid grid-cols-2 gap-3 border-t bg-muted/50 px-3.5 py-3.5 pl-[52px]"
622
+ >
623
+ <legend className="sr-only">{option.label} options</legend>
624
+ <ExportSettingsFields
625
+ format={configurableFormat}
626
+ settings={settings}
627
+ onChange={setSettings}
628
+ />
629
+ </fieldset>
630
+ )}
631
+ {rowFailure && (
632
+ <div className="border-t px-3 py-2 pl-[42px]">
633
+ <Alert
634
+ variant="destructive"
635
+ className="p-3 has-[svg]:pl-9 [&>svg]:left-3 [&>svg]:top-3"
636
+ >
637
+ <AlertCircleIcon className="h-4 w-4" />
638
+ <div>
639
+ <AlertTitle className="text-sm">
640
+ {rowFailure.title}
641
+ </AlertTitle>
642
+ <AlertDescription className="text-xs">
643
+ {rowFailure.kind === "missing-packages" ? (
644
+ <MissingPackagePrompt
645
+ packages={rowFailure.packages}
646
+ featureName={rowFailure.featureName}
647
+ description={rowFailure.description}
648
+ onInstall={() => retryAction(rowFailure.action)}
649
+ className="items-start"
650
+ />
651
+ ) : (
652
+ rowFailure.description
653
+ )}
654
+ </AlertDescription>
655
+ </div>
656
+ </Alert>
657
+ </div>
658
+ )}
659
+ </li>
660
+ );
661
+ })}
662
+ </ul>
663
+ </DialogContent>
664
+ </Dialog>
665
+ );
666
+ };
667
+
668
+ interface ExportSettingsFieldsProps {
669
+ format: ConfigurableFormat;
670
+ settings: ExportSettings;
671
+ onChange: React.Dispatch<React.SetStateAction<ExportSettings>>;
672
+ }
673
+
674
+ const ExportSettingsFields: React.FC<ExportSettingsFieldsProps> = ({
675
+ format,
676
+ settings,
677
+ onChange,
678
+ }) => {
679
+ const id = React.useId();
680
+
681
+ const encodingField = (encoding: Encoding, key: "csv" | "tsv") => (
682
+ <SettingField
683
+ id={`${id}-encoding`}
684
+ label="Encoding"
685
+ help="Text encoding of the saved file. Pick UTF-8 with BOM when Excel shows garbled accents. Copies always use UTF-8."
686
+ >
687
+ <NativeSelect
688
+ id={`${id}-encoding`}
689
+ className="mb-0 w-full"
690
+ value={encoding}
691
+ onChange={(event) => {
692
+ const next = event.target.value as Encoding;
693
+ onChange((current) => ({
694
+ ...current,
695
+ [key]: { ...current[key], encoding: next },
696
+ }));
697
+ }}
698
+ >
699
+ {ENCODINGS.map((item) => (
700
+ <option key={item.value} value={item.value}>
701
+ {item.label}
702
+ </option>
327
703
  ))}
328
- </DropdownMenuContent>
329
- </DropdownMenu>
704
+ </NativeSelect>
705
+ </SettingField>
330
706
  );
707
+
708
+ switch (format) {
709
+ case "csv":
710
+ return (
711
+ <>
712
+ <SettingField
713
+ id={`${id}-separator`}
714
+ label="Delimiter"
715
+ help="Character between fields. Semicolon suits spreadsheets in locales that use a comma as the decimal separator."
716
+ >
717
+ <NativeSelect
718
+ id={`${id}-separator`}
719
+ className="mb-0 w-full"
720
+ value={settings.csv.separator}
721
+ onChange={(event) => {
722
+ const next = event.target.value as Separator;
723
+ onChange((current) => ({
724
+ ...current,
725
+ csv: { ...current.csv, separator: next },
726
+ }));
727
+ }}
728
+ >
729
+ {SEPARATORS.map((item) => (
730
+ <option key={item.value} value={item.value}>
731
+ {item.label}
732
+ </option>
733
+ ))}
734
+ </NativeSelect>
735
+ </SettingField>
736
+ {encodingField(settings.csv.encoding, "csv")}
737
+ </>
738
+ );
739
+ case "tsv":
740
+ return encodingField(settings.tsv.encoding, "tsv");
741
+ case "json":
742
+ return (
743
+ <SettingField
744
+ id={`${id}-ensure-ascii`}
745
+ label="Escape non-ASCII"
746
+ help="Write characters outside ASCII as \\u escapes. Turn off to keep accents and symbols readable in the file."
747
+ >
748
+ <Switch
749
+ id={`${id}-ensure-ascii`}
750
+ size="xs"
751
+ checked={settings.json.ensureAscii}
752
+ onCheckedChange={(checked) => {
753
+ onChange((current) => ({
754
+ ...current,
755
+ json: { ensureAscii: checked },
756
+ }));
757
+ }}
758
+ />
759
+ </SettingField>
760
+ );
761
+ default:
762
+ logNever(format);
763
+ return null;
764
+ }
331
765
  };
332
766
 
767
+ interface SettingFieldProps {
768
+ id: string;
769
+ label: string;
770
+ help: string;
771
+ children: React.ReactNode;
772
+ }
773
+
774
+ const SettingField: React.FC<SettingFieldProps> = ({
775
+ id,
776
+ label,
777
+ help,
778
+ children,
779
+ }) => (
780
+ <div className="grid gap-1.5">
781
+ <span className="flex items-center gap-1">
782
+ <Label htmlFor={id} className="text-xs font-medium">
783
+ {label}
784
+ </Label>
785
+ <Tooltip content={help}>
786
+ <button
787
+ type="button"
788
+ className="inline-flex text-muted-foreground hover:text-foreground"
789
+ aria-label={`${label} help`}
790
+ >
791
+ <InfoIcon className="h-3 w-3" />
792
+ </button>
793
+ </Tooltip>
794
+ </span>
795
+ <span className="flex items-center">{children}</span>
796
+ </div>
797
+ );
798
+
333
799
  function fetchJson(url: string): Promise<Record<string, unknown>[]> {
334
800
  return fetchText(url).then(
335
801
  jsonParseWithSpecialChar<Record<string, unknown>[]>,