@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pramen/cms-editor",
3
- "version": "0.0.65",
3
+ "version": "0.0.66",
4
4
  "description": "Visual block/page editor for @pramen/cms — a standalone React SPA that talks to the CMS handlers over HTTP.",
5
5
  "license": "MIT",
6
6
  "repository": {
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
- function FieldShell({ label, children }: { label: ReactNode; children: ReactNode }) {
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
- <Input label={label} value={(value as string) ?? ""} onChange={onChange} placeholder={placeholder} />
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
- <label className="flex items-center gap-2">
247
- <input type="checkbox" checked={Boolean(value)} onChange={(e) => onChange(e.target.checked)} />
248
- <span className="text-sm text-fg">{def.label ?? def.name}</span>
249
- </label>
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>
@@ -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[];