@marimo-team/islands 0.23.17-dev1 → 0.23.17-dev13
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/dist/{chat-ui-DsZJj75A.js → chat-ui-DH3hwWoI.js} +2 -2
- package/dist/{common-BGCQJb-W.js → common-D8DGPf0N.js} +5 -5
- package/dist/{html-to-image-CZ1kLKkq.js → html-to-image-CxlSazqu.js} +2095 -2088
- package/dist/main.js +5 -5
- package/dist/{process-output-bVfbcx5_.js → process-output-B24cDGzV.js} +1 -1
- package/dist/{reveal-component-NNi374so.js → reveal-component-UfXDaVe-.js} +2 -2
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/__mocks__/requests.ts +5 -0
- package/src/components/databases/icons/huggingface.svg +8 -0
- package/src/components/editor/actions/__tests__/pair-with-agent-commands.test.ts +1 -1
- package/src/components/editor/actions/export-dialog/__tests__/export-command.test.ts +115 -0
- package/src/components/editor/actions/export-dialog/__tests__/export-dialog.test.tsx +458 -0
- package/src/components/editor/actions/export-dialog/__tests__/export-notebook.test.ts +178 -0
- package/src/components/editor/actions/export-dialog/__tests__/state.test.ts +295 -0
- package/src/components/editor/actions/export-dialog/__tests__/use-export-dialog.test.tsx +287 -0
- package/src/components/editor/actions/export-dialog/export-command.ts +149 -0
- package/src/components/editor/actions/export-dialog/export-dialog.tsx +194 -0
- package/src/components/editor/actions/export-dialog/export-notebook.ts +98 -0
- package/src/components/editor/actions/export-dialog/format-notice.tsx +166 -0
- package/src/components/editor/actions/export-dialog/format-options.tsx +531 -0
- package/src/components/editor/actions/export-dialog/state.ts +339 -0
- package/src/components/editor/actions/export-dialog/use-export-dialog.ts +372 -0
- package/src/components/editor/actions/pair-with-agent-commands.ts +1 -21
- package/src/components/editor/actions/useNotebookActions.tsx +72 -170
- package/src/components/editor/chrome/panels/outline/__tests__/useActiveOutline.test.ts +26 -0
- package/src/components/editor/connections/add-connection-dialog.tsx +1 -1
- package/src/components/editor/connections/quick-add-data-sources.tsx +14 -7
- package/src/components/editor/connections/storage/__tests__/__snapshots__/as-code.test.ts.snap +14 -0
- package/src/components/editor/connections/storage/__tests__/as-code.test.ts +20 -0
- package/src/components/editor/connections/storage/add-storage-form.tsx +10 -0
- package/src/components/editor/connections/storage/as-code.ts +19 -1
- package/src/components/editor/connections/storage/schemas.ts +19 -0
- package/src/components/editor/controls/__tests__/notebook-menu-dropdown.test.tsx +187 -0
- package/src/components/editor/controls/notebook-menu-dropdown.tsx +4 -2
- package/src/components/storage/__tests__/storage-snippets.test.ts +88 -0
- package/src/components/storage/components.tsx +2 -0
- package/src/components/storage/storage-snippets.ts +58 -0
- package/src/core/__tests__/mode.test.ts +85 -0
- package/src/core/dom/outline.ts +25 -2
- package/src/core/mode.ts +11 -9
- package/src/core/network/__tests__/requests-lazy.test.ts +1 -0
- package/src/core/storage/types.ts +1 -0
- package/src/utils/__tests__/download.test.tsx +6 -4
- package/src/utils/download.ts +3 -1
- package/src/utils/shell.ts +17 -0
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
CodeIcon,
|
|
5
|
+
FileIcon,
|
|
6
|
+
FileTextIcon,
|
|
7
|
+
GlobeIcon,
|
|
8
|
+
ImageIcon,
|
|
9
|
+
type LucideIcon,
|
|
10
|
+
NotebookIcon,
|
|
11
|
+
} from "lucide-react";
|
|
12
|
+
import { type ComponentType, type PropsWithChildren, useId } from "react";
|
|
13
|
+
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
|
14
|
+
import {
|
|
15
|
+
Select,
|
|
16
|
+
SelectContent,
|
|
17
|
+
SelectItem,
|
|
18
|
+
SelectTrigger,
|
|
19
|
+
SelectValue,
|
|
20
|
+
} from "@/components/ui/select";
|
|
21
|
+
import { Switch } from "@/components/ui/switch";
|
|
22
|
+
import {
|
|
23
|
+
IPYNB_SORT_MODES,
|
|
24
|
+
MARKDOWN_FLAVORS,
|
|
25
|
+
PDF_PRESETS,
|
|
26
|
+
SCRIPT_TYPES,
|
|
27
|
+
type ExportFormat,
|
|
28
|
+
type ExportOptions,
|
|
29
|
+
type MarkdownFlavor,
|
|
30
|
+
} from "./state";
|
|
31
|
+
|
|
32
|
+
export interface FormatOptionsProps {
|
|
33
|
+
options: ExportOptions;
|
|
34
|
+
updateOptions: UpdateExportOptions;
|
|
35
|
+
disabled: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type UpdateExportOptions = <Format extends keyof ExportOptions>(
|
|
39
|
+
format: Format,
|
|
40
|
+
options: Partial<ExportOptions[Format]>,
|
|
41
|
+
) => void;
|
|
42
|
+
|
|
43
|
+
interface Choice<Value extends string> {
|
|
44
|
+
value: Value;
|
|
45
|
+
label: string;
|
|
46
|
+
description: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
type ChoiceDetails = Omit<Choice<string>, "value">;
|
|
50
|
+
|
|
51
|
+
function defineChoices<Value extends string>(
|
|
52
|
+
values: readonly Value[],
|
|
53
|
+
details: Record<Value, ChoiceDetails>,
|
|
54
|
+
): readonly Choice<Value>[] {
|
|
55
|
+
return values.map((value) => ({ value, ...details[value] }));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
type MarkdownFormat = MarkdownFlavor | "automatic";
|
|
59
|
+
const MARKDOWN_FORMATS: readonly MarkdownFormat[] = [
|
|
60
|
+
"automatic",
|
|
61
|
+
...MARKDOWN_FLAVORS,
|
|
62
|
+
];
|
|
63
|
+
const MARKDOWN_FLAVOR_OPTIONS = defineChoices(MARKDOWN_FORMATS, {
|
|
64
|
+
automatic: {
|
|
65
|
+
label: "Automatic",
|
|
66
|
+
description:
|
|
67
|
+
"Chooses a format from the notebook filename. Defaults to Markdown (.md).",
|
|
68
|
+
},
|
|
69
|
+
pymdown: {
|
|
70
|
+
label: "Markdown (.md)",
|
|
71
|
+
description: "Creates a .md file with Python code blocks.",
|
|
72
|
+
},
|
|
73
|
+
qmd: {
|
|
74
|
+
label: "Quarto (.qmd)",
|
|
75
|
+
description: "Creates a .qmd file for Quarto.",
|
|
76
|
+
},
|
|
77
|
+
mystmd: {
|
|
78
|
+
label: "MyST (.myst.md)",
|
|
79
|
+
description: "Creates a .myst.md file for MyST.",
|
|
80
|
+
},
|
|
81
|
+
mdx: {
|
|
82
|
+
label: "MDX (.mdx)",
|
|
83
|
+
description: "Creates a .mdx file for MDX.",
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const IPYNB_SORT_OPTIONS = defineChoices(IPYNB_SORT_MODES, {
|
|
88
|
+
"top-down": {
|
|
89
|
+
label: "Top to bottom",
|
|
90
|
+
description: "Keeps cells in the same order as this notebook.",
|
|
91
|
+
},
|
|
92
|
+
topological: {
|
|
93
|
+
label: "Dependency order",
|
|
94
|
+
description:
|
|
95
|
+
"Reorders cells so each cell appears after the cells it depends on.",
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const PDF_PRESET_OPTIONS = defineChoices(PDF_PRESETS, {
|
|
100
|
+
document: {
|
|
101
|
+
label: "Document",
|
|
102
|
+
description: "Creates pages for reading or printing.",
|
|
103
|
+
},
|
|
104
|
+
slides: {
|
|
105
|
+
label: "Slides",
|
|
106
|
+
description: "Creates presentation slides from the notebook.",
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const PDF_RENDERERS = ["browser", "latex"] as const;
|
|
111
|
+
const PDF_RENDERER_OPTIONS = defineChoices(PDF_RENDERERS, {
|
|
112
|
+
browser: {
|
|
113
|
+
label: "Browser",
|
|
114
|
+
description:
|
|
115
|
+
"Uses browser rendering to preserve the notebook's visual output.",
|
|
116
|
+
},
|
|
117
|
+
latex: {
|
|
118
|
+
label: "LaTeX first",
|
|
119
|
+
description:
|
|
120
|
+
"Uses Pandoc and TeX when available, then falls back to browser rendering.",
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const SCRIPT_TYPE_OPTIONS = defineChoices(SCRIPT_TYPES, {
|
|
125
|
+
source: {
|
|
126
|
+
label: "Notebook source",
|
|
127
|
+
description:
|
|
128
|
+
"Downloads the saved notebook source for continued editing in marimo.",
|
|
129
|
+
},
|
|
130
|
+
flat: {
|
|
131
|
+
label: "Flat script",
|
|
132
|
+
description: "Reorders cells so the Python script runs from top to bottom.",
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
interface FormatDefinition {
|
|
137
|
+
label: string;
|
|
138
|
+
actionLabel: string;
|
|
139
|
+
description: string;
|
|
140
|
+
Icon: LucideIcon;
|
|
141
|
+
Options: ComponentType<FormatOptionsProps>;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export const FORMAT_DEFINITIONS: Record<ExportFormat, FormatDefinition> = {
|
|
145
|
+
html: {
|
|
146
|
+
label: "HTML",
|
|
147
|
+
actionLabel: "Export HTML",
|
|
148
|
+
description: "Save the current notebook as an HTML page.",
|
|
149
|
+
Icon: GlobeIcon,
|
|
150
|
+
Options: HTMLFormatOptions,
|
|
151
|
+
},
|
|
152
|
+
markdown: {
|
|
153
|
+
label: "Markdown",
|
|
154
|
+
actionLabel: "Export Markdown",
|
|
155
|
+
description:
|
|
156
|
+
"Save code and text in a Markdown-based file. Cell outputs are not included.",
|
|
157
|
+
Icon: FileTextIcon,
|
|
158
|
+
Options: MarkdownFormatOptions,
|
|
159
|
+
},
|
|
160
|
+
ipynb: {
|
|
161
|
+
label: "Jupyter",
|
|
162
|
+
actionLabel: "Export Jupyter notebook",
|
|
163
|
+
description: "Open the notebook in Jupyter and other compatible tools.",
|
|
164
|
+
Icon: NotebookIcon,
|
|
165
|
+
Options: IPYNBFormatOptions,
|
|
166
|
+
},
|
|
167
|
+
pdf: {
|
|
168
|
+
label: "PDF",
|
|
169
|
+
actionLabel: "Export PDF",
|
|
170
|
+
description: "Save the notebook as a PDF for printing or sharing.",
|
|
171
|
+
Icon: FileIcon,
|
|
172
|
+
Options: PDFFormatOptions,
|
|
173
|
+
},
|
|
174
|
+
script: {
|
|
175
|
+
label: "Python",
|
|
176
|
+
actionLabel: "Export flat script",
|
|
177
|
+
description: "Save the notebook as a Python file.",
|
|
178
|
+
Icon: CodeIcon,
|
|
179
|
+
Options: ScriptFormatOptions,
|
|
180
|
+
},
|
|
181
|
+
png: {
|
|
182
|
+
label: "PNG",
|
|
183
|
+
actionLabel: "Export PNG",
|
|
184
|
+
description: "Capture the current app view as an image.",
|
|
185
|
+
Icon: ImageIcon,
|
|
186
|
+
Options: NoFormatOptions,
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
function HTMLFormatOptions({
|
|
191
|
+
options,
|
|
192
|
+
updateOptions,
|
|
193
|
+
disabled,
|
|
194
|
+
}: FormatOptionsProps) {
|
|
195
|
+
return (
|
|
196
|
+
<OptionsGroup>
|
|
197
|
+
<SwitchOption
|
|
198
|
+
label="Include code"
|
|
199
|
+
descriptions={{
|
|
200
|
+
checked: "Includes code cells in the webpage.",
|
|
201
|
+
unchecked: "Leaves code cells out of the webpage.",
|
|
202
|
+
}}
|
|
203
|
+
checked={options.html.includeCode}
|
|
204
|
+
disabled={disabled}
|
|
205
|
+
onCheckedChange={(includeCode) =>
|
|
206
|
+
updateOptions("html", { includeCode })
|
|
207
|
+
}
|
|
208
|
+
/>
|
|
209
|
+
</OptionsGroup>
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function MarkdownFormatOptions({
|
|
214
|
+
options,
|
|
215
|
+
updateOptions,
|
|
216
|
+
disabled,
|
|
217
|
+
}: FormatOptionsProps) {
|
|
218
|
+
return (
|
|
219
|
+
<OptionsGroup>
|
|
220
|
+
<SelectOption
|
|
221
|
+
label="Format"
|
|
222
|
+
value={options.markdown.flavor ?? "automatic"}
|
|
223
|
+
disabled={disabled}
|
|
224
|
+
onValueChange={(value) =>
|
|
225
|
+
updateOptions("markdown", {
|
|
226
|
+
flavor: value === "automatic" ? null : value,
|
|
227
|
+
})
|
|
228
|
+
}
|
|
229
|
+
options={MARKDOWN_FLAVOR_OPTIONS}
|
|
230
|
+
/>
|
|
231
|
+
</OptionsGroup>
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function IPYNBFormatOptions({
|
|
236
|
+
options,
|
|
237
|
+
updateOptions,
|
|
238
|
+
disabled,
|
|
239
|
+
}: FormatOptionsProps) {
|
|
240
|
+
return (
|
|
241
|
+
<OptionsGroup>
|
|
242
|
+
<RadioOption
|
|
243
|
+
label="Cell order"
|
|
244
|
+
value={options.ipynb.sortMode}
|
|
245
|
+
disabled={disabled}
|
|
246
|
+
onValueChange={(sortMode) => updateOptions("ipynb", { sortMode })}
|
|
247
|
+
options={IPYNB_SORT_OPTIONS}
|
|
248
|
+
/>
|
|
249
|
+
<SwitchOption
|
|
250
|
+
label="Include outputs"
|
|
251
|
+
descriptions={{
|
|
252
|
+
checked: "Keeps the outputs currently shown in the notebook.",
|
|
253
|
+
unchecked: "Leaves cell outputs out of the Jupyter notebook.",
|
|
254
|
+
}}
|
|
255
|
+
checked={options.ipynb.includeOutputs}
|
|
256
|
+
disabled={disabled}
|
|
257
|
+
onCheckedChange={(includeOutputs) =>
|
|
258
|
+
updateOptions("ipynb", { includeOutputs })
|
|
259
|
+
}
|
|
260
|
+
/>
|
|
261
|
+
</OptionsGroup>
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function PDFFormatOptions({
|
|
266
|
+
options,
|
|
267
|
+
updateOptions,
|
|
268
|
+
disabled,
|
|
269
|
+
}: FormatOptionsProps) {
|
|
270
|
+
return (
|
|
271
|
+
<OptionsGroup>
|
|
272
|
+
<RadioOption
|
|
273
|
+
label="Layout"
|
|
274
|
+
value={options.pdf.preset}
|
|
275
|
+
disabled={disabled}
|
|
276
|
+
onValueChange={(preset) => updateOptions("pdf", { preset })}
|
|
277
|
+
options={PDF_PRESET_OPTIONS}
|
|
278
|
+
/>
|
|
279
|
+
<SwitchOption
|
|
280
|
+
label="Include code"
|
|
281
|
+
descriptions={{
|
|
282
|
+
checked: "Includes code cells in the PDF.",
|
|
283
|
+
unchecked: "Leaves code cells out of the PDF.",
|
|
284
|
+
}}
|
|
285
|
+
checked={options.pdf.includeInputs}
|
|
286
|
+
disabled={disabled}
|
|
287
|
+
onCheckedChange={(includeInputs) =>
|
|
288
|
+
updateOptions("pdf", { includeInputs })
|
|
289
|
+
}
|
|
290
|
+
/>
|
|
291
|
+
<SwitchOption
|
|
292
|
+
label="Include outputs"
|
|
293
|
+
descriptions={{
|
|
294
|
+
checked: "Keeps the outputs currently shown in the notebook.",
|
|
295
|
+
unchecked: "Leaves cell outputs out of the PDF.",
|
|
296
|
+
}}
|
|
297
|
+
checked={options.pdf.includeOutputs}
|
|
298
|
+
disabled={disabled}
|
|
299
|
+
onCheckedChange={(includeOutputs) =>
|
|
300
|
+
updateOptions("pdf", { includeOutputs })
|
|
301
|
+
}
|
|
302
|
+
/>
|
|
303
|
+
{options.pdf.preset === "document" ? (
|
|
304
|
+
<RadioOption
|
|
305
|
+
label="Method"
|
|
306
|
+
value={options.pdf.webpdf ? "browser" : "latex"}
|
|
307
|
+
disabled={disabled}
|
|
308
|
+
onValueChange={(renderer) =>
|
|
309
|
+
updateOptions("pdf", { webpdf: renderer === "browser" })
|
|
310
|
+
}
|
|
311
|
+
options={PDF_RENDERER_OPTIONS}
|
|
312
|
+
/>
|
|
313
|
+
) : null}
|
|
314
|
+
</OptionsGroup>
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function ScriptFormatOptions({
|
|
319
|
+
options,
|
|
320
|
+
updateOptions,
|
|
321
|
+
disabled,
|
|
322
|
+
}: FormatOptionsProps) {
|
|
323
|
+
return (
|
|
324
|
+
<OptionsGroup>
|
|
325
|
+
<RadioOption
|
|
326
|
+
label="Format"
|
|
327
|
+
value={options.script.type}
|
|
328
|
+
disabled={disabled}
|
|
329
|
+
onValueChange={(type) => updateOptions("script", { type })}
|
|
330
|
+
options={SCRIPT_TYPE_OPTIONS}
|
|
331
|
+
/>
|
|
332
|
+
</OptionsGroup>
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function NoFormatOptions() {
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function OptionsGroup({ children }: PropsWithChildren) {
|
|
341
|
+
return <div className="divide-y rounded-md border">{children}</div>;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function SwitchOption({
|
|
345
|
+
label,
|
|
346
|
+
descriptions,
|
|
347
|
+
descriptionOverride,
|
|
348
|
+
checked,
|
|
349
|
+
disabled,
|
|
350
|
+
onCheckedChange,
|
|
351
|
+
}: {
|
|
352
|
+
label: string;
|
|
353
|
+
descriptions: {
|
|
354
|
+
checked: string;
|
|
355
|
+
unchecked: string;
|
|
356
|
+
};
|
|
357
|
+
descriptionOverride?: string;
|
|
358
|
+
checked: boolean;
|
|
359
|
+
disabled?: boolean;
|
|
360
|
+
onCheckedChange: (checked: boolean) => void;
|
|
361
|
+
}) {
|
|
362
|
+
const id = useId();
|
|
363
|
+
const descriptionId = `${id}-description`;
|
|
364
|
+
const description =
|
|
365
|
+
descriptionOverride ??
|
|
366
|
+
(checked ? descriptions.checked : descriptions.unchecked);
|
|
367
|
+
const possibleDescriptions = [
|
|
368
|
+
descriptions.checked,
|
|
369
|
+
descriptions.unchecked,
|
|
370
|
+
...(descriptionOverride ? [descriptionOverride] : []),
|
|
371
|
+
];
|
|
372
|
+
return (
|
|
373
|
+
<div className="flex min-h-14 items-center justify-between gap-4 px-3 py-2">
|
|
374
|
+
<div className="min-w-0">
|
|
375
|
+
<label htmlFor={id} className="text-sm font-medium leading-5">
|
|
376
|
+
{label}
|
|
377
|
+
</label>
|
|
378
|
+
<OptionDescription
|
|
379
|
+
id={descriptionId}
|
|
380
|
+
description={description}
|
|
381
|
+
possibleDescriptions={possibleDescriptions}
|
|
382
|
+
/>
|
|
383
|
+
</div>
|
|
384
|
+
<Switch
|
|
385
|
+
id={id}
|
|
386
|
+
size="sm"
|
|
387
|
+
checked={checked}
|
|
388
|
+
disabled={disabled}
|
|
389
|
+
onCheckedChange={onCheckedChange}
|
|
390
|
+
aria-label={label}
|
|
391
|
+
aria-describedby={descriptionId}
|
|
392
|
+
/>
|
|
393
|
+
</div>
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function RadioOption<Value extends string>({
|
|
398
|
+
label,
|
|
399
|
+
value,
|
|
400
|
+
disabled,
|
|
401
|
+
onValueChange,
|
|
402
|
+
options,
|
|
403
|
+
}: {
|
|
404
|
+
label: string;
|
|
405
|
+
value: Value;
|
|
406
|
+
disabled?: boolean;
|
|
407
|
+
onValueChange: (value: Value) => void;
|
|
408
|
+
options: readonly Choice<Value>[];
|
|
409
|
+
}) {
|
|
410
|
+
const id = useId();
|
|
411
|
+
const descriptionId = `${id}-description`;
|
|
412
|
+
const selectedDescription = options.find(
|
|
413
|
+
(option) => option.value === value,
|
|
414
|
+
)?.description;
|
|
415
|
+
return (
|
|
416
|
+
<div className="flex min-h-14 flex-col items-stretch gap-2 px-3 py-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
|
417
|
+
<div className="min-w-0">
|
|
418
|
+
<div className="text-sm font-medium leading-5">{label}</div>
|
|
419
|
+
<OptionDescription
|
|
420
|
+
id={descriptionId}
|
|
421
|
+
description={selectedDescription ?? ""}
|
|
422
|
+
possibleDescriptions={options.map((option) => option.description)}
|
|
423
|
+
/>
|
|
424
|
+
</div>
|
|
425
|
+
<RadioGroup
|
|
426
|
+
value={value}
|
|
427
|
+
onValueChange={(nextValue) => onValueChange(nextValue as Value)}
|
|
428
|
+
className="flex shrink-0 flex-wrap gap-x-5 gap-y-2 sm:justify-end"
|
|
429
|
+
aria-label={label}
|
|
430
|
+
aria-describedby={descriptionId}
|
|
431
|
+
disabled={disabled}
|
|
432
|
+
>
|
|
433
|
+
{options.map((option, index) => {
|
|
434
|
+
const optionId = `${id}-${index}`;
|
|
435
|
+
return (
|
|
436
|
+
<div key={option.value} className="flex items-center gap-2">
|
|
437
|
+
<RadioGroupItem id={optionId} value={option.value} />
|
|
438
|
+
<label htmlFor={optionId} className="text-sm">
|
|
439
|
+
{option.label}
|
|
440
|
+
</label>
|
|
441
|
+
</div>
|
|
442
|
+
);
|
|
443
|
+
})}
|
|
444
|
+
</RadioGroup>
|
|
445
|
+
</div>
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function SelectOption<Value extends string>({
|
|
450
|
+
label,
|
|
451
|
+
value,
|
|
452
|
+
disabled,
|
|
453
|
+
onValueChange,
|
|
454
|
+
options,
|
|
455
|
+
}: {
|
|
456
|
+
label: string;
|
|
457
|
+
value: Value;
|
|
458
|
+
disabled?: boolean;
|
|
459
|
+
onValueChange: (value: Value) => void;
|
|
460
|
+
options: readonly Choice<Value>[];
|
|
461
|
+
}) {
|
|
462
|
+
const id = useId();
|
|
463
|
+
const descriptionId = `${id}-description`;
|
|
464
|
+
const selectedDescription = options.find(
|
|
465
|
+
(option) => option.value === value,
|
|
466
|
+
)?.description;
|
|
467
|
+
return (
|
|
468
|
+
<div className="flex min-h-14 flex-col items-stretch gap-2 px-3 py-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
|
469
|
+
<div className="min-w-0">
|
|
470
|
+
<label htmlFor={id} className="text-sm font-medium leading-5">
|
|
471
|
+
{label}
|
|
472
|
+
</label>
|
|
473
|
+
<OptionDescription
|
|
474
|
+
id={descriptionId}
|
|
475
|
+
description={selectedDescription ?? ""}
|
|
476
|
+
possibleDescriptions={options.map((option) => option.description)}
|
|
477
|
+
/>
|
|
478
|
+
</div>
|
|
479
|
+
<Select
|
|
480
|
+
value={value}
|
|
481
|
+
disabled={disabled}
|
|
482
|
+
onValueChange={(nextValue) => onValueChange(nextValue as Value)}
|
|
483
|
+
>
|
|
484
|
+
<SelectTrigger
|
|
485
|
+
id={id}
|
|
486
|
+
aria-describedby={descriptionId}
|
|
487
|
+
className="h-8 w-full shrink-0 sm:w-44"
|
|
488
|
+
>
|
|
489
|
+
<SelectValue />
|
|
490
|
+
</SelectTrigger>
|
|
491
|
+
<SelectContent>
|
|
492
|
+
{options.map((option) => (
|
|
493
|
+
<SelectItem key={option.value} value={option.value}>
|
|
494
|
+
{option.label}
|
|
495
|
+
</SelectItem>
|
|
496
|
+
))}
|
|
497
|
+
</SelectContent>
|
|
498
|
+
</Select>
|
|
499
|
+
</div>
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function OptionDescription({
|
|
504
|
+
id,
|
|
505
|
+
description,
|
|
506
|
+
possibleDescriptions,
|
|
507
|
+
}: {
|
|
508
|
+
id: string;
|
|
509
|
+
description: string;
|
|
510
|
+
possibleDescriptions: readonly string[];
|
|
511
|
+
}) {
|
|
512
|
+
const sizingDescriptions = [...new Set(possibleDescriptions)].filter(
|
|
513
|
+
(candidate) => candidate !== description,
|
|
514
|
+
);
|
|
515
|
+
return (
|
|
516
|
+
<div className="mt-0.5 grid text-xs leading-4 text-muted-foreground">
|
|
517
|
+
<p id={id} aria-live="polite" className="col-start-1 row-start-1">
|
|
518
|
+
{description}
|
|
519
|
+
</p>
|
|
520
|
+
{sizingDescriptions.map((candidate) => (
|
|
521
|
+
<p
|
|
522
|
+
key={candidate}
|
|
523
|
+
aria-hidden={true}
|
|
524
|
+
className="invisible col-start-1 row-start-1"
|
|
525
|
+
>
|
|
526
|
+
{candidate}
|
|
527
|
+
</p>
|
|
528
|
+
))}
|
|
529
|
+
</div>
|
|
530
|
+
);
|
|
531
|
+
}
|