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.
@@ -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
+ }