@vespercms/admin 0.0.0-stage → 0.1.0-next.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.
Files changed (203) hide show
  1. package/README.md +119 -2
  2. package/dist/BodyEditor-CAGNfofv.js +3047 -0
  3. package/dist/a11y/FieldNotes.d.ts +23 -0
  4. package/dist/a11y/NotesDrawer.d.ts +23 -0
  5. package/dist/a11y/pdf.d.ts +3 -0
  6. package/dist/a11y/run.d.ts +3 -0
  7. package/dist/a11y/useNotes.d.ts +28 -0
  8. package/dist/app/App.d.ts +2 -0
  9. package/dist/app/Screen.d.ts +88 -0
  10. package/dist/app/UserMenu.d.ts +7 -0
  11. package/dist/app/api.d.ts +75 -0
  12. package/dist/app/appearance.d.ts +10 -0
  13. package/dist/app/browser-api.d.ts +17 -0
  14. package/dist/app/collection-icon.d.ts +12 -0
  15. package/dist/app/context.d.ts +23 -0
  16. package/dist/app/entries-changed.d.ts +11 -0
  17. package/dist/app/file-url.d.ts +13 -0
  18. package/dist/app/format.d.ts +4 -0
  19. package/dist/app/idb.d.ts +7 -0
  20. package/dist/app/load.d.ts +16 -0
  21. package/dist/app/media.d.ts +5 -0
  22. package/dist/app/navigation.d.ts +4 -0
  23. package/dist/app/pane-context.d.ts +19 -0
  24. package/dist/app/panes.d.ts +14 -0
  25. package/dist/app/router.d.ts +62 -0
  26. package/dist/body/BodyEditor.d.ts +42 -0
  27. package/dist/body/TableView.d.ts +2 -0
  28. package/dist/body/catalog.d.ts +47 -0
  29. package/dist/body/checks.d.ts +15 -0
  30. package/dist/body/chips.d.ts +31 -0
  31. package/dist/body/commands.d.ts +31 -0
  32. package/dist/body/document.d.ts +42 -0
  33. package/dist/body/insert.d.ts +69 -0
  34. package/dist/body/nodes.d.ts +38 -0
  35. package/dist/body/reveal.d.ts +13 -0
  36. package/dist/body/scroll.d.ts +16 -0
  37. package/dist/body/settings.d.ts +19 -0
  38. package/dist/body/shortcuts.d.ts +13 -0
  39. package/dist/body/table.d.ts +31 -0
  40. package/dist/body/ui.d.ts +30 -0
  41. package/dist/body/views.d.ts +9 -0
  42. package/dist/checks-C-0xDu-I.js +57 -0
  43. package/dist/cloud/CloudGate.d.ts +14 -0
  44. package/dist/cloud/access.d.ts +14 -0
  45. package/dist/cloud/changes.d.ts +26 -0
  46. package/dist/cloud/connection.d.ts +100 -0
  47. package/dist/cloud/editor.d.ts +44 -0
  48. package/dist/cloud/session.d.ts +62 -0
  49. package/dist/cloud/storage.d.ts +45 -0
  50. package/dist/editor/Field.d.ts +24 -0
  51. package/dist/editor/PagePicker.d.ts +20 -0
  52. package/dist/editor/arrange.d.ts +13 -0
  53. package/dist/editor/context.d.ts +23 -0
  54. package/dist/editor/copy.d.ts +9 -0
  55. package/dist/editor/reveal.d.ts +7 -0
  56. package/dist/editor/target.d.ts +21 -0
  57. package/dist/editor/values.d.ts +18 -0
  58. package/dist/geo-map-CT3OGD7O.js +110 -0
  59. package/dist/github/GitHubGate.d.ts +15 -0
  60. package/dist/github/api.d.ts +20 -0
  61. package/dist/github/cache.d.ts +2 -0
  62. package/dist/github/session.d.ts +35 -0
  63. package/dist/index.d.ts +3 -0
  64. package/dist/markdown-KUkv11EB.js +211 -0
  65. package/dist/media/MediaPicker.d.ts +28 -0
  66. package/dist/media/file-icon.d.ts +3 -0
  67. package/dist/media/formats.d.ts +23 -0
  68. package/dist/media/hygiene.d.ts +37 -0
  69. package/dist/media/upload.d.ts +31 -0
  70. package/dist/mount.d.ts +3 -0
  71. package/dist/pdf-m5s_uXJ3.js +24 -0
  72. package/dist/run-y4XRkg17.js +7 -0
  73. package/dist/schedule/ScheduleDialog.d.ts +28 -0
  74. package/dist/schedule/ScheduleSummary.d.ts +12 -0
  75. package/dist/schedule/ScheduledView.d.ts +8 -0
  76. package/dist/schedule/cloud.d.ts +21 -0
  77. package/dist/schedule/now.d.ts +5 -0
  78. package/dist/schedule/times.d.ts +37 -0
  79. package/dist/screens/EntryEditor.d.ts +13 -0
  80. package/dist/screens/EntryList.d.ts +18 -0
  81. package/dist/screens/Home.d.ts +6 -0
  82. package/dist/screens/ListPane.d.ts +6 -0
  83. package/dist/screens/MediaBrowser.d.ts +7 -0
  84. package/dist/screens/NotFound.d.ts +3 -0
  85. package/dist/screens/PageTree.d.ts +10 -0
  86. package/dist/screens/Redirects.d.ts +1 -0
  87. package/dist/screens/Unavailable.d.ts +8 -0
  88. package/dist/shortcuts-B_JMAvF9.js +4630 -0
  89. package/dist/studio.css +3 -0
  90. package/dist/studio.js +6312 -0
  91. package/dist/types.d.ts +76 -0
  92. package/dist/widgets/basic.d.ts +25 -0
  93. package/dist/widgets/crops.d.ts +43 -0
  94. package/dist/widgets/focal-point.d.ts +15 -0
  95. package/dist/widgets/geo-map.d.ts +10 -0
  96. package/dist/widgets/geo.d.ts +16 -0
  97. package/dist/widgets/group.d.ts +41 -0
  98. package/dist/widgets/icon.d.ts +9 -0
  99. package/dist/widgets/image.d.ts +16 -0
  100. package/dist/widgets/markdown-lazy.d.ts +2 -0
  101. package/dist/widgets/markdown.d.ts +2 -0
  102. package/dist/widgets/reference.d.ts +12 -0
  103. package/dist/widgets/registry.d.ts +32 -0
  104. package/dist/widgets/special.d.ts +20 -0
  105. package/dist/widgets/union.d.ts +36 -0
  106. package/package.json +63 -4
  107. package/src/a11y/FieldNotes.tsx +56 -0
  108. package/src/a11y/NotesDrawer.tsx +81 -0
  109. package/src/a11y/pdf.ts +45 -0
  110. package/src/a11y/run.ts +12 -0
  111. package/src/a11y/useNotes.ts +139 -0
  112. package/src/app/App.tsx +156 -0
  113. package/src/app/Screen.tsx +416 -0
  114. package/src/app/UserMenu.tsx +95 -0
  115. package/src/app/api.ts +176 -0
  116. package/src/app/appearance.ts +54 -0
  117. package/src/app/browser-api.ts +173 -0
  118. package/src/app/collection-icon.ts +15 -0
  119. package/src/app/context.tsx +32 -0
  120. package/src/app/entries-changed.ts +48 -0
  121. package/src/app/file-url.tsx +44 -0
  122. package/src/app/format.ts +39 -0
  123. package/src/app/idb.ts +82 -0
  124. package/src/app/load.ts +40 -0
  125. package/src/app/media.ts +20 -0
  126. package/src/app/navigation.ts +4 -0
  127. package/src/app/pane-context.ts +25 -0
  128. package/src/app/panes.tsx +513 -0
  129. package/src/app/router.ts +158 -0
  130. package/src/body/BodyEditor.tsx +1139 -0
  131. package/src/body/TableView.tsx +700 -0
  132. package/src/body/catalog.ts +198 -0
  133. package/src/body/checks.ts +83 -0
  134. package/src/body/chips.ts +150 -0
  135. package/src/body/commands.ts +191 -0
  136. package/src/body/document.ts +388 -0
  137. package/src/body/insert.tsx +301 -0
  138. package/src/body/nodes.ts +287 -0
  139. package/src/body/reveal.ts +93 -0
  140. package/src/body/scroll.ts +53 -0
  141. package/src/body/settings.tsx +510 -0
  142. package/src/body/shortcuts.ts +31 -0
  143. package/src/body/table.ts +192 -0
  144. package/src/body/ui.ts +77 -0
  145. package/src/body/views.tsx +268 -0
  146. package/src/build.ts +6 -0
  147. package/src/cloud/CloudGate.tsx +245 -0
  148. package/src/cloud/access.ts +52 -0
  149. package/src/cloud/changes.tsx +474 -0
  150. package/src/cloud/connection.ts +281 -0
  151. package/src/cloud/editor.tsx +250 -0
  152. package/src/cloud/session.ts +289 -0
  153. package/src/cloud/storage.ts +212 -0
  154. package/src/editor/Field.tsx +182 -0
  155. package/src/editor/PagePicker.tsx +102 -0
  156. package/src/editor/arrange.ts +98 -0
  157. package/src/editor/context.tsx +31 -0
  158. package/src/editor/copy.ts +30 -0
  159. package/src/editor/reveal.ts +38 -0
  160. package/src/editor/target.ts +104 -0
  161. package/src/editor/values.ts +71 -0
  162. package/src/github/GitHubGate.tsx +261 -0
  163. package/src/github/api.ts +81 -0
  164. package/src/github/cache.ts +64 -0
  165. package/src/github/session.ts +146 -0
  166. package/src/index.ts +10 -0
  167. package/src/media/MediaPicker.tsx +302 -0
  168. package/src/media/file-icon.ts +17 -0
  169. package/src/media/formats.ts +262 -0
  170. package/src/media/hygiene.ts +220 -0
  171. package/src/media/upload.ts +47 -0
  172. package/src/mount.tsx +20 -0
  173. package/src/schedule/ScheduleDialog.tsx +300 -0
  174. package/src/schedule/ScheduleSummary.tsx +59 -0
  175. package/src/schedule/ScheduledView.tsx +294 -0
  176. package/src/schedule/cloud.ts +66 -0
  177. package/src/schedule/now.ts +18 -0
  178. package/src/schedule/times.ts +133 -0
  179. package/src/screens/EntryEditor.tsx +1803 -0
  180. package/src/screens/EntryList.tsx +274 -0
  181. package/src/screens/Home.tsx +131 -0
  182. package/src/screens/ListPane.tsx +459 -0
  183. package/src/screens/MediaBrowser.tsx +253 -0
  184. package/src/screens/NotFound.tsx +19 -0
  185. package/src/screens/PageTree.tsx +718 -0
  186. package/src/screens/Redirects.tsx +522 -0
  187. package/src/screens/Unavailable.tsx +27 -0
  188. package/src/studio.css +163 -0
  189. package/src/types.ts +82 -0
  190. package/src/widgets/basic.tsx +311 -0
  191. package/src/widgets/crops.tsx +399 -0
  192. package/src/widgets/focal-point.tsx +240 -0
  193. package/src/widgets/geo-map.tsx +160 -0
  194. package/src/widgets/geo.tsx +170 -0
  195. package/src/widgets/group.tsx +827 -0
  196. package/src/widgets/icon.tsx +47 -0
  197. package/src/widgets/image.tsx +407 -0
  198. package/src/widgets/markdown-lazy.tsx +22 -0
  199. package/src/widgets/markdown.tsx +267 -0
  200. package/src/widgets/reference.tsx +196 -0
  201. package/src/widgets/registry.ts +104 -0
  202. package/src/widgets/special.tsx +286 -0
  203. package/src/widgets/union.tsx +233 -0
@@ -0,0 +1,286 @@
1
+ import {
2
+ fromWallTime,
3
+ isoWithOffset,
4
+ offsetOf,
5
+ resolveTimeZone,
6
+ slugify,
7
+ wallTime,
8
+ zoneName,
9
+ } from '@vespercms/core';
10
+ import { RadioGroup, cx } from '@vespercms/ui';
11
+ import { useEffect, useState } from 'react';
12
+ import { useStudio } from '../app/context.tsx';
13
+ import { useEditor } from '../editor/context.tsx';
14
+ import { ChoosePageButton } from '../editor/PagePicker.tsx';
15
+ import type { WidgetProps } from '../types.ts';
16
+ import { inputClass } from './basic.tsx';
17
+
18
+ /*
19
+ * The built-in widgets with behaviour of their own (spec §6.3.2). Every one
20
+ * keeps a plain native or ui-kit control as its primary, accessible control.
21
+ */
22
+
23
+ const HEX6 = /^#[0-9a-f]{6}$/i;
24
+
25
+ function describedByWith(describedBy: string | undefined, ...ids: (string | false)[]): string {
26
+ return [describedBy, ...ids].filter(Boolean).join(' ');
27
+ }
28
+
29
+ /** A small square showing a colour, with a hairline so pale colours stay visible. */
30
+ function Swatch({ color }: { color: string }) {
31
+ return (
32
+ <span
33
+ className="inline-block size-6 shrink-0 rounded-sm border border-border-strong"
34
+ style={{ backgroundColor: color }}
35
+ />
36
+ );
37
+ }
38
+
39
+ /**
40
+ * Colour (spec §6.3.2). The hex text field is the primary control; the
41
+ * native picker is a convenience beside it. With a palette, it's a radio
42
+ * group of named swatches instead, each named in words.
43
+ */
44
+ export function ColorWidget({
45
+ value,
46
+ onChange,
47
+ field,
48
+ id,
49
+ describedBy,
50
+ invalid,
51
+ disabled,
52
+ }: WidgetProps<string>) {
53
+ const palette = field.widgetOptions?.palette as Record<string, string> | undefined;
54
+ const current = typeof value === 'string' ? value : '';
55
+
56
+ if (palette && Object.keys(palette).length > 0) {
57
+ const options = Object.entries(palette).map(([name, hex]) => ({
58
+ value: hex,
59
+ label: `${name} (${hex})`,
60
+ prefix: <Swatch color={hex} />,
61
+ }));
62
+ if (current && !options.some((o) => o.value.toLowerCase() === current.toLowerCase())) {
63
+ options.push({
64
+ value: current,
65
+ label: `Current colour (${current})`,
66
+ prefix: <Swatch color={current} />,
67
+ });
68
+ }
69
+ return (
70
+ <RadioGroup
71
+ id={id}
72
+ aria-labelledby={`${id}-label`}
73
+ aria-describedby={describedBy}
74
+ options={options}
75
+ value={options.find((o) => o.value.toLowerCase() === current.toLowerCase())?.value ?? null}
76
+ onChange={onChange}
77
+ isInvalid={invalid}
78
+ isDisabled={disabled}
79
+ />
80
+ );
81
+ }
82
+
83
+ return (
84
+ <div className="flex items-center gap-3">
85
+ <input
86
+ id={id}
87
+ type="text"
88
+ value={current}
89
+ placeholder="#1a73e8"
90
+ spellCheck={false}
91
+ autoComplete="off"
92
+ onChange={(event) => {
93
+ onChange(event.target.value === '' && !field.required ? undefined : event.target.value);
94
+ }}
95
+ aria-describedby={describedBy}
96
+ aria-invalid={invalid || undefined}
97
+ aria-required={field.required || undefined}
98
+ disabled={disabled}
99
+ className={cx(inputClass, 'max-w-40 font-mono')}
100
+ />
101
+ {/* The picker can't show alpha, so 8-digit colours are text only. */}
102
+ {field.widgetOptions?.alpha !== true && (
103
+ <input
104
+ type="color"
105
+ aria-label={`Pick ${field.label.toLowerCase()} from a colour picker`}
106
+ value={HEX6.test(current) ? current.toLowerCase() : '#000000'}
107
+ onChange={(event) => {
108
+ onChange(event.target.value);
109
+ }}
110
+ disabled={disabled}
111
+ className="size-10 cursor-pointer rounded-md border border-border-strong bg-surface p-1 focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-focus focus-visible:ring-6 focus-visible:ring-(color:--glow-focus)"
112
+ />
113
+ )}
114
+ </div>
115
+ );
116
+ }
117
+
118
+ /**
119
+ * Slug (spec §6.3.2): follows its source field (usually the title) until
120
+ * someone edits it by hand. It follows only on entries that haven't been
121
+ * saved yet, so a published page's URL never changes behind anyone's back.
122
+ */
123
+ export function SlugWidget({
124
+ value,
125
+ onChange,
126
+ field,
127
+ id,
128
+ describedBy,
129
+ invalid,
130
+ disabled,
131
+ }: WidgetProps<string>) {
132
+ const editor = useEditor();
133
+ const from = typeof field.widgetOptions?.from === 'string' ? field.widgetOptions.from : undefined;
134
+ const source = from ? editor.values[from] : undefined;
135
+ const sourceText = typeof source === 'string' ? source : '';
136
+ const current = typeof value === 'string' ? value : '';
137
+ const [following, setFollowing] = useState(
138
+ () => editor.isNew && from !== undefined && (current === '' || current === toSlug(sourceText)),
139
+ );
140
+
141
+ useEffect(() => {
142
+ if (!following) return;
143
+ const next = toSlug(sourceText);
144
+ if (next !== current) onChange(next === '' && !field.required ? undefined : next);
145
+ }, [following, sourceText, current, onChange, field.required]);
146
+
147
+ const sourceLabel = editor.collection.fields?.find((f) => f.path[0] === from)?.label ?? from;
148
+ return (
149
+ <>
150
+ <input
151
+ id={id}
152
+ type="text"
153
+ value={current}
154
+ spellCheck={false}
155
+ autoComplete="off"
156
+ onChange={(event) => {
157
+ setFollowing(false);
158
+ onChange(event.target.value === '' && !field.required ? undefined : event.target.value);
159
+ }}
160
+ aria-describedby={describedByWith(describedBy, following && `${id}-follows`)}
161
+ aria-invalid={invalid || undefined}
162
+ aria-required={field.required || undefined}
163
+ disabled={disabled}
164
+ className={cx(inputClass, 'font-mono')}
165
+ />
166
+ {following && sourceLabel && (
167
+ <p id={`${id}-follows`} className="text-small text-content-muted">
168
+ Follows the {sourceLabel.toLowerCase()} until you edit it.
169
+ </p>
170
+ )}
171
+ </>
172
+ );
173
+ }
174
+
175
+ function toSlug(text: string): string {
176
+ return slugify(text).replaceAll('/', '-');
177
+ }
178
+
179
+ /** URL, with a link that opens it (spec §6.3.2). */
180
+ export function UrlWidget({
181
+ value,
182
+ onChange,
183
+ field,
184
+ id,
185
+ describedBy,
186
+ invalid,
187
+ disabled,
188
+ }: WidgetProps<string>) {
189
+ const { manifest } = useStudio();
190
+ const current = typeof value === 'string' ? value : '';
191
+ const openable = /^https?:\/\/\S+$/i.test(current);
192
+ // A schema that takes only full URLs (z.url()) gets the site's address in
193
+ // front of the page's path, which needs Astro's `site`; without it, no picker.
194
+ const site = manifest.site.urls?.site;
195
+ const pickable = field.format !== 'url' || site !== undefined;
196
+ return (
197
+ <div className="flex flex-wrap items-center gap-3">
198
+ <input
199
+ id={id}
200
+ type="url"
201
+ inputMode="url"
202
+ value={current}
203
+ placeholder={field.placeholder ?? 'https://'}
204
+ onChange={(event) => {
205
+ onChange(event.target.value === '' && !field.required ? undefined : event.target.value);
206
+ }}
207
+ aria-describedby={describedBy}
208
+ aria-invalid={invalid || undefined}
209
+ aria-required={field.required || undefined}
210
+ disabled={disabled}
211
+ className={inputClass}
212
+ />
213
+ {openable && (
214
+ <a
215
+ href={current}
216
+ target="_blank"
217
+ rel="noopener noreferrer"
218
+ className="shrink-0 rounded-sm text-accent underline underline-offset-2 hover:text-accent-hover focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-focus focus-visible:ring-6 focus-visible:ring-(color:--glow-focus)"
219
+ >
220
+ Open <span className="sr-only">{current} in a new tab</span>
221
+ </a>
222
+ )}
223
+ {pickable && (
224
+ <ChoosePageButton
225
+ fieldLabel={field.label}
226
+ {...(field.format === 'url' && site ? { absolute: site } : {})}
227
+ {...(disabled ? { isDisabled: true } : {})}
228
+ onPick={(url) => {
229
+ onChange(url);
230
+ }}
231
+ />
232
+ )}
233
+ </div>
234
+ );
235
+ }
236
+
237
+ /**
238
+ * Date and time in the site's time zone, named in text (spec §6.17). The
239
+ * value keeps its type: a Date stays a Date, written with that day's offset.
240
+ */
241
+ export function DateTimeWidget({
242
+ value,
243
+ onChange,
244
+ field,
245
+ id,
246
+ describedBy,
247
+ invalid,
248
+ disabled,
249
+ }: WidgetProps<Date | string>) {
250
+ const { manifest } = useStudio();
251
+ const { zone, isSite } = resolveTimeZone(manifest.site.timeZone);
252
+ const instant =
253
+ value instanceof Date ? value : typeof value === 'string' && value ? new Date(value) : null;
254
+ const valid = instant !== null && !Number.isNaN(instant.getTime());
255
+ const name = zoneName(zone, valid ? instant : new Date());
256
+ return (
257
+ <>
258
+ <input
259
+ id={id}
260
+ type="datetime-local"
261
+ value={valid ? wallTime(instant, zone) : ''}
262
+ onChange={(event) => {
263
+ if (event.target.value === '') {
264
+ onChange(undefined);
265
+ return;
266
+ }
267
+ const next = fromWallTime(event.target.value, zone);
268
+ if (!next) return;
269
+ // A quoted string stays a string, with the offset written out.
270
+ onChange(typeof value === 'string' ? isoWithOffset(next, offsetOf(next) ?? 0) : next);
271
+ }}
272
+ aria-describedby={describedByWith(describedBy, `${id}-zone`)}
273
+ aria-invalid={invalid || undefined}
274
+ aria-required={field.required || undefined}
275
+ disabled={disabled}
276
+ required={field.required}
277
+ className={cx(inputClass, 'max-w-64')}
278
+ />
279
+ <p id={`${id}-zone`} className="text-small text-content-muted">
280
+ {isSite
281
+ ? `Times are in ${name}, the site’s time zone.`
282
+ : `Times are in your time zone, ${name}.`}
283
+ </p>
284
+ </>
285
+ );
286
+ }
@@ -0,0 +1,233 @@
1
+ import {
2
+ humanize,
3
+ isPlainObject,
4
+ variantFor,
5
+ type DataObject,
6
+ type DataValue,
7
+ type FieldModel,
8
+ type FieldVariant,
9
+ } from '@vespercms/core';
10
+ import { RadioGroup, Select } from '@vespercms/ui';
11
+ import { useRef } from 'react';
12
+ import { formatCount } from '../app/format.ts';
13
+ import { Field } from '../editor/Field.tsx';
14
+ import { initialValues, keyOf, type ValuePath } from '../editor/values.ts';
15
+ import type { WidgetProps } from '../types.ts';
16
+ import { RawWidget } from './basic.tsx';
17
+
18
+ /*
19
+ * A discriminated union of objects: one value of several kinds (DECISIONS.md,
20
+ * "Blocks are a discriminated union of objects"). In a list each item is a
21
+ * row named by its kind, and its "…" menu adds and changes kinds; on its own
22
+ * the field starts with a choice of kind. Either way the fields shown are the
23
+ * chosen kind's, and a kind the schema doesn't list is edited as YAML.
24
+ */
25
+
26
+ /** A new value of a kind: its key first, then its fields' own defaults, as a new entry gets them. */
27
+ export function kindValue(union: FieldModel, kind: FieldVariant): DataObject {
28
+ return { [union.discriminator ?? '']: kind.value, ...initialValues(kind.children) };
29
+ }
30
+
31
+ /** A value that holds nothing a person typed: undefined, empty text, an empty list or object. */
32
+ function isBlank(value: DataValue): boolean {
33
+ if (value === undefined || value === null || value === '') return true;
34
+ if (Array.isArray(value)) return value.length === 0;
35
+ return isPlainObject(value) && Object.values(value).every(isBlank);
36
+ }
37
+
38
+ export type KindChange = {
39
+ value: DataObject;
40
+ /** Labels of the fields both kinds have, whose values carried over. */
41
+ kept: string[];
42
+ /** Labels of the fields with content the new kind has no place for. */
43
+ dropped: string[];
44
+ };
45
+
46
+ /**
47
+ * A value moved to another kind. A field the new kind has under the same key
48
+ * and of the same kind keeps its value; the rest are dropped. `setAside` is
49
+ * what this value last held as the new kind, in this visit: changing back
50
+ * before saving brings it back.
51
+ */
52
+ export function changeKind(
53
+ union: FieldModel,
54
+ value: DataValue,
55
+ to: FieldVariant,
56
+ setAside?: DataObject,
57
+ ): KindChange {
58
+ const key = union.discriminator ?? '';
59
+ const from = variantFor(union, value);
60
+ const current = isPlainObject(value) ? value : {};
61
+ const defaults = initialValues(to.children);
62
+ const next: DataObject = { [key]: to.value };
63
+ const kept: string[] = [];
64
+ for (const child of to.children) {
65
+ const name = keyOf(child);
66
+ const was = from?.children.find((c) => keyOf(c) === name);
67
+ // An unknown kind's fields can't be compared, so any the new kind has are offered to it.
68
+ const fits = from ? was?.kind === child.kind : true;
69
+ const carried = fits ? current[name] : undefined;
70
+ if (carried !== undefined) {
71
+ next[name] = carried;
72
+ if (!isBlank(carried)) kept.push(child.label);
73
+ } else if (setAside?.[name] !== undefined) {
74
+ next[name] = setAside[name];
75
+ } else if (defaults[name] !== undefined) {
76
+ next[name] = defaults[name];
77
+ }
78
+ }
79
+ const dropped = Object.keys(current)
80
+ .filter((name) => name !== key && !(name in next) && !isBlank(current[name]))
81
+ .map((name) => from?.children.find((c) => keyOf(c) === name)?.label ?? humanize(name));
82
+ return { value: next, kept, dropped };
83
+ }
84
+
85
+ /** "Heading kept. Cards and Layout removed." for the status line after a change of kind. */
86
+ export function changeSummary(change: KindChange): string {
87
+ const list = (labels: string[]) =>
88
+ labels.length <= 1
89
+ ? (labels[0] ?? '')
90
+ : `${labels.slice(0, -1).join(', ')} and ${labels.at(-1) ?? ''}`;
91
+ return [
92
+ change.kept.length > 0 ? `${list(change.kept)} kept.` : '',
93
+ change.dropped.length > 0 ? `${list(change.dropped)} removed.` : '',
94
+ ]
95
+ .filter(Boolean)
96
+ .join(' ');
97
+ }
98
+
99
+ const NAME_KEYS = ['title', 'name', 'label', 'heading', 'text'];
100
+ const plain = (text: string) => text.replace(/[*_`]/g, '').trim();
101
+
102
+ /**
103
+ * How a list names an item of a kind: by its kind, with its own words after
104
+ * it ("Card grid", "This industry has rules"). On a page, the order of kinds
105
+ * is what someone scans for, and kinds are short names.
106
+ */
107
+ export function kindRow(value: DataValue, union: FieldModel): { name: string; detail: string } {
108
+ const kind = variantFor(union, value);
109
+ if (!kind) {
110
+ const named = isPlainObject(value) ? value[union.discriminator ?? ''] : undefined;
111
+ return {
112
+ name: typeof named === 'string' && named ? `Unknown kind: ${named}` : 'No kind',
113
+ detail: '',
114
+ };
115
+ }
116
+ if (!isPlainObject(value)) return { name: kind.label, detail: '' };
117
+ const shown = kind.children.filter((child) => !child.hidden);
118
+ for (const key of [kind.summary, ...NAME_KEYS, ...shown.map(keyOf)]) {
119
+ const text = key ? value[key] : undefined;
120
+ if (typeof text === 'string' && plain(text)) return { name: kind.label, detail: plain(text) };
121
+ }
122
+ const list = shown.find((child) => child.kind === 'array');
123
+ if (list) {
124
+ const held = value[keyOf(list)];
125
+ const many = list.label.toLowerCase();
126
+ const one = /[^s]s$/.test(many) ? many.slice(0, -1) : many;
127
+ return {
128
+ name: kind.label,
129
+ detail: formatCount(Array.isArray(held) ? held.length : 0, one, many),
130
+ };
131
+ }
132
+ return { name: kind.label, detail: '' };
133
+ }
134
+
135
+ /** A kind's fields, as an object's are shown. */
136
+ function KindFields({ kind, path }: { kind: FieldVariant; path: ValuePath }) {
137
+ return (
138
+ <div className="flex flex-col gap-4">
139
+ {kind.children
140
+ .filter((child) => !child.hidden)
141
+ .map((child) => (
142
+ <Field key={keyOf(child)} field={child} path={[...path, keyOf(child)]} />
143
+ ))}
144
+ </div>
145
+ );
146
+ }
147
+
148
+ const NONE = '';
149
+
150
+ type UnionProps = WidgetProps<DataValue> & { path: ValuePath; inRow?: boolean };
151
+
152
+ export function UnionWidget(props: UnionProps) {
153
+ const { value, onChange, field, path, id, describedBy, invalid, disabled, inRow } = props;
154
+ // What the value held as each kind it left, so changing back brings it back.
155
+ const setAside = useRef(new Map<string, DataObject>());
156
+ const kind = variantFor(field, value);
157
+ const kinds = field.variants ?? [];
158
+ const unknown = value !== undefined && value !== null && !kind;
159
+
160
+ const body = kind ? (
161
+ kind.children.length > 0 ? (
162
+ <KindFields kind={kind} path={path} />
163
+ ) : inRow ? (
164
+ <p className="text-small text-content-muted">This kind has nothing more to fill in.</p>
165
+ ) : null
166
+ ) : unknown ? (
167
+ <div className="flex flex-col gap-2">
168
+ <label htmlFor={`${id}-yaml`} className="text-label text-content">
169
+ {kindRow(value, field).name}
170
+ </label>
171
+ <p id={`${id}-unknown`} className="text-small text-content-muted">
172
+ The site has no kind by this name. Change it to one it has, or edit it here.
173
+ </p>
174
+ <RawWidget
175
+ value={value}
176
+ onChange={onChange}
177
+ field={{ ...field, unknownReason: undefined }}
178
+ id={`${id}-yaml`}
179
+ describedBy={[describedBy, `${id}-unknown`].filter(Boolean).join(' ')}
180
+ invalid={invalid}
181
+ disabled={disabled}
182
+ />
183
+ </div>
184
+ ) : null;
185
+
186
+ // In a list, the row names the kind and its "…" menu changes it.
187
+ if (inRow) return body;
188
+
189
+ const choose = (next: string) => {
190
+ if (kind) setAside.current.set(kind.value, isPlainObject(value) ? value : {});
191
+ const to = kinds.find((one) => one.value === next);
192
+ if (!to) {
193
+ onChange(undefined);
194
+ return;
195
+ }
196
+ onChange(changeKind(field, value, to, setAside.current.get(to.value)).value);
197
+ };
198
+ const options = [
199
+ ...(field.required ? [] : [{ value: NONE, label: 'None' }]),
200
+ ...kinds.map((one) => ({ value: one.value, label: one.label })),
201
+ ];
202
+ const selected = kind ? kind.value : value === undefined ? NONE : null;
203
+ const chooser =
204
+ options.length <= 4 ? (
205
+ <RadioGroup
206
+ id={`${id}-kind`}
207
+ label="Kind"
208
+ options={options}
209
+ value={selected}
210
+ onChange={choose}
211
+ isDisabled={disabled}
212
+ orientation={options.length <= 3 ? 'horizontal' : 'vertical'}
213
+ />
214
+ ) : (
215
+ <Select
216
+ id={`${id}-kind`}
217
+ label="Kind"
218
+ options={options}
219
+ value={selected}
220
+ onChange={(next) => {
221
+ choose(next ?? NONE);
222
+ }}
223
+ isDisabled={disabled}
224
+ />
225
+ );
226
+ return (
227
+ <div className="flex flex-col gap-4">
228
+ {chooser}
229
+ {kind?.description && <p className="text-small text-content-muted">{kind.description}</p>}
230
+ {body}
231
+ </div>
232
+ );
233
+ }