@forming/react 0.0.0-stage → 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/dist/index.js ADDED
@@ -0,0 +1,790 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import React, { useEffect, useId, useMemo, useRef, useState, useSyncExternalStore } from 'react';
3
+ import { atPointer, JsonNumber, parseJson, stringifyJson } from '@forming/integrations/lossless-json';
4
+ import { baseType, editorText, Icon, inputMode, MAX_LIST_ITEMS, pickerAccepts, ScalarListEditor, TemporalPicker, } from './editors.js';
5
+ import { markdown } from './markdown.js';
6
+ import { localized } from './format.js';
7
+ export function createController(runtime, dispatch = event => runtime.dispatch(event), uploads) {
8
+ const listeners = new Set();
9
+ const editors = new Set();
10
+ let disposed = false;
11
+ const initial = runtime.view();
12
+ let snapshot = {
13
+ viewJson: initial,
14
+ view: parseJson(initial),
15
+ editorErrors: new Set(),
16
+ error: null,
17
+ reset: 0,
18
+ };
19
+ // A successful dispatch already returns the new view; reuse that text (and
20
+ // its parsed form) instead of rebuilding, reserializing and reparsing it.
21
+ function refresh(error = null, viewJson, view) {
22
+ if (disposed)
23
+ return;
24
+ const text = viewJson ?? runtime.view();
25
+ const parsed = view ?? (text === snapshot.viewJson ? snapshot.view : parseJson(text));
26
+ snapshot = { ...snapshot, viewJson: text, view: parsed, editorErrors: new Set(editors), error };
27
+ for (const listener of listeners)
28
+ listener();
29
+ }
30
+ return {
31
+ document: () => runtime.document(),
32
+ getSnapshot: () => snapshot,
33
+ subscribe(listener) {
34
+ if (!disposed)
35
+ listeners.add(listener);
36
+ return () => {
37
+ listeners.delete(listener);
38
+ };
39
+ },
40
+ dispose() {
41
+ disposed = true;
42
+ listeners.clear();
43
+ editors.clear();
44
+ },
45
+ refresh: () => refresh(),
46
+ uploadsAvailable: !!uploads,
47
+ selectFiles(owner, files) {
48
+ if (disposed)
49
+ return;
50
+ if (!uploads) {
51
+ refresh('Configure an upload host to select files.');
52
+ return;
53
+ }
54
+ try {
55
+ const result = JSON.parse(uploads.select(owner, files));
56
+ refresh(result.status === 'error'
57
+ ? (result.diagnostic?.message ?? 'The files could not be selected.')
58
+ : result.uploadOutcome?.rejected?.length
59
+ ? 'Some files exceed the allowed format or limits.'
60
+ : null);
61
+ }
62
+ catch {
63
+ refresh('The files could not be selected.');
64
+ }
65
+ },
66
+ retryFile(owner, slot) {
67
+ if (disposed)
68
+ return;
69
+ if (!uploads)
70
+ return;
71
+ try {
72
+ const raw = uploads.retry(owner, slot);
73
+ const result = raw === null ? null : JSON.parse(raw);
74
+ refresh(result?.status === 'error' ? (result.diagnostic?.message ?? 'Reselect the file to retry.') : null);
75
+ }
76
+ catch {
77
+ refresh('The file could not be retried.');
78
+ }
79
+ },
80
+ editor(id, invalid) {
81
+ if (disposed)
82
+ return;
83
+ if (editors.has(id) === invalid)
84
+ return;
85
+ if (invalid) {
86
+ editors.add(id);
87
+ // A pending Submit must not publish the old value behind an invalid buffer.
88
+ dispatch('{"type":"submit.cancel"}');
89
+ }
90
+ else
91
+ editors.delete(id);
92
+ refresh();
93
+ },
94
+ send(event) {
95
+ if (disposed)
96
+ return false;
97
+ // Never submit the previous committed value while an invalid buffer is visible.
98
+ const resets = event.type === 'action' && snapshot.view.actions.find(action => action.id === event.id)?.type === 'reset';
99
+ if (editors.size && event.type === 'action' && !resets) {
100
+ refresh('Finish or clear the highlighted entries first.');
101
+ return false;
102
+ }
103
+ const result = dispatch(stringifyJson(event));
104
+ if (result === null) {
105
+ refresh();
106
+ return true;
107
+ } // Provider bridge may queue a reentrant event.
108
+ const parsed = parseJson(result);
109
+ if (parsed.status === 'error') {
110
+ refresh(parsed.diagnostic?.message ?? 'This change could not be applied.');
111
+ return false;
112
+ }
113
+ if (parsed.outcome === 'reset') {
114
+ editors.clear();
115
+ snapshot = { ...snapshot, reset: snapshot.reset + 1 };
116
+ }
117
+ refresh(null, result, parsed);
118
+ return true;
119
+ },
120
+ };
121
+ }
122
+ const defaultText = {
123
+ clear: 'Clear',
124
+ setNull: 'Set to null',
125
+ create: 'Add details',
126
+ add: 'Add item',
127
+ remove: 'Remove',
128
+ up: 'Move up',
129
+ down: 'Move down',
130
+ search: 'Search options',
131
+ more: 'Load more',
132
+ retry: 'Retry',
133
+ waiting: 'Checking…',
134
+ unavailable: 'This check is unavailable.',
135
+ choose: 'Choose an option',
136
+ chooseMany: 'Choose as many as you like',
137
+ invalid: 'Enter a complete value before continuing.',
138
+ unsupported: 'This field needs a supported editor.',
139
+ nullValue: 'Currently null',
140
+ shownAs: 'Shown in your locale as {value}',
141
+ reselect: 'Select the unfinished files again, or dismiss them.',
142
+ dismiss: 'Dismiss unfinished files',
143
+ uploading: 'Uploading…',
144
+ verified: 'Verified',
145
+ uploadFailed: 'This file could not be verified.',
146
+ uploadNotReady: 'Complete the required upload details first.',
147
+ issueSingle: '1 answer needs attention.',
148
+ issueCount: '{count} answers need attention.',
149
+ issueFirst: 'Go to the first',
150
+ step: 'Step {current} of {total}',
151
+ next: 'Continue',
152
+ back: 'Back',
153
+ pressEnter: 'Press Enter ↵',
154
+ counter: '{count} / {max}',
155
+ };
156
+ const additionalText = {
157
+ trueValue: 'Yes',
158
+ falseValue: 'No',
159
+ accept: 'I accept',
160
+ formUnavailable: 'The form could not be loaded.',
161
+ };
162
+ /** Field drawings by answer pointer and by exact annotation (package@version#export). */
163
+ function fieldDrawings(extensions) {
164
+ const presenters = extensions?.presentation() ?? [];
165
+ return {
166
+ pointers: new Map(presenters.flatMap(p => [...(p.fields ?? new Map())])),
167
+ annotations: new Map(presenters.flatMap(p => [...(p.annotations ?? new Map())])),
168
+ decorations: new Map(presenters.flatMap(p => [...(p.decorations ?? new Map())])),
169
+ };
170
+ }
171
+ /** A ready-made plugin for selectable sections with a heading and body text.
172
+ * Field keys are answer pointers (e.g. /plan); values stay exact JSON values.
173
+ * Register it with createExtensions like any other plugin.
174
+ */
175
+ export function choiceCards({ identity, fields, }) {
176
+ const entries = new Map(Object.entries(fields).map(([bind, cards]) => [
177
+ bind,
178
+ cards.map(({ value, ...presentation }) => ({ value: parseJson(stringifyJson(value)), ...presentation })),
179
+ ]));
180
+ return {
181
+ identity,
182
+ present: {
183
+ choice({ field, value }) {
184
+ const card = entries.get(field.bind ?? '')?.find(card => equal(card.value, value));
185
+ return card ? { title: card.title, description: card.description } : undefined;
186
+ },
187
+ },
188
+ };
189
+ }
190
+ // The registry already rejects duplicate identities and malformed hooks.
191
+ function rendererPlugins(extensions) {
192
+ const handlers = (extensions?.presentation() ?? []).flatMap(p => (p.choice ? [p.choice] : []));
193
+ return (option) => {
194
+ let result;
195
+ for (const handler of handlers) {
196
+ const next = handler(option);
197
+ if (next === undefined)
198
+ continue;
199
+ if (!next ||
200
+ typeof next !== 'object' ||
201
+ (next.title !== undefined && typeof next.title !== 'string') ||
202
+ (next.description !== undefined && typeof next.description !== 'string'))
203
+ throw new TypeError('Choice presentation must contain plain text title and description.');
204
+ if (result !== undefined)
205
+ throw new TypeError('Multiple renderer plugins claim the same option.');
206
+ result = next;
207
+ }
208
+ return result;
209
+ };
210
+ }
211
+ // Controls whose own keys matter: Enter and letters must keep their native meaning there.
212
+ function typing(target) {
213
+ if (target.isContentEditable || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT')
214
+ return true;
215
+ return target.tagName === 'INPUT' && !['radio', 'checkbox', 'file'].includes(target.type);
216
+ }
217
+ function shown(value) {
218
+ return value === undefined ? '' : typeof value === 'string' ? value : stringifyJson(value);
219
+ }
220
+ function schemaFor(document, node) {
221
+ if (node.schemaPath)
222
+ return (atPointer(document, node.schemaPath) ?? {});
223
+ let schema = document;
224
+ for (const segment of (node.bind ?? '').slice(1).split('/'))
225
+ schema = schema.properties?.[segment.replaceAll('~1', '/').replaceAll('~0', '~')] ?? {};
226
+ return schema;
227
+ }
228
+ function numberKey(raw) {
229
+ const [mantissa, exponent = '0'] = raw.toLowerCase().split('e');
230
+ const negative = mantissa.startsWith('-'), unsigned = negative ? mantissa.slice(1) : mantissa;
231
+ const [whole, fraction = ''] = unsigned.split('.');
232
+ let digits = (whole + fraction).replace(/^0+/, '');
233
+ if (!digits)
234
+ return '0';
235
+ const trimmed = digits.replace(/0+$/, '');
236
+ const power = BigInt(exponent) - BigInt(fraction.length) + BigInt(digits.length - trimmed.length);
237
+ digits = trimmed;
238
+ return `${negative ? '-' : ''}${digits}e${power}`;
239
+ }
240
+ function equal(a, b) {
241
+ return a instanceof JsonNumber && b instanceof JsonNumber
242
+ ? numberKey(a.raw) === numberKey(b.raw)
243
+ : stringifyJson(a) === stringifyJson(b);
244
+ }
245
+ export function FormingForm({ controller, text: overrides, className, presentation = 'embedded', detail = 'standard', extensions, }) {
246
+ const presentChoice = useMemo(() => rendererPlugins(extensions), [extensions]);
247
+ const drawings = useMemo(() => fieldDrawings(extensions), [extensions]);
248
+ const snapshot = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);
249
+ const view = snapshot.view;
250
+ const document = useMemo(() => parseJson(controller.document()), [controller]);
251
+ const form = view.form ?? document.form;
252
+ const text = { ...defaultText, ...additionalText, ...editorText, ...view.localization?.renderer, ...overrides };
253
+ const instanceId = useId();
254
+ const formRef = useRef(null);
255
+ const previousPage = useRef(view.navigation?.currentPage);
256
+ const focusInside = useRef(false);
257
+ useEffect(() => {
258
+ const changed = previousPage.current !== view.navigation?.currentPage;
259
+ previousPage.current = view.navigation?.currentPage;
260
+ // Do not steal focus on mount, locale changes or from a host's source editor.
261
+ if (changed && focusInside.current)
262
+ formRef.current?.querySelector('[data-forming-page]')?.focus();
263
+ }, [view.navigation?.currentPage]);
264
+ const states = new Map([...(view.validation?.fieldStates ?? []), ...(view.validation?.viewStates ?? [])].map(state => [
265
+ state.nodeId,
266
+ state,
267
+ ]));
268
+ // Page transitions slide with the direction of travel along the runtime's route.
269
+ const route = view.navigation?.route ?? [];
270
+ const pageIndex = route.indexOf(view.navigation?.currentPage ?? '');
271
+ const lastIndex = useRef(pageIndex);
272
+ const direction = useRef('forward');
273
+ // The core keeps errors revealed after a successful Next. A page someone has just
274
+ // arrived at stays quiet until they try to continue or submit from it.
275
+ const quietPage = useRef(null);
276
+ if (pageIndex !== lastIndex.current) {
277
+ direction.current = pageIndex >= 0 && pageIndex < lastIndex.current ? 'back' : 'forward';
278
+ lastIndex.current = pageIndex;
279
+ quietPage.current = view.navigation?.currentPage ?? null;
280
+ }
281
+ const revealed = view.errorsRevealed && (quietPage.current === null || quietPage.current !== view.navigation?.currentPage);
282
+ const activate = (id) => {
283
+ quietPage.current = null;
284
+ return controller.send({ type: 'action', id });
285
+ };
286
+ const context = {
287
+ presentChoice,
288
+ drawings,
289
+ controller,
290
+ snapshot,
291
+ view,
292
+ document,
293
+ text,
294
+ instanceId,
295
+ states,
296
+ presentation,
297
+ detail,
298
+ direction: direction.current,
299
+ revealed,
300
+ };
301
+ if (view.status !== 'ok')
302
+ return _jsx("div", { role: "alert", children: text.formUnavailable });
303
+ const pageNodes = (form.children ?? []).filter(node => node.type === 'page');
304
+ const steps = view.navigation.currentPage && route.length > 1
305
+ ? {
306
+ label: text.step
307
+ .replace('{current}', String(route.indexOf(view.navigation.currentPage) + 1))
308
+ .replace('{total}', String(route.length)),
309
+ pages: route.map(id => ({
310
+ id,
311
+ title: pageNodes.find(node => node.id === id)?.title ?? id,
312
+ visited: (view.navigation.history ?? []).includes(id),
313
+ })),
314
+ }
315
+ : null;
316
+ // Actions declared on the current page (Back/Continue/Send) sit apart from form-level ones.
317
+ const currentPage = pageNodes.find(node => node.id === view.navigation.currentPage);
318
+ const actionNodes = (node) => node.type === 'action' ? [node] : (node.children ?? []).flatMap(actionNodes);
319
+ const pageActionNodes = currentPage ? actionNodes(currentPage) : [];
320
+ const ownedByPage = new Set(pageActionNodes.map(node => node.id));
321
+ // Synthesized sequential navigation carries no authored label; use the renderer's text.
322
+ const implicitLabel = (action) => pageActionNodes.find(node => node.id === action.id)?.implicit
323
+ ? (text[action.type] ?? action.label)
324
+ : action.label;
325
+ // On paged forms the summary covers the questions in front of the respondent, not later pages.
326
+ const nodeIds = (node) => [node.id, ...(node.children ?? []).flatMap(nodeIds)];
327
+ const onPage = currentPage ? new Set(nodeIds(currentPage)) : null;
328
+ const pageIssues = view.validation.issues.filter(issue => !onPage || onPage.has(issue.nodeId));
329
+ const goToFirstIssue = () => {
330
+ const fields = new Map(Array.from(formRef.current?.querySelectorAll('[data-forming-node]') ?? []).map(field => [
331
+ field.dataset.formingNode,
332
+ field,
333
+ ]));
334
+ // A plugin can draw an entire group, so its children may have no DOM of their own.
335
+ const parents = new Map();
336
+ const visit = (node) => {
337
+ for (const child of node.children ?? []) {
338
+ parents.set(child.id, node.id);
339
+ visit(child);
340
+ }
341
+ };
342
+ visit(form);
343
+ for (const issue of pageIssues) {
344
+ let id = issue.nodeId;
345
+ while (id && !fields.has(id))
346
+ id = parents.get(id);
347
+ const field = id ? fields.get(id) : undefined;
348
+ if (!field)
349
+ continue;
350
+ const controls = Array.from(field.querySelectorAll('input:not([type="hidden"]), select, textarea, button, [tabindex]:not([tabindex="-1"])')).filter(control => !control.matches(':disabled, [aria-disabled="true"]') && !control.closest('[hidden], [inert]'));
351
+ const target = controls.find(control => control.getAttribute('aria-invalid') === 'true') ?? controls[0] ?? field;
352
+ target.focus({ preventScroll: true });
353
+ field.scrollIntoView?.({ block: 'center', inline: 'nearest' });
354
+ return;
355
+ }
356
+ };
357
+ const pageActions = view.actions.filter(action => ownedByPage.has(action.id));
358
+ const formActions = view.actions.filter(action => !ownedByPage.has(action.id));
359
+ // Unfinished editor buffers block every action but Reset.
360
+ const buffered = (action) => snapshot.editorErrors.size > 0 && action.type !== 'reset';
361
+ // A blocked Next/Submit/Validate still goes to the core, which reveals what is missing.
362
+ const reveals = (action) => ['next', 'submit', 'validate'].includes(action.type);
363
+ const available = (action) => !buffered(action) && (action.enabled || reveals(action));
364
+ // Enter moves forward: the page's Continue, otherwise Submit.
365
+ const primary = pageActions.find(action => action.type === 'next' && action.enabled && available(action)) ??
366
+ pageActions.find(action => action.type === 'next' && available(action)) ??
367
+ view.actions.find(action => action.type === 'submit' && available(action));
368
+ function shortcut(event) {
369
+ if (event.defaultPrevented || event.altKey || event.nativeEvent.isComposing)
370
+ return;
371
+ const target = event.target;
372
+ if (event.key === 'Enter') {
373
+ if (event.shiftKey || target.tagName === 'BUTTON' || target.tagName === 'A' || target.tagName === 'SELECT')
374
+ return;
375
+ if (target.tagName === 'INPUT' && ['file', 'search'].includes(target.type))
376
+ return;
377
+ // Textareas keep Enter for new lines; Cmd/Ctrl+Enter continues.
378
+ if (target.tagName === 'TEXTAREA' && !(event.metaKey || event.ctrlKey))
379
+ return;
380
+ event.preventDefault();
381
+ if (primary)
382
+ activate(primary.id);
383
+ return;
384
+ }
385
+ // A-H pick a choice card: in the focused question, or the page's only card question.
386
+ // Only focused presentation shows the letters, so only it answers to them.
387
+ if (presentation !== 'focused')
388
+ return;
389
+ if (event.ctrlKey || event.metaKey || !/^[a-h]$/i.test(event.key) || typing(target))
390
+ return;
391
+ const groups = formRef.current?.querySelectorAll('[data-forming-choices]') ?? [];
392
+ const group = target.closest('[data-forming-choices]') ?? (groups.length === 1 ? groups[0] : null);
393
+ const choice = group?.querySelector(`input[aria-keyshortcuts="${event.key.toUpperCase()}"]`);
394
+ if (!choice || choice.disabled)
395
+ return;
396
+ event.preventDefault();
397
+ choice.focus();
398
+ choice.click();
399
+ }
400
+ return (_jsxs("form", { ref: formRef, className: ['forming-form', className].filter(Boolean).join(' '), "data-forming-root": "", "data-presentation": presentation, onFocusCapture: () => {
401
+ focusInside.current = true;
402
+ }, onBlurCapture: event => {
403
+ if (event.relatedTarget && !event.currentTarget.contains(event.relatedTarget))
404
+ focusInside.current = false;
405
+ }, noValidate: true, lang: view.localization?.locale, dir: view.localization?.direction, onKeyDown: shortcut, onSubmit: event => {
406
+ event.preventDefault();
407
+ const submit = view.actions.find(action => action.type === 'submit' && action.enabled);
408
+ if (submit)
409
+ activate(submit.id);
410
+ }, children: [_jsxs("header", { "data-forming-header": "", children: [_jsx("h1", { children: form.title }), form.description && _jsx("p", { children: form.description })] }), steps && (
411
+ // Pages are steps: the runtime's route lists the pages this respondent will visit.
412
+ _jsxs("nav", { "data-forming-steps": "", "aria-label": steps.label, children: [_jsx("span", { "aria-live": "polite", children: steps.label }), _jsx("ol", { children: steps.pages.map((page, index) => (_jsxs("li", { "aria-current": page.id === view.navigation.currentPage ? 'step' : undefined, "data-visited": page.visited ? '' : undefined, children: [_jsx("span", { "data-forming-step-number": "", "aria-hidden": "true", children: index + 1 }), _jsx("span", { children: page.title })] }, page.id))) })] })), snapshot.error && _jsx("div", { role: "alert", children: snapshot.error }), form.children?.map(node => (_jsx(Tree, { node: node, context: context }, node.id))), view.submissionIntent?.state === 'waiting' && _jsx("p", { role: "status", children: text.waiting }), revealed && pageIssues.length > 0 && (
413
+ // Each field already shows its own message; this summary follows the
414
+ // fields and only offers a jump to the first one still needing attention.
415
+ _jsxs("p", { role: "alert", "data-forming-issues": "", children: [(new Set(pageIssues.map(issue => issue.nodeId)).size === 1 ? text.issueSingle : text.issueCount).replace('{count}', String(new Set(pageIssues.map(issue => issue.nodeId)).size)), ' ', _jsx("button", { type: "button", onClick: goToFirstIssue, children: text.issueFirst })] })), [pageActions, formActions].map((group, index) => group.length > 0 && (_jsx("div", { "data-forming-actions": index === 0 ? 'page' : 'form', children: group.map(action => (_jsxs(React.Fragment, { children: [_jsx("button", { type: "button", "data-action": action.type, "aria-keyshortcuts": action === primary ? 'Enter' : undefined, disabled: !available(action), "aria-disabled": !action.enabled && available(action) ? true : undefined, onClick: () => activate(action.id), children: implicitLabel(action) }), action === primary && presentation === 'focused' && (_jsx("span", { "data-forming-hint": "", "aria-hidden": "true", children: text.pressEnter }))] }, action.id))) }, index)))] }));
416
+ }
417
+ function Decorated({ node, context, children }) {
418
+ const decorations = (node.annotations ?? []).flatMap(annotation => {
419
+ const draw = context.drawings.decorations.get(`${annotation.package}#${annotation.export}`);
420
+ return draw ? [{ annotation, draw }] : [];
421
+ });
422
+ if (!decorations.length)
423
+ return _jsx(_Fragment, { children: children });
424
+ const content = (placement) => decorations.map(({ annotation, draw }) => (_jsx(React.Fragment, { children: draw({
425
+ node: { id: node.id, type: node.type, bind: node.bind, annotations: node.annotations },
426
+ annotation,
427
+ placement,
428
+ }) }, `${annotation.package}#${annotation.export}`)));
429
+ return (_jsxs("div", { "data-forming-decoration": "", style: { minWidth: 0 }, children: [content('before'), children, content('after')] }));
430
+ }
431
+ function Tree({ node, context }) {
432
+ if (node.type === 'action' || (node.type === 'page' && node.id !== context.view.navigation.currentPage))
433
+ return null;
434
+ if (node.type === 'field') {
435
+ if (!context.states.get(node.id)?.visible)
436
+ return null;
437
+ return (_jsx(Decorated, { node: node, context: context, children: _jsx(Field, { node: node, context: context }) }));
438
+ }
439
+ if (context.states.get(node.id)?.visible === false)
440
+ return null;
441
+ if (node.type === 'display')
442
+ return (_jsx(Decorated, { node: node, context: context, children: _jsxs("section", { "data-display": node.id, children: [(context.view.texts?.[node.id]?.title ?? node.title) && (_jsx("h2", { children: context.view.texts?.[node.id]?.title ?? node.title })), markdown(context.view.texts?.[node.id]?.content ?? node.content ?? '')] }) }));
443
+ const columns = node.type === 'grid'
444
+ ? Number(node.columns?.raw ?? '1')
445
+ : node.type === 'columns'
446
+ ? Math.max(1, node.children?.length ?? 1)
447
+ : undefined;
448
+ if (node.type === 'page')
449
+ return (_jsxs("section", { tabIndex: -1, "data-layout": "page", "data-forming-page": node.id, "data-direction": context.direction, "aria-labelledby": node.title ? context.instanceId + node.id + ':title' : undefined, children: [node.title && _jsx("h2", { id: context.instanceId + node.id + ':title', children: node.title }), node.description && _jsx("p", { children: node.description }), node.children?.map(child => (_jsx(Tree, { node: child, context: context }, child.id)))] }));
450
+ return (_jsxs("section", { "data-layout": node.type, style: columns
451
+ ? {
452
+ display: 'grid',
453
+ gridTemplateColumns: `repeat(auto-fit, minmax(min(100%, max(14rem, calc((100% - ${columns - 1}rem) / ${columns}))), 1fr))`,
454
+ gap: '1rem',
455
+ minWidth: 0,
456
+ }
457
+ : undefined, children: [node.title && _jsx("h2", { children: node.title }), node.children?.map(child => (_jsx(Tree, { node: child, context: context }, child.id)))] }));
458
+ }
459
+ function Field({ node, context }) {
460
+ const { controller, snapshot, view, document, text, instanceId } = context;
461
+ const state = context.states.get(node.id);
462
+ const schema = schemaFor(document, node);
463
+ const types = Array.isArray(schema.type) ? schema.type : [schema.type];
464
+ const type = types.find(type => type !== 'null');
465
+ const value = atPointer(view.data, node.bind ?? '');
466
+ const stored = value === undefined ? 'missing' : stringifyJson(value);
467
+ const committed = value === null ? '' : shown(value);
468
+ const [buffer, setBuffer] = useState(committed);
469
+ const [localError, setLocalError] = useState(false);
470
+ const [editorReset, setEditorReset] = useState(0);
471
+ const [dragging, setDragging] = useState(false);
472
+ const composing = useRef(false);
473
+ useEffect(() => {
474
+ composing.current = false;
475
+ setBuffer(committed);
476
+ setLocalError(false);
477
+ controller.editor(node.id, false);
478
+ }, [stored, committed, snapshot.reset, controller, node.id]);
479
+ useEffect(() => () => controller.editor(node.id, false), [controller, node.id]);
480
+ useEffect(() => {
481
+ if (!state?.visible || !state.enabled || state.readonly) {
482
+ composing.current = false;
483
+ setBuffer(committed);
484
+ setLocalError(false);
485
+ controller.editor(node.id, false);
486
+ }
487
+ }, [state?.visible, state?.enabled, state?.readonly, committed, controller, node.id]);
488
+ if (!state?.visible || node.control === 'hidden')
489
+ return null;
490
+ const disabled = !state.enabled || state.readonly;
491
+ const id = instanceId + node.id;
492
+ // A missing required answer reports both a schema and a completeness issue;
493
+ // show the completeness wording once rather than both.
494
+ const own = view.validation.issues.filter(issue => issue.nodeId === node.id);
495
+ const issues = context.revealed
496
+ ? own
497
+ .filter(issue => !(issue.code === 'FORMING_REQUIRED' && own.some(other => other.code === 'FORMING_UNANSWERED')))
498
+ .filter((issue, index, all) => all.findIndex(other => other.message === issue.message) === index)
499
+ : [];
500
+ const slots = view.providerState?.slots.filter(slot => node.providers?.some(site => site.id === slot.site)) ?? [];
501
+ const help = node.description ?? schema.description;
502
+ const bad = localError || issues.length > 0;
503
+ const common = {
504
+ id,
505
+ disabled,
506
+ 'aria-required': state.required,
507
+ 'aria-invalid': bad,
508
+ 'aria-describedby': `${id}:help ${id}:errors`,
509
+ placeholder: node.placeholder,
510
+ };
511
+ const composition = {
512
+ onCompositionStart: () => {
513
+ composing.current = true;
514
+ controller.editor(node.id, true);
515
+ },
516
+ onCompositionEnd: (event) => {
517
+ composing.current = false;
518
+ input(event.currentTarget.value);
519
+ },
520
+ };
521
+ function invalid(value) {
522
+ setLocalError(value);
523
+ controller.editor(node.id, value);
524
+ }
525
+ function commit(next) {
526
+ const ok = controller.send({ type: 'edit', id: node.id, value: next });
527
+ invalid(!ok);
528
+ return ok;
529
+ }
530
+ function input(raw) {
531
+ setBuffer(raw);
532
+ if (composing.current)
533
+ return;
534
+ if (type === 'number' || type === 'integer' || type === 'array') {
535
+ if (!raw.length) {
536
+ controller.send({ type: 'clear', id: node.id });
537
+ setLocalError(false);
538
+ controller.editor(node.id, false);
539
+ return;
540
+ }
541
+ try {
542
+ const parsed = type === 'array' ? parseJson(raw) : new JsonNumber(raw);
543
+ if (type === 'array' && !Array.isArray(parsed))
544
+ throw new TypeError();
545
+ commit(parsed);
546
+ }
547
+ catch {
548
+ setLocalError(true);
549
+ controller.editor(node.id, true);
550
+ }
551
+ }
552
+ else
553
+ commit(raw);
554
+ }
555
+ // A plugin may draw this field, by pointer or through an annotation the form
556
+ // wrote on it; the core still checks every edit.
557
+ const annotated = (node.annotations ?? []).flatMap(annotation => {
558
+ const by = context.drawings.annotations.get(`${annotation.package}#${annotation.export}`);
559
+ return by ? [{ annotation, by }] : [];
560
+ });
561
+ const byPointer = node.bind ? context.drawings.pointers.get(node.bind) : undefined;
562
+ if (annotated.length + (byPointer ? 1 : 0) > 1)
563
+ throw new TypeError(`Two plugins draw ${node.bind ?? node.id}.`);
564
+ const first = annotated.at(0);
565
+ const draw = byPointer ?? first?.by;
566
+ const uploadOwner = node.file ? view.uploadState?.fields.find(f => !f.retired && f.node === node.id) : undefined;
567
+ // Part of an object goes to whichever placement of that part is visible and
568
+ // editable now: one answer can be drawn differently depending on others.
569
+ function partId(pointer) {
570
+ if (!pointer)
571
+ return node.id;
572
+ const target = (node.bind ?? '') + pointer;
573
+ const current = new Map(controller.getSnapshot().view.validation.fieldStates.map(s => [s.nodeId, s]));
574
+ const found = [];
575
+ (function walk(parent) {
576
+ for (const child of parent.children ?? []) {
577
+ if (child.bind === target)
578
+ found.push(child);
579
+ walk(child);
580
+ }
581
+ })(node);
582
+ return found.find(child => {
583
+ const now = current.get(child.id);
584
+ return now?.visible && now.enabled && !now.readonly;
585
+ })?.id;
586
+ }
587
+ const drawing = draw && {
588
+ field: { id: node.id, bind: node.bind ?? '', title: node.title, description: help, type, file: !!node.file },
589
+ choices: node.options?.map(option => ({ ...option, label: option.label ?? shown(option.value) })) ??
590
+ (schema.items?.enum ?? schema.enum)?.map(value => ({ value, label: shown(value) })),
591
+ children: (node.children ?? []).flatMap(child => {
592
+ const childState = context.states.get(child.id);
593
+ if (child.type !== 'field' || !child.bind || !childState?.visible)
594
+ return [];
595
+ const childSchema = schemaFor(document, child);
596
+ return [
597
+ {
598
+ pointer: child.bind.slice((node.bind ?? '').length),
599
+ title: child.title ?? child.bind,
600
+ value: atPointer(view.data, child.bind),
601
+ choices: child.options?.map(option => ({ ...option, label: option.label ?? shown(option.value) })) ??
602
+ childSchema.enum?.map(value => ({ value, label: shown(value) })) ??
603
+ [],
604
+ disabled: disabled || !childState.enabled,
605
+ readonly: state.readonly || childState.readonly,
606
+ required: childState.required,
607
+ invalid: view.validation.issues.some(issue => issue.nodeId === child.id),
608
+ },
609
+ ];
610
+ }),
611
+ ...(first && !byPointer ? { annotation: first.annotation } : {}),
612
+ value,
613
+ set(next, pointer = '') {
614
+ if (!pointer)
615
+ return commit(next);
616
+ const target = partId(pointer);
617
+ return target !== undefined && controller.send({ type: 'edit', id: target, value: next });
618
+ },
619
+ clear(pointer = '') {
620
+ const target = partId(pointer);
621
+ return target !== undefined && controller.send({ type: 'clear', id: target });
622
+ },
623
+ selectFiles(files) {
624
+ if (uploadOwner && files.length)
625
+ controller.selectFiles(uploadOwner.owner, files);
626
+ },
627
+ filesReady: !!uploadOwner?.ready && controller.uploadsAvailable,
628
+ disabled,
629
+ readonly: state.readonly,
630
+ required: state.required,
631
+ invalid: bad,
632
+ inputId: id,
633
+ describedBy: `${id}:help ${id}:errors`,
634
+ };
635
+ // File fields: the drawing replaces only the picker; upload status stays below it.
636
+ const drawnFile = node.file && drawing && draw && (_jsx("div", { "data-forming-plugin": "", children: draw(drawing) }));
637
+ const optionsSite = node.providers?.find(site => site.kind === 'options');
638
+ const multi = node.control === 'multichoice' || (type === 'array' && !!schema.items?.enum);
639
+ const choices = node.options
640
+ ? [...node.options]
641
+ : ((multi ? schema.items?.enum : schema.enum)?.map(value => ({ value, label: shown(value) })) ?? []);
642
+ if (optionsSite)
643
+ for (const slot of slots.filter(s => s.operation.startsWith('options.')))
644
+ for (const option of slot.result?.items ?? [])
645
+ if (!choices.some(other => equal(other.value, option.value)))
646
+ choices.push(option);
647
+ const selected = value === undefined || value === null ? [] : multi && Array.isArray(value) ? value : [value];
648
+ for (const saved of selected)
649
+ if ((node.options || optionsSite || schema.enum || multi) && !choices.some(option => equal(option.value, saved)))
650
+ choices.push({ value: saved, label: shown(saved), disabled: true });
651
+ const tokens = selected.map(selected => String(choices.findIndex(option => equal(option.value, selected))));
652
+ const choice = !!node.options || !!optionsSite || !!schema.enum || multi;
653
+ // Consent (a Boolean that must be true) is one box to tick, not a yes/no question.
654
+ const consent = type === 'boolean' && !choice && schema.const === true;
655
+ const cardOptions = consent
656
+ ? [{ value: true, label: text.accept }]
657
+ : type === 'boolean' && !choice
658
+ ? [
659
+ { value: true, label: text.trueValue },
660
+ { value: false, label: text.falseValue },
661
+ ]
662
+ : choices;
663
+ // Small local choices are native radios/checkboxes: keyboard-operable cards in
664
+ // focused mode, a compact list in embedded mode (a multi-select list box hides
665
+ // that several answers are allowed; a dropdown hides a handful of options).
666
+ // Remote/large lists retain search and select behavior; never auto-advance.
667
+ const focused = context.presentation === 'focused';
668
+ const presentations = cardOptions.map(option => context.presentChoice({
669
+ field: { id: node.id, bind: node.bind },
670
+ value: option.value,
671
+ label: option.label ?? shown(option.value),
672
+ }));
673
+ const richChoices = presentations.some(presentation => presentation !== undefined);
674
+ const cards = !optionsSite &&
675
+ !node.structure &&
676
+ !node.file &&
677
+ (choice || type === 'boolean') &&
678
+ cardOptions.length > 0 &&
679
+ (richChoices || cardOptions.length <= (focused || multi ? 8 : 5));
680
+ const scalarList = type === 'array' &&
681
+ !choice &&
682
+ !node.structure &&
683
+ (!Array.isArray(value) || value.length <= MAX_LIST_ITEMS) &&
684
+ ['string', 'integer', 'number', 'boolean'].includes(baseType(schema.items) ?? '');
685
+ const temporal = type === 'string' &&
686
+ ['date', 'forming-local-time', 'date-time'].includes(schema.format ?? '') &&
687
+ (!node.control || ['date', 'time', 'datetime'].includes(node.control));
688
+ const exact = context.detail === 'exact';
689
+ // Respondents edit dates and times with the pickers alone, unless saved text
690
+ // is something the pickers cannot show (then the portable text stays visible).
691
+ const standaloneTemporal = temporal && !exact && !localError && (!committed || pickerAccepts(committed, schema.format ?? ''));
692
+ // Locale rendering of the committed value; the exact text stays editable as is.
693
+ const localeText = !node.structure && !node.file && !localError && buffer === committed
694
+ ? localized(value, schema, view.localization?.locale ?? (typeof navigator === 'undefined' ? undefined : navigator.language))
695
+ : null;
696
+ // Outside exact mode, skip echoes that repeat what the control already shows.
697
+ const formatted = localeText && (exact || (localeText !== committed && !(standaloneTemporal && schema.format !== 'date-time')))
698
+ ? localeText
699
+ : null;
700
+ const maxLength = type === 'string' && !temporal && !choice && schema.maxLength !== undefined
701
+ ? Number(schema.maxLength instanceof JsonNumber ? schema.maxLength.raw : schema.maxLength)
702
+ : undefined;
703
+ const length = [...buffer].length;
704
+ // Long answers always show their budget; short ones once they approach it.
705
+ const counter = maxLength !== undefined && Number.isFinite(maxLength) && (node.control === 'textarea' || length >= maxLength * 0.8)
706
+ ? text.counter.replace('{count}', String(length)).replace('{max}', String(maxLength))
707
+ : null;
708
+ const resetKey = `${snapshot.reset}:${editorReset}`;
709
+ let editor;
710
+ // Plain single-control editors carry their Clear button inside the control
711
+ // (hosts style it as a trailing ×); composite editors keep it below.
712
+ const inline = !draw &&
713
+ !node.structure &&
714
+ !node.file &&
715
+ !scalarList &&
716
+ !cards &&
717
+ !temporal &&
718
+ !(choice && (optionsSite || multi)) &&
719
+ type !== undefined;
720
+ if (node.file) {
721
+ const owner = uploadOwner;
722
+ editor = (_jsxs(_Fragment, { children: [drawnFile || (_jsx("input", { ...common, type: "file", "data-dragging": dragging ? '' : undefined, onDragEnter: () => setDragging(true), onDragLeave: () => setDragging(false), onDrop: () => setDragging(false), multiple: node.file.multiple, accept: node.file.accept.join(','), disabled: disabled || !owner?.ready || !controller.uploadsAvailable, onChange: event => {
723
+ const files = Array.from(event.currentTarget.files ?? []);
724
+ event.currentTarget.value = '';
725
+ if (owner && files.length)
726
+ controller.selectFiles(owner.owner, files);
727
+ } })), !owner?.ready && _jsx("p", { role: "status", children: text.uploadNotReady }), !controller.uploadsAvailable && _jsx("p", { role: "status", children: text.unsupported }), owner && Number(owner.reselect.raw) > 0 && (_jsxs("p", { children: [text.reselect, ' ', _jsx("button", { type: "button", "data-forming-tool": "", disabled: disabled, onClick: () => controller.send({ type: 'upload', owner: owner.owner, event: { type: 'reselect.dismiss' } }), children: text.dismiss })] })), _jsx("ul", { "data-forming-files": "", children: owner?.engine.slots
728
+ .filter(s => s.state !== 'cancelled')
729
+ .map(slot => (_jsxs("li", { "data-state": slot.state, children: [_jsx("span", { "data-forming-file-name": "", children: slot.file.name }), ' ', _jsx("span", { role: "status", "data-forming-badge": slot.state, children: slot.state === 'complete'
730
+ ? text.verified
731
+ : slot.state === 'failed'
732
+ ? text.uploadFailed
733
+ : slot.state === 'uploading'
734
+ ? text.uploading
735
+ : text.waiting }), ' ', _jsx("button", { type: "button", "data-forming-tool": "", disabled: disabled, onClick: () => controller.send({
736
+ type: 'upload',
737
+ owner: owner.owner,
738
+ event: { type: slot.committed ? 'remove' : 'cancel', slot: slot.id },
739
+ }), children: text.remove }), slot.state === 'failed' && slot.error?.retryable && (_jsx("button", { type: "button", "data-forming-tool": "", disabled: disabled || !controller.uploadsAvailable, onClick: () => controller.retryFile(owner.owner, slot.id), children: text.retry }))] }, slot.id))) })] }));
740
+ }
741
+ else if (node.structure)
742
+ editor = (_jsxs(_Fragment, { children: [node.structure === 'object' && (value === undefined || value === null) ? (_jsxs("button", { type: "button", "data-forming-add": "", disabled: disabled, onClick: () => controller.send({ type: 'object.create', id: node.id }), children: [_jsx(Icon, { name: "plus" }), text.create] })) : (node.children?.map((child, index) => (_jsxs("div", { "data-forming-item": node.structure === 'repeat' ? '' : undefined, children: [node.structure === 'repeat' && (_jsxs("div", { "data-forming-item-header": "", children: [_jsxs("span", { "data-forming-item-title": "", children: [text.item, " ", index + 1] }), _jsxs("div", { "data-forming-item-tools": "", children: [_jsxs("button", { type: "button", disabled: disabled || index === 0, onClick: () => controller.send({ type: 'repeat.move', id: node.id, item: child.itemId, index: index - 1 }), children: [_jsx(Icon, { name: "up" }), _jsx("span", { children: text.up })] }), _jsxs("button", { type: "button", disabled: disabled || index === (node.children?.length ?? 0) - 1, onClick: () => controller.send({ type: 'repeat.move', id: node.id, item: child.itemId, index: index + 1 }), children: [_jsx(Icon, { name: "down" }), _jsx("span", { children: text.down })] }), _jsxs("button", { type: "button", "data-danger": "", disabled: disabled, onClick: () => controller.send({ type: 'repeat.remove', id: node.id, item: child.itemId }), children: [_jsx(Icon, { name: "trash" }), _jsx("span", { children: text.remove })] })] })] })), _jsx(Tree, { node: child, context: context })] }, child.id)))), node.structure === 'object' && value !== undefined && value !== null && !state.required && (_jsx("button", { type: "button", "data-forming-tool": "", disabled: disabled, "aria-label": `${text.remove} ${node.title ?? ''}`.trim(), onClick: () => controller.send({ type: 'clear', id: node.id }), children: text.remove })), node.structure === 'repeat' && (_jsxs("button", { type: "button", "data-forming-add": "", disabled: disabled, onClick: () => controller.send({ type: 'repeat.insert', id: node.id, index: node.children?.length ?? 0 }), children: [_jsx(Icon, { name: "plus" }), text.add] }))] }));
743
+ else if (cards)
744
+ editor = (_jsxs(_Fragment, { children: [multi && _jsx("p", { "data-forming-choice-hint": "", children: text.chooseMany }), _jsx("div", { "data-forming-choices": multi ? 'multiple' : 'single', "data-rich-choices": richChoices ? '' : undefined, children: cardOptions.map((option, index) => {
745
+ const checked = selected.some(saved => equal(saved, option.value));
746
+ const key = index < 8 ? String.fromCharCode(65 + index) : undefined;
747
+ const presentation = presentations[index];
748
+ return (_jsxs("label", { "data-forming-choice": "", "data-rich-choice": presentation ? '' : undefined, "data-selected": checked ? '' : undefined, children: [_jsx("input", { type: multi || consent ? 'checkbox' : 'radio', name: id, id: `${id}:choice:${index}`, value: shown(option.value), checked: checked, disabled: disabled || option.disabled || option.status === 'missing', "aria-keyshortcuts": focused ? key : undefined, "aria-required": !multi && state.required, "aria-invalid": bad, "aria-labelledby": presentation ? `${id}:choice:${index}:title` : undefined, "aria-describedby": `${id}:help ${id}:errors${presentation?.description ? ` ${id}:choice:${index}:description` : ''}`, onChange: () =>
749
+ // Unticking consent leaves it unanswered, so its "please accept" check applies.
750
+ consent && checked
751
+ ? controller.send({ type: 'clear', id: node.id })
752
+ : commit(multi
753
+ ? checked
754
+ ? selected.filter(saved => !equal(saved, option.value))
755
+ : [...selected, option.value]
756
+ : option.value) }), focused && key && (_jsx("kbd", { "data-forming-key": "", "aria-hidden": "true", children: key })), _jsx("span", { "data-forming-choice-content": "", children: presentation ? (_jsxs(_Fragment, { children: [_jsx("strong", { id: `${id}:choice:${index}:title`, children: presentation.title ?? option.label ?? shown(option.value) }), presentation.description && (_jsx("span", { "data-forming-choice-description": "", id: `${id}:choice:${index}:description`, children: presentation.description }))] })) : ((option.label ?? shown(option.value))) }), focused && checked && _jsx(Icon, { name: "check" })] }, index));
757
+ }) })] }));
758
+ else if (choice)
759
+ editor = (_jsxs(_Fragment, { children: [optionsSite && (_jsxs("label", { "data-forming-search": "", children: [text.search, _jsx("input", { type: "search", disabled: disabled, onChange: event => controller.send({ type: 'provider.search', site: optionsSite.id, query: event.target.value }) })] })), _jsxs("select", { ...common, multiple: multi, value: multi ? tokens : (tokens[0] ?? ''), onChange: event => {
760
+ if (!multi && event.target.value === '') {
761
+ controller.send({ type: 'clear', id: node.id });
762
+ return;
763
+ }
764
+ const values = Array.from(event.target.selectedOptions).map(option => choices[Number(option.value)].value);
765
+ commit(multi ? values : values[0]);
766
+ }, children: [!multi && _jsx("option", { value: "", children: text.choose }), choices.map((option, index) => (_jsx("option", { value: String(index), disabled: option.disabled || option.status === 'missing', children: option.label ?? shown(option.value) }, index)))] }), optionsSite && slots.some(slot => slot.operation === 'options.search' && slot.result?.nextCursor) && (_jsx("button", { type: "button", "data-forming-tool": "", disabled: disabled, onClick: () => controller.send({ type: 'provider.more', site: optionsSite.id }), children: text.more }))] }));
767
+ else if (scalarList)
768
+ editor = (_jsx(ScalarListEditor, { id: id, value: value, schema: schema.items, disabled: disabled, reset: resetKey, text: text, describedBy: common['aria-describedby'], commit: commit, invalid: invalid, exact: exact }));
769
+ else if (type === 'boolean')
770
+ editor = (_jsxs("select", { ...common, value: value === true ? 'true' : value === false ? 'false' : '', onChange: event => event.target.value ? commit(event.target.value === 'true') : controller.send({ type: 'clear', id: node.id }), children: [_jsx("option", { value: "", children: text.choose }), _jsx("option", { value: "true", children: text.trueValue }), _jsx("option", { value: "false", children: text.falseValue })] }));
771
+ else if (type === 'string' || type === 'number' || type === 'integer' || type === 'array')
772
+ editor = (_jsxs(_Fragment, { children: [standaloneTemporal ? null : node.control === 'textarea' || type === 'array' ? (_jsx("textarea", { ...common, ...composition, value: buffer, onChange: event => input(event.target.value) })) : (_jsx("input", { ...common, ...composition, type: node.control === 'password' ? 'password' : 'text', inputMode: inputMode(schema), value: buffer, onChange: event => input(event.target.value) })), temporal && (_jsx(TemporalPicker, { id: id, title: node.title ?? '', value: buffer, format: schema.format, disabled: disabled, reset: resetKey, text: text, describedBy: common['aria-describedby'], change: input, incomplete: () => invalid(true), standalone: standaloneTemporal }))] }));
773
+ else
774
+ editor = _jsx("p", { role: "alert", children: text.unsupported });
775
+ // Other fields: the drawing receives the built-in editor and decides where it
776
+ // goes, e.g. a search box above the usual address fields.
777
+ if (drawing && draw && !node.file)
778
+ editor = _jsx("div", { "data-forming-plugin": "", children: draw({ ...drawing, standard: editor }) });
779
+ const clearButton = !node.structure && !node.file && !draw && (_jsx("button", { type: "button", "data-forming-clear": "", "data-forming-tool": inline ? undefined : '', "aria-label": `${text.clear} ${node.title ?? ''}`.trim(), title: text.clear, disabled: disabled || (value === undefined && !buffer && !localError), onClick: () => {
780
+ if (controller.send({ type: 'clear', id: node.id })) {
781
+ setBuffer('');
782
+ invalid(false);
783
+ setEditorReset(value => value + 1);
784
+ }
785
+ }, children: inline ? _jsx(Icon, { name: "close" }) : text.clear }));
786
+ return (_jsxs("fieldset", { style: { minWidth: 0 }, "data-forming-field": "", "data-forming-node": node.id, "data-structure": node.structure, "data-invalid": bad ? '' : undefined, "data-readonly": state.readonly ? '' : undefined, id: node.structure || scalarList || cards ? id : undefined, tabIndex: -1, "aria-invalid": scalarList || cards ? bad : undefined, children: [node.structure || scalarList || cards ? (_jsxs("legend", { children: [node.title, state.required && (_jsx("span", { "data-forming-required": "", "aria-hidden": "true", children: ' *' }))] })) : (_jsxs("label", { htmlFor: standaloneTemporal ? `${id}:${schema.format === 'forming-local-time' ? 'time' : 'date'}` : id, children: [node.title, state.required && (_jsx("span", { "data-forming-required": "", "aria-hidden": "true", children: ' *' }))] })), inline ? (_jsxs("div", { "data-forming-control": "", children: [editor, clearButton] })) : (editor), _jsxs("p", { "data-forming-help": "", id: `${id}:help`, children: [help, exact && value === null ? ` ${text.nullValue}` : '', formatted && (_jsxs(_Fragment, { children: [help || (exact && value === null) ? ' ' : '', _jsx("span", { "data-forming-formatted": "", children: text.shownAs.replace('{value}', formatted) })] })), counter && (_jsx("span", { "data-forming-counter": "", "data-over": maxLength !== undefined && length > maxLength ? '' : undefined, children: counter }))] }), _jsxs("div", { "data-forming-errors": "", id: `${id}:errors`, "aria-live": "polite", children: [localError && _jsx("p", { children: text.invalid }), issues.map((issue, i) => (_jsx("p", { children: issue.message }, i)))] }), !inline && clearButton, exact && types.includes('null') && !node.structure && !node.file && (_jsx("button", { type: "button", "data-forming-tool": "", disabled: disabled, onClick: () => {
787
+ if (commit(null))
788
+ setEditorReset(value => value + 1);
789
+ }, children: text.setNull })), slots.map(slot => (_jsxs("div", { "data-forming-provider": slot.state, "aria-live": "polite", children: [['pending', 'scheduled'].includes(slot.state) && _jsx("span", { children: text.waiting }), slot.state === 'invalid' && _jsx("span", { children: slot.message }), slot.state === 'failed' && (_jsxs(_Fragment, { children: [_jsx("span", { children: slot.errorMessage ?? text.unavailable }), slot.error?.retryable && (_jsx("button", { type: "button", "data-forming-tool": "", disabled: disabled, onClick: () => controller.send({ type: 'provider.retry', site: slot.site, operation: slot.operation }), children: text.retry }))] }))] }, slot.site + slot.operation)))] }));
790
+ }