emdash-header-footer-code 0.1.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/LICENSE +21 -0
- package/README.md +217 -0
- package/dist/index.d.mts +14 -0
- package/dist/index.mjs +22 -0
- package/dist/plugin.d.mts +17 -0
- package/dist/plugin.mjs +608 -0
- package/dist/types-CNDv5vQX.d.mts +46 -0
- package/dist/version-iEzwHCKh.mjs +6 -0
- package/package.json +46 -0
- package/src/admin/ChangeLog.tsx +84 -0
- package/src/admin/ChipInput.tsx +154 -0
- package/src/admin/CodeEditor.tsx +135 -0
- package/src/admin/SnippetForm.tsx +458 -0
- package/src/admin/SnippetList.tsx +282 -0
- package/src/admin/SnippetsPage.tsx +285 -0
- package/src/admin/api.ts +47 -0
- package/src/admin/chips.ts +51 -0
- package/src/admin/format.ts +96 -0
- package/src/admin/icons.tsx +91 -0
- package/src/admin/indent.ts +56 -0
- package/src/admin/index.tsx +6 -0
- package/src/admin/styles.tsx +93 -0
- package/src/core/fields.ts +117 -0
- package/src/core/match.ts +54 -0
- package/src/core/paths.ts +48 -0
- package/src/core/pipeline.ts +53 -0
- package/src/core/types.ts +79 -0
- package/src/core/validate.ts +139 -0
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
import { Banner, Button, Dialog, Input, Label, LayerCard, Select, Switch, cn } from "@cloudflare/kumo";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
import { FIELDS, type FieldDef, type FieldSection, getFieldValue, setFieldValue } from "../core/fields.js";
|
|
4
|
+
import type { EditableSnippet, Snippet } from "../core/types.js";
|
|
5
|
+
import { LIMITS, byteLength, checkLimits, formatKB, validateSnippetInput } from "../core/validate.js";
|
|
6
|
+
import { ApiError, api } from "./api.js";
|
|
7
|
+
import { ChipInput } from "./ChipInput.js";
|
|
8
|
+
import { CodeEditor } from "./CodeEditor.js";
|
|
9
|
+
import { ArrowLeftIcon } from "./icons.js";
|
|
10
|
+
|
|
11
|
+
export type Draft = EditableSnippet & { id?: string };
|
|
12
|
+
|
|
13
|
+
/** Keyboard help shown in the Code card header; the code textarea references it. */
|
|
14
|
+
const CODE_KEYS_ID = "hfc-code-keys";
|
|
15
|
+
|
|
16
|
+
const SECTION_TITLE: Record<Exclude<FieldSection, "main">, { title: string; description: string }> = {
|
|
17
|
+
settings: { title: "Settings", description: "What this snippet is and where in the page it goes." },
|
|
18
|
+
targeting: { title: "Targeting", description: "Which pages it renders on. Leave everything empty for every page." },
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function fieldId(name: string) {
|
|
22
|
+
return `hfc-field-${name}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function sameDraft(a: Draft, b: Draft) {
|
|
26
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function FieldShell({
|
|
30
|
+
field,
|
|
31
|
+
error,
|
|
32
|
+
children,
|
|
33
|
+
labelFor = true,
|
|
34
|
+
hideLabel = false,
|
|
35
|
+
}: {
|
|
36
|
+
field: FieldDef;
|
|
37
|
+
error?: string;
|
|
38
|
+
children: React.ReactNode;
|
|
39
|
+
labelFor?: boolean;
|
|
40
|
+
/** Visually hide the label (the code field's card already names it). */
|
|
41
|
+
hideLabel?: boolean;
|
|
42
|
+
}) {
|
|
43
|
+
const id = fieldId(field.name);
|
|
44
|
+
return (
|
|
45
|
+
<div className="grid gap-1.5">
|
|
46
|
+
<Label htmlFor={labelFor ? id : undefined} className={hideLabel ? "sr-only" : undefined}>
|
|
47
|
+
{field.label}
|
|
48
|
+
</Label>
|
|
49
|
+
{children}
|
|
50
|
+
{error && (
|
|
51
|
+
<p id={`${id}-error`} data-testid={`hfc-error-${field.name}`} role="alert" className="text-sm text-kumo-danger">
|
|
52
|
+
{error}
|
|
53
|
+
</p>
|
|
54
|
+
)}
|
|
55
|
+
{field.help && (
|
|
56
|
+
<p id={`${id}-help`} className="text-sm text-kumo-subtle">
|
|
57
|
+
{field.help}
|
|
58
|
+
</p>
|
|
59
|
+
)}
|
|
60
|
+
</div>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function FieldControl({
|
|
65
|
+
field,
|
|
66
|
+
value,
|
|
67
|
+
error,
|
|
68
|
+
onChange,
|
|
69
|
+
onChipError,
|
|
70
|
+
onChipPending,
|
|
71
|
+
}: {
|
|
72
|
+
field: FieldDef;
|
|
73
|
+
value: unknown;
|
|
74
|
+
error?: string;
|
|
75
|
+
onChange: (v: unknown) => void;
|
|
76
|
+
onChipError: (e: string | null) => void;
|
|
77
|
+
onChipPending: (pending: boolean) => void;
|
|
78
|
+
}) {
|
|
79
|
+
const id = fieldId(field.name);
|
|
80
|
+
const describedBy =
|
|
81
|
+
[field.type === "code" && CODE_KEYS_ID, field.help && `${id}-help`, error && `${id}-error`]
|
|
82
|
+
.filter(Boolean)
|
|
83
|
+
.join(" ") || undefined;
|
|
84
|
+
const invalid = Boolean(error);
|
|
85
|
+
switch (field.type) {
|
|
86
|
+
case "text":
|
|
87
|
+
case "number":
|
|
88
|
+
return (
|
|
89
|
+
<FieldShell field={field} error={error}>
|
|
90
|
+
<Input
|
|
91
|
+
id={id}
|
|
92
|
+
data-testid={id}
|
|
93
|
+
type={field.type === "number" ? "number" : "text"}
|
|
94
|
+
step={field.type === "number" ? 1 : undefined}
|
|
95
|
+
className={cn("w-full", invalid && "!ring-kumo-danger")}
|
|
96
|
+
aria-invalid={invalid || undefined}
|
|
97
|
+
aria-describedby={describedBy}
|
|
98
|
+
value={String(value ?? "")}
|
|
99
|
+
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
|
100
|
+
onChange(
|
|
101
|
+
field.type === "number"
|
|
102
|
+
? e.target.value === ""
|
|
103
|
+
? ""
|
|
104
|
+
: Number(e.target.value)
|
|
105
|
+
: e.target.value,
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
/>
|
|
109
|
+
</FieldShell>
|
|
110
|
+
);
|
|
111
|
+
case "select":
|
|
112
|
+
return (
|
|
113
|
+
<FieldShell field={field} error={error} labelFor={false}>
|
|
114
|
+
<div data-testid={id}>
|
|
115
|
+
<Select
|
|
116
|
+
aria-label={field.label}
|
|
117
|
+
className={cn("w-full", invalid && "!ring-kumo-danger")}
|
|
118
|
+
value={String(value ?? "")}
|
|
119
|
+
onValueChange={(v) => onChange(v)}
|
|
120
|
+
items={Object.fromEntries((field.options ?? []).map((o) => [o.value, o.label]))}
|
|
121
|
+
/>
|
|
122
|
+
</div>
|
|
123
|
+
</FieldShell>
|
|
124
|
+
);
|
|
125
|
+
case "toggle":
|
|
126
|
+
return (
|
|
127
|
+
<div className="grid gap-1.5">
|
|
128
|
+
<Switch
|
|
129
|
+
id={id}
|
|
130
|
+
data-testid={id}
|
|
131
|
+
checked={Boolean(value)}
|
|
132
|
+
onCheckedChange={onChange}
|
|
133
|
+
label={field.label}
|
|
134
|
+
/>
|
|
135
|
+
{field.help && <p className="text-sm text-kumo-subtle">{field.help}</p>}
|
|
136
|
+
{error && (
|
|
137
|
+
<p data-testid={`hfc-error-${field.name}`} role="alert" className="text-sm text-kumo-danger">
|
|
138
|
+
{error}
|
|
139
|
+
</p>
|
|
140
|
+
)}
|
|
141
|
+
</div>
|
|
142
|
+
);
|
|
143
|
+
case "pathList":
|
|
144
|
+
case "localeList":
|
|
145
|
+
return (
|
|
146
|
+
<FieldShell field={field} error={error}>
|
|
147
|
+
<ChipInput
|
|
148
|
+
id={id}
|
|
149
|
+
kind={field.type === "pathList" ? "path" : "locale"}
|
|
150
|
+
label={field.label}
|
|
151
|
+
placeholder={field.type === "pathList" ? "/blog/*" : "en"}
|
|
152
|
+
value={Array.isArray(value) ? (value as string[]) : []}
|
|
153
|
+
onChange={onChange}
|
|
154
|
+
onErrorChange={onChipError}
|
|
155
|
+
onPendingChange={onChipPending}
|
|
156
|
+
invalid={invalid}
|
|
157
|
+
describedBy={describedBy}
|
|
158
|
+
/>
|
|
159
|
+
</FieldShell>
|
|
160
|
+
);
|
|
161
|
+
case "code":
|
|
162
|
+
return (
|
|
163
|
+
<FieldShell field={field} error={error} hideLabel={field.section === "main"}>
|
|
164
|
+
<CodeEditor
|
|
165
|
+
id={id}
|
|
166
|
+
label={field.label}
|
|
167
|
+
value={String(value ?? "")}
|
|
168
|
+
onChange={onChange}
|
|
169
|
+
invalid={invalid}
|
|
170
|
+
describedBy={describedBy}
|
|
171
|
+
/>
|
|
172
|
+
</FieldShell>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function CardHeader({
|
|
178
|
+
title,
|
|
179
|
+
description,
|
|
180
|
+
descriptionId,
|
|
181
|
+
aside,
|
|
182
|
+
}: {
|
|
183
|
+
title: string;
|
|
184
|
+
description?: string;
|
|
185
|
+
descriptionId?: string;
|
|
186
|
+
aside?: React.ReactNode;
|
|
187
|
+
}) {
|
|
188
|
+
return (
|
|
189
|
+
<div className="flex items-start justify-between gap-4 border-b border-kumo-line px-5 py-4">
|
|
190
|
+
<div className="min-w-0">
|
|
191
|
+
<h2 className="text-base font-semibold text-kumo-default">{title}</h2>
|
|
192
|
+
{description && (
|
|
193
|
+
<p id={descriptionId} className="text-sm text-kumo-subtle">
|
|
194
|
+
{description}
|
|
195
|
+
</p>
|
|
196
|
+
)}
|
|
197
|
+
</div>
|
|
198
|
+
{aside}
|
|
199
|
+
</div>
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function SnippetForm({
|
|
204
|
+
initial,
|
|
205
|
+
onSaved,
|
|
206
|
+
onClose,
|
|
207
|
+
}: {
|
|
208
|
+
initial: Draft;
|
|
209
|
+
onSaved: (snippet: Snippet) => void;
|
|
210
|
+
onClose: () => void;
|
|
211
|
+
}) {
|
|
212
|
+
const [draft, setDraft] = React.useState<Draft>(initial);
|
|
213
|
+
const draftRef = React.useRef(draft);
|
|
214
|
+
const [errors, setErrors] = React.useState<Record<string, string>>({});
|
|
215
|
+
const [chipErrors, setChipErrors] = React.useState<Record<string, string>>({});
|
|
216
|
+
const [saving, setSaving] = React.useState(false);
|
|
217
|
+
const [confirmDiscard, setConfirmDiscard] = React.useState(false);
|
|
218
|
+
// Chip inputs holding text that has not been added yet (valid or not) count as unsaved edits.
|
|
219
|
+
const [pendingChips, setPendingChips] = React.useState<Record<string, true>>({});
|
|
220
|
+
|
|
221
|
+
const dirty = !sameDraft(draft, initial) || Object.keys(pendingChips).length > 0;
|
|
222
|
+
const codeBytes = byteLength(draft.code);
|
|
223
|
+
const overLimit = codeBytes > LIMITS.maxSnippetBytes;
|
|
224
|
+
|
|
225
|
+
function update(name: string, v: unknown) {
|
|
226
|
+
const next = setFieldValue(draftRef.current as unknown as Record<string, unknown>, name, v) as unknown as Draft;
|
|
227
|
+
draftRef.current = next;
|
|
228
|
+
setDraft(next);
|
|
229
|
+
setErrors((e) => {
|
|
230
|
+
if (!(name in e) && !("_form" in e)) return e;
|
|
231
|
+
const { [name]: _, _form: __, ...rest } = e;
|
|
232
|
+
return rest;
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function save() {
|
|
237
|
+
if (saving) return;
|
|
238
|
+
// Commit any half-typed chip (ChipInput adds pending text on blur) before reading the draft.
|
|
239
|
+
const active = document.activeElement;
|
|
240
|
+
if (active instanceof HTMLElement) active.blur();
|
|
241
|
+
// When the save does not go through, put focus back where the user was (e.g. after Ctrl+S).
|
|
242
|
+
const restoreFocus = () => {
|
|
243
|
+
if (active instanceof HTMLElement && active !== document.body && active.isConnected) active.focus();
|
|
244
|
+
};
|
|
245
|
+
await Promise.resolve();
|
|
246
|
+
const current = draftRef.current;
|
|
247
|
+
const local = validateSnippetInput(current);
|
|
248
|
+
if (!local.ok) {
|
|
249
|
+
setErrors(local.errors);
|
|
250
|
+
restoreFocus();
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
// The per-snippet size limit needs no server data, so report it under the Code field here.
|
|
254
|
+
const sizeErrors = checkLimits({ code: local.value.code }, []);
|
|
255
|
+
if (sizeErrors) {
|
|
256
|
+
setErrors(sizeErrors);
|
|
257
|
+
restoreFocus();
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
if (Object.keys(chipErrorsRef.current).length > 0) {
|
|
261
|
+
restoreFocus();
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
setSaving(true);
|
|
265
|
+
try {
|
|
266
|
+
const { snippet } = await api.save({ ...local.value, meta: current.meta, id: current.id });
|
|
267
|
+
onSaved(snippet);
|
|
268
|
+
} catch (e) {
|
|
269
|
+
// EmDash 1.0.1 drops error details over HTTP; fall back to the (readable) message.
|
|
270
|
+
const fieldErrors = e instanceof ApiError ? e.fieldErrors : {};
|
|
271
|
+
setErrors(
|
|
272
|
+
Object.keys(fieldErrors).length > 0 ? fieldErrors : { _form: e instanceof Error ? e.message : String(e) },
|
|
273
|
+
);
|
|
274
|
+
setSaving(false);
|
|
275
|
+
restoreFocus();
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const chipErrorsRef = React.useRef(chipErrors);
|
|
280
|
+
chipErrorsRef.current = chipErrors;
|
|
281
|
+
const saveRef = React.useRef(save);
|
|
282
|
+
saveRef.current = save;
|
|
283
|
+
|
|
284
|
+
// Ctrl/Cmd+S saves; leaving the page with unsaved edits asks first.
|
|
285
|
+
React.useEffect(() => {
|
|
286
|
+
function onKey(e: KeyboardEvent) {
|
|
287
|
+
if ((e.metaKey || e.ctrlKey) && !e.altKey && e.key.toLowerCase() === "s") {
|
|
288
|
+
e.preventDefault();
|
|
289
|
+
void saveRef.current();
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
window.addEventListener("keydown", onKey);
|
|
293
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
294
|
+
}, []);
|
|
295
|
+
React.useEffect(() => {
|
|
296
|
+
if (!dirty) return;
|
|
297
|
+
function onBeforeUnload(e: BeforeUnloadEvent) {
|
|
298
|
+
e.preventDefault();
|
|
299
|
+
}
|
|
300
|
+
window.addEventListener("beforeunload", onBeforeUnload);
|
|
301
|
+
return () => window.removeEventListener("beforeunload", onBeforeUnload);
|
|
302
|
+
}, [dirty]);
|
|
303
|
+
|
|
304
|
+
function requestClose() {
|
|
305
|
+
if (saving) return;
|
|
306
|
+
if (dirty) setConfirmDiscard(true);
|
|
307
|
+
else onClose();
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function renderField(field: FieldDef) {
|
|
311
|
+
return (
|
|
312
|
+
<FieldControl
|
|
313
|
+
key={field.name}
|
|
314
|
+
field={field}
|
|
315
|
+
value={getFieldValue(draft as unknown as Record<string, unknown>, field.name)}
|
|
316
|
+
error={chipErrors[field.name] ?? errors[field.name]}
|
|
317
|
+
onChange={(v) => update(field.name, v)}
|
|
318
|
+
onChipPending={(pending) =>
|
|
319
|
+
setPendingChips((p) => {
|
|
320
|
+
if (pending === Boolean(p[field.name])) return p;
|
|
321
|
+
if (pending) return { ...p, [field.name]: true };
|
|
322
|
+
const { [field.name]: _, ...rest } = p;
|
|
323
|
+
return rest;
|
|
324
|
+
})
|
|
325
|
+
}
|
|
326
|
+
onChipError={(err) =>
|
|
327
|
+
setChipErrors((c) => {
|
|
328
|
+
if (err) return { ...c, [field.name]: err };
|
|
329
|
+
if (!(field.name in c)) return c;
|
|
330
|
+
const { [field.name]: _, ...rest } = c;
|
|
331
|
+
return rest;
|
|
332
|
+
})
|
|
333
|
+
}
|
|
334
|
+
/>
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const mainFields = FIELDS.filter((f) => f.section === "main");
|
|
339
|
+
// Switches read best at the end of a card, so toggles go last (otherwise FIELDS order).
|
|
340
|
+
const settingsFields = FIELDS.filter((f) => f.section === "settings").sort(
|
|
341
|
+
(x, y) => Number(x.type === "toggle") - Number(y.type === "toggle"),
|
|
342
|
+
);
|
|
343
|
+
const targetingFields = FIELDS.filter((f) => f.section === "targeting");
|
|
344
|
+
|
|
345
|
+
return (
|
|
346
|
+
<div className="space-y-6">
|
|
347
|
+
<div className="space-y-3">
|
|
348
|
+
<Button
|
|
349
|
+
variant="ghost"
|
|
350
|
+
size="sm"
|
|
351
|
+
icon={<ArrowLeftIcon />}
|
|
352
|
+
data-testid="hfc-back"
|
|
353
|
+
disabled={saving}
|
|
354
|
+
onClick={requestClose}
|
|
355
|
+
>
|
|
356
|
+
Back to snippets
|
|
357
|
+
</Button>
|
|
358
|
+
<div>
|
|
359
|
+
<h1 className="text-2xl font-semibold leading-tight">{initial.id ? "Edit snippet" : "New snippet"}</h1>
|
|
360
|
+
<p className="text-sm text-kumo-subtle">
|
|
361
|
+
{initial.id
|
|
362
|
+
? "Changes apply on the next page load once saved."
|
|
363
|
+
: "Paste the code your provider gave you, then choose where it renders."}
|
|
364
|
+
</p>
|
|
365
|
+
</div>
|
|
366
|
+
</div>
|
|
367
|
+
|
|
368
|
+
{errors._form && (
|
|
369
|
+
<Banner
|
|
370
|
+
variant="error"
|
|
371
|
+
data-testid="hfc-error-_form"
|
|
372
|
+
title="The snippet could not be saved"
|
|
373
|
+
description={errors._form}
|
|
374
|
+
/>
|
|
375
|
+
)}
|
|
376
|
+
|
|
377
|
+
{/*
|
|
378
|
+
Wide: Code and Targeting stacked in the main column, Settings alone on the right and
|
|
379
|
+
sticky. Narrow: Code -> Settings -> Targeting. Placement is done with grid areas in
|
|
380
|
+
styles.tsx so the DOM (and tab) order is the narrow order.
|
|
381
|
+
*/}
|
|
382
|
+
<div className="hfc-edit-grid">
|
|
383
|
+
<LayerCard className="hfc-area-code">
|
|
384
|
+
<CardHeader
|
|
385
|
+
title="Code"
|
|
386
|
+
description="Tab indents, Shift+Tab outdents. Press Esc then Tab to move on."
|
|
387
|
+
descriptionId={CODE_KEYS_ID}
|
|
388
|
+
aside={
|
|
389
|
+
<span
|
|
390
|
+
data-testid="hfc-code-size"
|
|
391
|
+
className={cn(
|
|
392
|
+
"shrink-0 whitespace-nowrap text-sm tabular-nums",
|
|
393
|
+
overLimit ? "font-medium text-kumo-danger" : "text-kumo-subtle",
|
|
394
|
+
)}
|
|
395
|
+
>
|
|
396
|
+
{formatKB(codeBytes)} of {formatKB(LIMITS.maxSnippetBytes).replace(".0", "")}
|
|
397
|
+
</span>
|
|
398
|
+
}
|
|
399
|
+
/>
|
|
400
|
+
<div className="space-y-4 p-5">{mainFields.map(renderField)}</div>
|
|
401
|
+
</LayerCard>
|
|
402
|
+
|
|
403
|
+
{settingsFields.length > 0 && (
|
|
404
|
+
<LayerCard className="hfc-area-settings">
|
|
405
|
+
<CardHeader title={SECTION_TITLE.settings.title} description={SECTION_TITLE.settings.description} />
|
|
406
|
+
<div className="space-y-5 p-5">{settingsFields.map(renderField)}</div>
|
|
407
|
+
</LayerCard>
|
|
408
|
+
)}
|
|
409
|
+
|
|
410
|
+
{targetingFields.length > 0 && (
|
|
411
|
+
<LayerCard className="hfc-area-targeting hfc-targeting">
|
|
412
|
+
<CardHeader title={SECTION_TITLE.targeting.title} description={SECTION_TITLE.targeting.description} />
|
|
413
|
+
<div className="hfc-targeting-grid p-5">{targetingFields.map(renderField)}</div>
|
|
414
|
+
</LayerCard>
|
|
415
|
+
)}
|
|
416
|
+
</div>
|
|
417
|
+
|
|
418
|
+
<div className="hfc-footer flex flex-wrap items-center justify-between gap-3">
|
|
419
|
+
<p className="text-sm text-kumo-subtle">
|
|
420
|
+
{dirty ? "Unsaved changes" : "No changes yet"}
|
|
421
|
+
<span className="hidden sm:inline"> · Ctrl/⌘ + S to save</span>
|
|
422
|
+
</p>
|
|
423
|
+
<div className="flex gap-2">
|
|
424
|
+
<Button variant="secondary" data-testid="hfc-cancel" disabled={saving} onClick={requestClose}>
|
|
425
|
+
Cancel
|
|
426
|
+
</Button>
|
|
427
|
+
<Button variant="primary" data-testid="hfc-save" loading={saving} onClick={() => void save()}>
|
|
428
|
+
Save snippet
|
|
429
|
+
</Button>
|
|
430
|
+
</div>
|
|
431
|
+
</div>
|
|
432
|
+
|
|
433
|
+
<Dialog.Root role="alertdialog" open={confirmDiscard} onOpenChange={(o) => !o && setConfirmDiscard(false)}>
|
|
434
|
+
<Dialog className="max-w-md p-6" size="sm">
|
|
435
|
+
<Dialog.Title className="text-lg font-semibold">Discard unsaved changes?</Dialog.Title>
|
|
436
|
+
<Dialog.Description className="text-kumo-subtle">
|
|
437
|
+
Your edits to this snippet will be lost.
|
|
438
|
+
</Dialog.Description>
|
|
439
|
+
<div className="mt-6 flex justify-end gap-2">
|
|
440
|
+
<Button variant="secondary" onClick={() => setConfirmDiscard(false)}>
|
|
441
|
+
Keep editing
|
|
442
|
+
</Button>
|
|
443
|
+
<Button
|
|
444
|
+
variant="destructive"
|
|
445
|
+
data-testid="hfc-discard-confirm"
|
|
446
|
+
onClick={() => {
|
|
447
|
+
setConfirmDiscard(false);
|
|
448
|
+
onClose();
|
|
449
|
+
}}
|
|
450
|
+
>
|
|
451
|
+
Discard
|
|
452
|
+
</Button>
|
|
453
|
+
</div>
|
|
454
|
+
</Dialog>
|
|
455
|
+
</Dialog.Root>
|
|
456
|
+
</div>
|
|
457
|
+
);
|
|
458
|
+
}
|