@pramen/cms-editor 0.0.65 → 0.0.66
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/editor.js +105 -105
- package/package.json +1 -1
- package/src/fields.tsx +83 -23
- package/src/schema-builder.tsx +13 -0
- package/src/types.ts +14 -0
package/package.json
CHANGED
package/src/fields.tsx
CHANGED
|
@@ -8,7 +8,7 @@ import Highlight from "@tiptap/extension-highlight";
|
|
|
8
8
|
import TaskItem from "@tiptap/extension-task-item";
|
|
9
9
|
import TaskList from "@tiptap/extension-task-list";
|
|
10
10
|
import StarterKit from "@tiptap/starter-kit";
|
|
11
|
-
import { useCallback, useEffect, useRef, useState, type DragEvent, type ReactNode } from "react";
|
|
11
|
+
import { useCallback, useEffect, useId, useRef, useState, type DragEvent, type ReactNode } from "react";
|
|
12
12
|
import type { Api } from "./api";
|
|
13
13
|
import { isRichTextDoc, richTextToPlainText } from "./rich-text";
|
|
14
14
|
import type { FieldDefinition, FieldValue, FieldValues, Media, ReferenceOption, ReferenceResult, RichTextDoc } from "./types";
|
|
@@ -17,8 +17,33 @@ import type { FieldDefinition, FieldValue, FieldValues, Media, ReferenceOption,
|
|
|
17
17
|
// don't map cleanly onto a podoba primitive (number/date/select/file).
|
|
18
18
|
export const CONTROL = "h-10 w-full rounded-lg border border-border bg-surface-card px-4 text-sm text-fg outline-none transition-colors placeholder:text-fg-muted focus:border-brand-green";
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
/**
|
|
21
|
+
* A field's help text — `FieldDefinition.description`, rendered under the control.
|
|
22
|
+
*
|
|
23
|
+
* Styled to match what podoba's `Input`/`Textarea` emit for their own `description` prop
|
|
24
|
+
* (`<Text slot="description">`), because those two render theirs and everything else renders
|
|
25
|
+
* this one: two helper texts on one form that don't look alike read as two different kinds
|
|
26
|
+
* of thing.
|
|
27
|
+
*/
|
|
28
|
+
function FieldHint({ id, children }: { id: string; children: ReactNode }) {
|
|
21
29
|
return (
|
|
30
|
+
<p id={id} className="text-label text-fg-muted">
|
|
31
|
+
{children}
|
|
32
|
+
</p>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* `description` is rendered OUTSIDE the `<label>`, deliberately.
|
|
38
|
+
*
|
|
39
|
+
* A `<label>` wrapping a control contributes ALL its text to that control's accessible name,
|
|
40
|
+
* so a hint nested inside would be read out as part of the field's name — "Adresa Použije se,
|
|
41
|
+
* jen když akce nemá přiřazená sportoviště" instead of "Adresa". The hint belongs in
|
|
42
|
+
* `aria-describedby`, which is a separate announcement the user can skip; hence the id, which
|
|
43
|
+
* the caller puts on the control itself.
|
|
44
|
+
*/
|
|
45
|
+
function FieldShell({ label, description, descriptionId, children }: { label: ReactNode; description?: string; descriptionId?: string; children: ReactNode }) {
|
|
46
|
+
const field = (
|
|
22
47
|
<label className="flex w-full flex-col gap-2">
|
|
23
48
|
{/* No label element at all when there is no label — an empty one still occupies a
|
|
24
49
|
row and, with a required marker, showed a stray asterisk above the control. */}
|
|
@@ -26,6 +51,13 @@ function FieldShell({ label, children }: { label: ReactNode; children: ReactNode
|
|
|
26
51
|
{children}
|
|
27
52
|
</label>
|
|
28
53
|
);
|
|
54
|
+
if (!description || !descriptionId) return field;
|
|
55
|
+
return (
|
|
56
|
+
<div className="flex w-full flex-col gap-2">
|
|
57
|
+
{field}
|
|
58
|
+
<FieldHint id={descriptionId}>{description}</FieldHint>
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
29
61
|
}
|
|
30
62
|
|
|
31
63
|
/**
|
|
@@ -183,6 +215,19 @@ function FieldInput({ def, value, onChange, api, hideLabelAs, siblings }: { def:
|
|
|
183
215
|
{def.label ?? def.name} {def.required ? <span className="text-danger">*</span> : null}
|
|
184
216
|
</>
|
|
185
217
|
);
|
|
218
|
+
// A label names the field; a description says what it MEANS — which of two plausible
|
|
219
|
+
// readings is the right one, when it applies, what leaving it empty does. Without
|
|
220
|
+
// somewhere to put that, the only place it can live is a comment in the app's source,
|
|
221
|
+
// where the person filling the field in will never see it.
|
|
222
|
+
//
|
|
223
|
+
// Suppressed under `hideLabelAs` (a single-field repeater row) for the same reason the
|
|
224
|
+
// label is: the list already carries both, once, and repeating a sentence on every row
|
|
225
|
+
// buries the rows.
|
|
226
|
+
const hint = hideLabelAs !== undefined ? undefined : def.description?.trim() || undefined;
|
|
227
|
+
// Stable per field instance, so the control can point `aria-describedby` at the hint.
|
|
228
|
+
// `useId` is called unconditionally — it is a hook, and the switch below returns early.
|
|
229
|
+
const hintId = useId();
|
|
230
|
+
const describedBy = hint ? hintId : undefined;
|
|
186
231
|
const asText = (v: FieldValue) => (typeof v === "string" ? v : v == null ? "" : JSON.stringify(v));
|
|
187
232
|
switch (def.type) {
|
|
188
233
|
case "text":
|
|
@@ -191,34 +236,36 @@ function FieldInput({ def, value, onChange, api, hideLabelAs, siblings }: { def:
|
|
|
191
236
|
return hideLabelAs !== undefined ? (
|
|
192
237
|
<input className={CONTROL} type="text" aria-label={hideLabelAs} placeholder={placeholder} value={(value as string) ?? ""} onChange={(e) => onChange(e.target.value)} />
|
|
193
238
|
) : (
|
|
194
|
-
|
|
239
|
+
// podoba's own `description` prop, not our `FieldHint`: `Input` is a React Aria
|
|
240
|
+
// `TextField`, which associates label, description and error with the control for us.
|
|
241
|
+
<Input label={label} description={hint} value={(value as string) ?? ""} onChange={onChange} placeholder={placeholder} />
|
|
195
242
|
);
|
|
196
243
|
}
|
|
197
244
|
case "textarea":
|
|
198
245
|
return hideLabelAs !== undefined ? (
|
|
199
246
|
<textarea className={`${CONTROL} h-auto min-h-20 py-2.5`} aria-label={hideLabelAs} value={asText(value)} onChange={(e) => onChange(e.target.value)} />
|
|
200
247
|
) : (
|
|
201
|
-
<Textarea label={label} value={asText(value)} onChange={onChange} />
|
|
248
|
+
<Textarea label={label} description={hint} value={asText(value)} onChange={onChange} />
|
|
202
249
|
);
|
|
203
250
|
case "richtext":
|
|
204
251
|
// A rich-text value is a document tree. A legacy HTML string still opens (it seeds
|
|
205
252
|
// the editor as-is) and is upgraded to a doc by the first save.
|
|
206
253
|
return (
|
|
207
|
-
<FieldShell label={label}>
|
|
254
|
+
<FieldShell label={label} description={hint} descriptionId={hintId}>
|
|
208
255
|
<RichText value={value as RichTextDoc | string | null} onChange={onChange as (v: RichTextDoc) => void} />
|
|
209
256
|
</FieldShell>
|
|
210
257
|
);
|
|
211
258
|
case "number":
|
|
212
259
|
return (
|
|
213
|
-
<FieldShell label={label}>
|
|
214
|
-
<input className={CONTROL} type="number" aria-label={hideLabelAs} value={value == null ? "" : String(value)} onChange={(e) => onChange(e.target.value === "" ? null : Number(e.target.value))} />
|
|
260
|
+
<FieldShell label={label} description={hint} descriptionId={hintId}>
|
|
261
|
+
<input className={CONTROL} type="number" aria-label={hideLabelAs} aria-describedby={describedBy} value={value == null ? "" : String(value)} onChange={(e) => onChange(e.target.value === "" ? null : Number(e.target.value))} />
|
|
215
262
|
</FieldShell>
|
|
216
263
|
);
|
|
217
264
|
case "date":
|
|
218
265
|
case "datetime":
|
|
219
266
|
return (
|
|
220
|
-
<FieldShell label={label}>
|
|
221
|
-
<input className={CONTROL} type={def.type === "date" ? "date" : "datetime-local"} aria-label={hideLabelAs} value={(value as string) ?? ""} onChange={(e) => onChange(e.target.value || null)} />
|
|
267
|
+
<FieldShell label={label} description={hint} descriptionId={hintId}>
|
|
268
|
+
<input className={CONTROL} type={def.type === "date" ? "date" : "datetime-local"} aria-label={hideLabelAs} aria-describedby={describedBy} value={(value as string) ?? ""} onChange={(e) => onChange(e.target.value || null)} />
|
|
222
269
|
</FieldShell>
|
|
223
270
|
);
|
|
224
271
|
case "slug":
|
|
@@ -226,6 +273,8 @@ function FieldInput({ def, value, onChange, api, hideLabelAs, siblings }: { def:
|
|
|
226
273
|
<SlugField
|
|
227
274
|
def={def}
|
|
228
275
|
label={label}
|
|
276
|
+
description={hint}
|
|
277
|
+
descriptionId={hintId}
|
|
229
278
|
value={typeof value === "string" ? value : ""}
|
|
230
279
|
source={typeof siblings?.[def.from ?? ""] === "string" ? (siblings[def.from ?? ""] as string) : ""}
|
|
231
280
|
onChange={onChange as (v: string) => void}
|
|
@@ -239,24 +288,30 @@ function FieldInput({ def, value, onChange, api, hideLabelAs, siblings }: { def:
|
|
|
239
288
|
<div className="flex w-full flex-col gap-2">
|
|
240
289
|
<span className="text-sm font-medium text-fg">{label}</span>
|
|
241
290
|
<PublishControl value={typeof value === "string" ? value : ""} onChange={onChange as (v: string | null) => void} />
|
|
291
|
+
{hint ? <FieldHint id={hintId}>{hint}</FieldHint> : null}
|
|
242
292
|
</div>
|
|
243
293
|
);
|
|
244
294
|
case "boolean":
|
|
245
295
|
return (
|
|
246
|
-
<
|
|
247
|
-
<
|
|
248
|
-
|
|
249
|
-
|
|
296
|
+
<div className="flex w-full flex-col gap-1">
|
|
297
|
+
<label className="flex items-center gap-2">
|
|
298
|
+
<input type="checkbox" aria-describedby={describedBy} checked={Boolean(value)} onChange={(e) => onChange(e.target.checked)} />
|
|
299
|
+
<span className="text-sm text-fg">{def.label ?? def.name}</span>
|
|
300
|
+
</label>
|
|
301
|
+
{/* Outside the <label>, like everywhere else — inside, it would be read out as
|
|
302
|
+
part of the checkbox's name. */}
|
|
303
|
+
{hint ? <FieldHint id={hintId}>{hint}</FieldHint> : null}
|
|
304
|
+
</div>
|
|
250
305
|
);
|
|
251
306
|
case "select":
|
|
252
307
|
return (
|
|
253
|
-
<FieldShell label={label}>
|
|
254
|
-
<SelectField def={def} value={value as string | null} onChange={onChange} api={api} ariaLabel={hideLabelAs} />
|
|
308
|
+
<FieldShell label={label} description={hint} descriptionId={hintId}>
|
|
309
|
+
<SelectField def={def} value={value as string | null} onChange={onChange} api={api} ariaLabel={hideLabelAs} describedBy={describedBy} />
|
|
255
310
|
</FieldShell>
|
|
256
311
|
);
|
|
257
312
|
case "media":
|
|
258
313
|
return (
|
|
259
|
-
<FieldShell label={label}>
|
|
314
|
+
<FieldShell label={label} description={hint} descriptionId={hintId}>
|
|
260
315
|
<MediaField value={value as string | null} onChange={onChange} api={api} />
|
|
261
316
|
</FieldShell>
|
|
262
317
|
);
|
|
@@ -268,18 +323,19 @@ function FieldInput({ def, value, onChange, api, hideLabelAs, siblings }: { def:
|
|
|
268
323
|
<div className="flex w-full flex-col gap-2">
|
|
269
324
|
{label === undefined ? null : <span className="text-sm font-medium text-fg">{label}</span>}
|
|
270
325
|
<ReferenceField def={def} value={value} onChange={onChange} api={api} ariaLabel={hideLabelAs} />
|
|
326
|
+
{hint ? <FieldHint id={hintId}>{hint}</FieldHint> : null}
|
|
271
327
|
</div>
|
|
272
328
|
);
|
|
273
329
|
case "group":
|
|
274
330
|
return (
|
|
275
|
-
<FieldShell label={label}>
|
|
331
|
+
<FieldShell label={label} description={hint} descriptionId={hintId}>
|
|
276
332
|
<div className="rounded-lg border border-border bg-surface-muted p-3.5">
|
|
277
333
|
<FieldForm schema={def.fields ?? []} value={(value as FieldValues) ?? {}} onChange={onChange as (v: FieldValues) => void} api={api} />
|
|
278
334
|
</div>
|
|
279
335
|
</FieldShell>
|
|
280
336
|
);
|
|
281
337
|
case "repeater":
|
|
282
|
-
return <Repeater def={def} value={(value as FieldValues[]) ?? []} onChange={onChange as (v: FieldValues[]) => void} api={api} label={label} />;
|
|
338
|
+
return <Repeater def={def} value={(value as FieldValues[]) ?? []} onChange={onChange as (v: FieldValues[]) => void} api={api} label={label} description={hint} descriptionId={hintId} />;
|
|
283
339
|
default:
|
|
284
340
|
return null;
|
|
285
341
|
}
|
|
@@ -385,7 +441,7 @@ export function RichText({ value, onChange }: { value: RichTextDoc | string | nu
|
|
|
385
441
|
* Reordering is drag-and-drop from the ⠿ handle, mirroring the block canvas. ↑/↓ stay,
|
|
386
442
|
* because dragging is unavailable to keyboard users and awkward on touch.
|
|
387
443
|
*/
|
|
388
|
-
function Repeater({ def, value, onChange, api, label }: { def: FieldDefinition; value: FieldValues[]; onChange: (v: FieldValues[]) => void; api: Api; label: ReactNode }) {
|
|
444
|
+
function Repeater({ def, value, onChange, api, label, description, descriptionId }: { def: FieldDefinition; value: FieldValues[]; onChange: (v: FieldValues[]) => void; api: Api; label: ReactNode; description?: string; descriptionId?: string }) {
|
|
389
445
|
const items = Array.isArray(value) ? value : [];
|
|
390
446
|
const fields = def.fields ?? [];
|
|
391
447
|
// One field, and not itself a tall control — the case where the card is pure overhead.
|
|
@@ -491,6 +547,9 @@ function Repeater({ def, value, onChange, api, label }: { def: FieldDefinition;
|
|
|
491
547
|
return (
|
|
492
548
|
<div className="flex flex-col gap-2">
|
|
493
549
|
<span className="text-sm font-medium text-fg">{label}</span>
|
|
550
|
+
{/* Above the rows, not under them: a repeater grows, and a note about what the list is
|
|
551
|
+
for is only useful before you start adding to it. */}
|
|
552
|
+
{description && descriptionId ? <FieldHint id={descriptionId}>{description}</FieldHint> : null}
|
|
494
553
|
|
|
495
554
|
{items.length === 0 ? <span className="text-sm text-fg-muted">None yet.</span> : null}
|
|
496
555
|
|
|
@@ -579,7 +638,7 @@ function slugifyInput(input: string): string {
|
|
|
579
638
|
* not silently change its URL and break every link to it; "Generate from …" is there for
|
|
580
639
|
* when that IS what you want.
|
|
581
640
|
*/
|
|
582
|
-
function SlugField({ def, label, value, source, onChange }: { def: FieldDefinition; label: ReactNode; value: string; source: string; onChange: (v: string) => void }) {
|
|
641
|
+
function SlugField({ def, label, description, descriptionId, value, source, onChange }: { def: FieldDefinition; label: ReactNode; description?: string; descriptionId?: string; value: string; source: string; onChange: (v: string) => void }) {
|
|
583
642
|
// What this control last wrote. While the field still holds it, the field is "untouched"
|
|
584
643
|
// and free to follow; anything else means a human typed it.
|
|
585
644
|
const derived = useRef<string | null>(null);
|
|
@@ -611,9 +670,10 @@ function SlugField({ def, label, value, source, onChange }: { def: FieldDefiniti
|
|
|
611
670
|
const canGenerate = Boolean(suggestion) && suggestion !== value;
|
|
612
671
|
|
|
613
672
|
return (
|
|
614
|
-
<FieldShell label={label}>
|
|
673
|
+
<FieldShell label={label} description={description} descriptionId={descriptionId}>
|
|
615
674
|
<input
|
|
616
675
|
className={CONTROL}
|
|
676
|
+
aria-describedby={description ? descriptionId : undefined}
|
|
617
677
|
value={value}
|
|
618
678
|
onChange={(e) => {
|
|
619
679
|
touched.current = true; // typed by hand — stop following from here on
|
|
@@ -642,7 +702,7 @@ function SlugField({ def, label, value, source, onChange }: { def: FieldDefiniti
|
|
|
642
702
|
// A `select` field. Static `options` render as-is; when `optionsFrom` is set, the options are
|
|
643
703
|
// fetched once from that query handler (returns `{ value, label }[]`) — e.g. a live list of
|
|
644
704
|
// campaigns — so the editor never has to hardcode or copy identifiers by hand.
|
|
645
|
-
function SelectField({ def, value, onChange, api, ariaLabel }: { def: FieldDefinition; value: string | null; onChange: (v: FieldValue) => void; api: Api; ariaLabel?: string }) {
|
|
705
|
+
function SelectField({ def, value, onChange, api, ariaLabel, describedBy }: { def: FieldDefinition; value: string | null; onChange: (v: FieldValue) => void; api: Api; ariaLabel?: string; describedBy?: string }) {
|
|
646
706
|
const [dyn, setDyn] = useState<{ value: string; label: string }[] | null>(null);
|
|
647
707
|
const from = def.optionsFrom;
|
|
648
708
|
useEffect(() => {
|
|
@@ -657,7 +717,7 @@ function SelectField({ def, value, onChange, api, ariaLabel }: { def: FieldDefin
|
|
|
657
717
|
const loading = Boolean(from) && dyn === null;
|
|
658
718
|
const opts = from ? dyn ?? [] : (def.options ?? []).map((o) => ({ value: o, label: o }));
|
|
659
719
|
return (
|
|
660
|
-
<select className={CONTROL} aria-label={ariaLabel} value={value ?? ""} onChange={(e) => onChange(e.target.value || null)}>
|
|
720
|
+
<select className={CONTROL} aria-label={ariaLabel} aria-describedby={describedBy} value={value ?? ""} onChange={(e) => onChange(e.target.value || null)}>
|
|
661
721
|
<option value="">{loading ? "Načítám…" : "—"}</option>
|
|
662
722
|
{opts.map((o) => (
|
|
663
723
|
<option key={o.value} value={o.value}>{o.label}</option>
|
package/src/schema-builder.tsx
CHANGED
|
@@ -191,6 +191,19 @@ function FieldRow({ def, siblings, siblingFields, index, count, depth, onChange,
|
|
|
191
191
|
{duplicate ? <p className="text-caption text-danger">Two fields here are called “{def.name}” — they would write the same key, and one could never be saved.</p> : null}
|
|
192
192
|
{badName ? <p className="text-caption text-danger">A name must start with a letter or underscore and hold only letters, digits and underscores.</p> : null}
|
|
193
193
|
|
|
194
|
+
{/* Full width, and under the three-up row: this is the one input here that takes a
|
|
195
|
+
sentence rather than a word, and it is the only place a field's meaning can be
|
|
196
|
+
written down where the person filling it in will read it. */}
|
|
197
|
+
<label className="flex flex-col gap-1.5">
|
|
198
|
+
<span className="text-caption text-fg-subtle">Help text (optional)</span>
|
|
199
|
+
<input
|
|
200
|
+
className={CONTROL}
|
|
201
|
+
value={def.description ?? ""}
|
|
202
|
+
placeholder="What this field means, when it applies, what empty does"
|
|
203
|
+
onChange={(e) => patch({ description: e.target.value || undefined })}
|
|
204
|
+
/>
|
|
205
|
+
</label>
|
|
206
|
+
|
|
194
207
|
<label className="flex items-center gap-2">
|
|
195
208
|
<input type="checkbox" checked={def.required === true} onChange={(e) => patch({ required: e.target.checked || undefined })} />
|
|
196
209
|
<span className="text-sm text-fg">Required</span>
|
package/src/types.ts
CHANGED
|
@@ -68,6 +68,20 @@ export interface FieldDefinition {
|
|
|
68
68
|
name: string;
|
|
69
69
|
label?: string;
|
|
70
70
|
type: FieldType;
|
|
71
|
+
/**
|
|
72
|
+
* Help text under the control — what the field MEANS, when it applies, what leaving it
|
|
73
|
+
* empty does. Optional, and worth writing exactly when the label alone leaves a real
|
|
74
|
+
* question open: an "Address" that is only used for an event with no venue attached, a
|
|
75
|
+
* "Time to" that applies to every day of a range rather than the last one.
|
|
76
|
+
*
|
|
77
|
+
* The alternative is a comment beside the field's declaration in the app's source, where
|
|
78
|
+
* the person filling the field in will never see it — which is where this kind of note
|
|
79
|
+
* had nowhere else to go before.
|
|
80
|
+
*
|
|
81
|
+
* One or two sentences. It is announced via `aria-describedby`, so a paragraph here is a
|
|
82
|
+
* paragraph a screen-reader user hears before every edit.
|
|
83
|
+
*/
|
|
84
|
+
description?: string;
|
|
71
85
|
required?: boolean;
|
|
72
86
|
default?: FieldValue;
|
|
73
87
|
fields?: FieldDefinition[];
|