@formancy/angular 0.1.0 → 0.2.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.
@@ -1,6 +1,9 @@
1
1
  import * as i0 from '@angular/core';
2
- import { InjectionToken, inject, signal, DestroyRef, computed, input, ChangeDetectionStrategy, Component, ViewContainerRef, Directive, Injector, runInInjectionContext, effect, output, viewChild, afterRenderEffect } from '@angular/core';
2
+ import { InjectionToken, inject, signal, DestroyRef, computed, input, ChangeDetectionStrategy, Component, forwardRef, effect, viewChild, viewChildren, ViewContainerRef, Directive, Injector, runInInjectionContext, ChangeDetectorRef, ElementRef, output, afterRenderEffect } from '@angular/core';
3
3
  import { parsePath } from '@formancy/core';
4
+ import { parseRichText, acceptRemoteOptions, capRemoteOptions, narrowOptionsByLabel, applyRichCommand, datagridColumns, LAYOUT_LEAF_KINDS, layoutChildren, resolveText } from '@formancy/spec';
5
+ import { NgTemplateOutlet } from '@angular/common';
6
+ import { encode } from 'uqr';
4
7
 
5
8
  /**
6
9
  * The engine reaches components through DI, never through inputs drilling: a
@@ -55,17 +58,31 @@ function injectRepeater(path) {
55
58
  const parsed = typeof path === 'string' ? parsePath(path) : path;
56
59
  const rowCount = signal(engine.rowCount(parsed), /* @ts-ignore */
57
60
  ...(ngDevMode ? [{ debugName: "rowCount" }] : /* istanbul ignore next */ []));
61
+ // A counter bumped on every notification, not only when the LENGTH changes.
62
+ //
63
+ // `rowCount.set` with an equal value notifies nothing, which is correct and was also
64
+ // why reordering did not redraw: a move leaves the count alone, so the ids computed
65
+ // from it kept their old order and Angular tracked rows by them -- reusing the wrong
66
+ // DOM, which is the failure tracking by identity exists to prevent.
67
+ const version = signal(0, /* @ts-ignore */
68
+ ...(ngDevMode ? [{ debugName: "version" }] : /* istanbul ignore next */ []));
58
69
  const unsubscribe = engine.subscribeField(parsed, () => {
59
70
  rowCount.set(engine.rowCount(parsed));
71
+ version.update((previous) => previous + 1);
60
72
  });
61
73
  inject(DestroyRef).onDestroy(unsubscribe);
62
74
  return {
63
75
  rowCount: rowCount.asReadonly(),
64
- // Computed from rowCount: the engine mints an id when a row is created, so
65
- // the count changing is exactly when the ids change.
66
- rowIds: computed(() => Array.from({ length: rowCount() }, (_, index) => engine.rowId(parsed, index))),
76
+ // Read through `version` as well as `rowCount`. The comment here used to say the
77
+ // count changing is exactly when the ids change, which was true until rows could be
78
+ // reordered.
79
+ rowIds: computed(() => {
80
+ version();
81
+ return Array.from({ length: rowCount() }, (_, index) => engine.rowId(parsed, index));
82
+ }),
67
83
  addRow: () => engine.addRow(parsed),
68
84
  removeRow: (index) => engine.removeRow(parsed, index),
85
+ moveRow: (from, to) => engine.moveRow(parsed, from, to),
69
86
  };
70
87
  }
71
88
 
@@ -96,6 +113,14 @@ function injectWizard() {
96
113
  };
97
114
  }
98
115
 
116
+ /** Reveal enclosing tabs before focusing: hidden controls cannot emit focusin. */
117
+ function focusControl(control) {
118
+ if (control === null)
119
+ return;
120
+ control.dispatchEvent(new Event('formancy-reveal', { bubbles: true }));
121
+ control.focus();
122
+ }
123
+
99
124
  /**
100
125
  * Submit the form: touch everything, validate everything, and on failure move
101
126
  * focus to the first invalid control — the WCAG-required behaviour that every
@@ -119,7 +144,7 @@ function injectSubmit() {
119
144
  const firstInvalid = engine.firstInvalid();
120
145
  if (firstInvalid !== null) {
121
146
  const ids = engine.getFieldSnapshot(parsePath(firstInvalid)).ids;
122
- document.getElementById(ids.control)?.focus();
147
+ focusControl(document.getElementById(ids.control));
123
148
  }
124
149
  }
125
150
  }
@@ -140,6 +165,431 @@ function provideFormancyRegistry(registry) {
140
165
  return [{ provide: FORMANCY_REGISTRY, useValue: registry }];
141
166
  }
142
167
 
168
+ /**
169
+ * One run of inline nodes, recursing through its own selector.
170
+ *
171
+ * Angular has no fragment component, so emphasis inside emphasis is expressed
172
+ * by the component referring to itself — which is why it is its own
173
+ * declaration rather than part of the block template below.
174
+ */
175
+ class FormancyRichInline {
176
+ nodes = input.required(/* @ts-ignore */
177
+ ...(ngDevMode ? [{ debugName: "nodes" }] : /* istanbul ignore next */ []));
178
+ asText(node) {
179
+ return node.kind === 'text' ? node.text : '';
180
+ }
181
+ childrenOf(node) {
182
+ return node.kind === 'text' ? [] : node.children;
183
+ }
184
+ hrefOf(node) {
185
+ return node.kind === 'link' ? node.href : '';
186
+ }
187
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: FormancyRichInline, deps: [], target: i0.ɵɵFactoryTarget.Component });
188
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.7", type: FormancyRichInline, isStandalone: true, selector: "formancy-rich-inline", inputs: { nodes: { classPropertyName: "nodes", publicName: "nodes", isSignal: true, isRequired: true, transformFunction: null } }, ngImport: i0, template: `
189
+ @for (node of nodes(); track $index) {
190
+ @switch (node.kind) {
191
+ @case ('text') {
192
+ <!-- Interpolated. This is the line that makes the whole design safe,
193
+ so it is the one worth pointing at. -->
194
+ {{ asText(node) }}
195
+ }
196
+ @case ('strong') {
197
+ <strong><formancy-rich-inline [nodes]="childrenOf(node)" /></strong>
198
+ }
199
+ @case ('emphasis') {
200
+ <em><formancy-rich-inline [nodes]="childrenOf(node)" /></em>
201
+ }
202
+ @case ('link') {
203
+ <!-- rel, because the destination was written by whoever filled the
204
+ form in and the page showing it is usually an administrator's.
205
+ The parser has already refused anything but http, https and
206
+ mailto. -->
207
+ <a [href]="hrefOf(node)" rel="noreferrer noopener nofollow ugc">
208
+ <formancy-rich-inline [nodes]="childrenOf(node)" />
209
+ </a>
210
+ }
211
+ }
212
+ }
213
+ `, isInline: true, dependencies: [{ kind: "component", type: i0.forwardRef(() => FormancyRichInline), selector: "formancy-rich-inline", inputs: ["nodes"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
214
+ }
215
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: FormancyRichInline, decorators: [{
216
+ type: Component,
217
+ args: [{
218
+ selector: 'formancy-rich-inline',
219
+ changeDetection: ChangeDetectionStrategy.OnPush,
220
+ // Itself, through forwardRef. A standalone component that uses its own
221
+ // selector has to import itself or the nested tag is an unknown element:
222
+ // Angular renders it as an empty custom element and says nothing, so a link
223
+ // comes out as an anchor with no text in it rather than as an error.
224
+ imports: [forwardRef(() => FormancyRichInline)],
225
+ template: `
226
+ @for (node of nodes(); track $index) {
227
+ @switch (node.kind) {
228
+ @case ('text') {
229
+ <!-- Interpolated. This is the line that makes the whole design safe,
230
+ so it is the one worth pointing at. -->
231
+ {{ asText(node) }}
232
+ }
233
+ @case ('strong') {
234
+ <strong><formancy-rich-inline [nodes]="childrenOf(node)" /></strong>
235
+ }
236
+ @case ('emphasis') {
237
+ <em><formancy-rich-inline [nodes]="childrenOf(node)" /></em>
238
+ }
239
+ @case ('link') {
240
+ <!-- rel, because the destination was written by whoever filled the
241
+ form in and the page showing it is usually an administrator's.
242
+ The parser has already refused anything but http, https and
243
+ mailto. -->
244
+ <a [href]="hrefOf(node)" rel="noreferrer noopener nofollow ugc">
245
+ <formancy-rich-inline [nodes]="childrenOf(node)" />
246
+ </a>
247
+ }
248
+ }
249
+ }
250
+ `,
251
+ }]
252
+ }], propDecorators: { nodes: [{ type: i0.Input, args: [{ isSignal: true, alias: "nodes", required: true }] }] } });
253
+ /**
254
+ * Showing a `richtext` answer, from the same parser the React renderer uses.
255
+ *
256
+ * No `[innerHTML]`, and no `DomSanitizer` — there is nothing to sanitise. The
257
+ * stored answer is parsed into a typed tree in `@formancy/spec`, and every
258
+ * element here is created by Angular from that tree, so the characters
259
+ * somebody typed arrive as interpolated text and cannot become markup however
260
+ * they are spelled ([0052](../../../docs/decisions/0052-richtext-is-not-html.md)).
261
+ *
262
+ * Both renderers building the same tree into the same elements is the promise
263
+ * the engine makes across browser and server, applied to presentation: two
264
+ * implementations, one meaning.
265
+ */
266
+ class FormancyRichText {
267
+ source = input('', /* @ts-ignore */
268
+ ...(ngDevMode ? [{ debugName: "source" }] : /* istanbul ignore next */ []));
269
+ blocks = computed(() => parseRichText(this.source()), /* @ts-ignore */
270
+ ...(ngDevMode ? [{ debugName: "blocks" }] : /* istanbul ignore next */ []));
271
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: FormancyRichText, deps: [], target: i0.ɵɵFactoryTarget.Component });
272
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.7", type: FormancyRichText, isStandalone: true, selector: "formancy-rich-text", inputs: { source: { classPropertyName: "source", publicName: "source", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: `
273
+ @if (blocks().length > 0) {
274
+ <div data-formancy-part="richtext">
275
+ @for (block of blocks(); track $index) {
276
+ @if (block.kind === 'paragraph') {
277
+ <p><formancy-rich-inline [nodes]="block.children" /></p>
278
+ } @else if (block.ordered) {
279
+ <ol>
280
+ @for (item of block.items; track $index) {
281
+ <li><formancy-rich-inline [nodes]="item" /></li>
282
+ }
283
+ </ol>
284
+ } @else {
285
+ <ul>
286
+ @for (item of block.items; track $index) {
287
+ <li><formancy-rich-inline [nodes]="item" /></li>
288
+ }
289
+ </ul>
290
+ }
291
+ }
292
+ </div>
293
+ }
294
+ `, isInline: true, dependencies: [{ kind: "component", type: FormancyRichInline, selector: "formancy-rich-inline", inputs: ["nodes"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
295
+ }
296
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: FormancyRichText, decorators: [{
297
+ type: Component,
298
+ args: [{
299
+ selector: 'formancy-rich-text',
300
+ changeDetection: ChangeDetectionStrategy.OnPush,
301
+ imports: [FormancyRichInline],
302
+ template: `
303
+ @if (blocks().length > 0) {
304
+ <div data-formancy-part="richtext">
305
+ @for (block of blocks(); track $index) {
306
+ @if (block.kind === 'paragraph') {
307
+ <p><formancy-rich-inline [nodes]="block.children" /></p>
308
+ } @else if (block.ordered) {
309
+ <ol>
310
+ @for (item of block.items; track $index) {
311
+ <li><formancy-rich-inline [nodes]="item" /></li>
312
+ }
313
+ </ol>
314
+ } @else {
315
+ <ul>
316
+ @for (item of block.items; track $index) {
317
+ <li><formancy-rich-inline [nodes]="item" /></li>
318
+ }
319
+ </ul>
320
+ }
321
+ }
322
+ </div>
323
+ }
324
+ `,
325
+ }]
326
+ }], propDecorators: { source: [{ type: i0.Input, args: [{ isSignal: true, alias: "source", required: false }] }] } });
327
+
328
+ /**
329
+ * Optional by design, and its absence costs less than the uploader's.
330
+ *
331
+ * A missing uploader makes a file field read-only, because there is nowhere to
332
+ * put the bytes. A missing editor costs only the WYSIWYG surface: the answer is
333
+ * still editable, still valid, and still the same grammar.
334
+ */
335
+ const FORMANCY_RICH_TEXT_EDITOR = new InjectionToken('formancy.richTextEditor');
336
+ function injectRichTextEditorFactory() {
337
+ return inject(FORMANCY_RICH_TEXT_EDITOR, { optional: true });
338
+ }
339
+ /** Providing one, for a host that wants the contenteditable surface. */
340
+ function provideFormancyRichTextEditor(make) {
341
+ return { provide: FORMANCY_RICH_TEXT_EDITOR, useValue: make };
342
+ }
343
+
344
+ /**
345
+ * Optional by design. A `scanner` widget with no scanner behind it renders the ordinary
346
+ * text input and no button — typing was always the field's primary route, so there is
347
+ * nothing to disable and a Scan button that opened nothing would be worse.
348
+ */
349
+ const FORMANCY_SCANNER = new InjectionToken('formancy.scanner');
350
+ function injectScanner() {
351
+ return inject(FORMANCY_SCANNER, { optional: true });
352
+ }
353
+ /** Providing one, for a host that has a camera and a decoder. */
354
+ function provideFormancyScanner(scan) {
355
+ return { provide: FORMANCY_SCANNER, useValue: scan };
356
+ }
357
+
358
+ /**
359
+ * Optional, and what its absence costs depends on the document. A form with no
360
+ * sourced field never needs one; a field that names a source renders a message where
361
+ * its chooser would be, exactly as the file field does without an uploader.
362
+ */
363
+ const FORMANCY_OPTIONS_SOURCES = new InjectionToken('formancy.optionsSources');
364
+ function injectOptionsSources() {
365
+ return inject(FORMANCY_OPTIONS_SOURCES, { optional: true });
366
+ }
367
+ /** Providing them, for a host that has the lists a document names. */
368
+ function provideFormancyOptionsSources(sources) {
369
+ return { provide: FORMANCY_OPTIONS_SOURCES, useValue: sources };
370
+ }
371
+
372
+ const DEBOUNCE_MS = 250;
373
+ const MIN_QUERY = 0;
374
+ const MAX_ROWS = 50;
375
+ /**
376
+ * Wire a field's remote options up.
377
+ *
378
+ * Called from a component's field context, so it may inject. `query` is the signal
379
+ * the control types into; a plain select passes a constant empty one, which asks the
380
+ * source for everything and shows what fits.
381
+ */
382
+ function injectSourcedOptions(definition,
383
+ /**
384
+ * The field's DATA PATH — `canton`, or `people[1].canton` inside a repeater.
385
+ *
386
+ * Which is what `OptionsRequest.path` promises. Both renderers sent the field KEY,
387
+ * so a resolver could not tell two same-named sourced fields apart and never saw
388
+ * which row it was answering for.
389
+ */
390
+ dataPath, storedValue, query, locale,
391
+ /**
392
+ * Whether this instance is the one that will render.
393
+ *
394
+ * A select that carries `widget: "typeahead"` delegates to another component, and
395
+ * both extend the same base — so both wire this up and both ask. React added the
396
+ * same flag for the same reason; without it a sourced typeahead sent every request
397
+ * twice, and one set of answers was read by nobody.
398
+ */
399
+ enabled) {
400
+ const sources = injectOptionsSources();
401
+ const destroyRef = inject(DestroyRef);
402
+ const rows = signal([], /* @ts-ignore */
403
+ ...(ngDevMode ? [{ debugName: "rows" }] : /* istanbul ignore next */ []));
404
+ const named = signal(new Map(), /* @ts-ignore */
405
+ ...(ngDevMode ? [{ debugName: "named" }] : /* istanbul ignore next */ []));
406
+ const busy = signal(false, /* @ts-ignore */
407
+ ...(ngDevMode ? [{ debugName: "busy" }] : /* istanbul ignore next */ []));
408
+ const failed = signal(false, /* @ts-ignore */
409
+ ...(ngDevMode ? [{ debugName: "failed" }] : /* istanbul ignore next */ []));
410
+ const capped = signal(null, /* @ts-ignore */
411
+ ...(ngDevMode ? [{ debugName: "capped" }] : /* istanbul ignore next */ []));
412
+ const source = () => {
413
+ const name = definition().optionsSource;
414
+ return name === undefined ? undefined : sources?.[name];
415
+ };
416
+ let generation = 0;
417
+ let inFlight;
418
+ let timer;
419
+ /** Every labels request still in the air, so a destroy can abort all of them. */
420
+ const labelRequests = new Set();
421
+ const stop = () => {
422
+ if (timer !== undefined)
423
+ clearTimeout(timer);
424
+ inFlight?.abort();
425
+ };
426
+ destroyRef.onDestroy(() => {
427
+ stop();
428
+ for (const controller of labelRequests)
429
+ controller.abort();
430
+ labelRequests.clear();
431
+ });
432
+ // The search, debounced and superseded by ABORTING rather than by ignoring: a
433
+ // request nobody wants any more is one a host should be able to cancel, and the
434
+ // signal is the only way to say so.
435
+ effect(() => {
436
+ const resolver = source();
437
+ const text = query();
438
+ const name = definition().optionsSource ?? '';
439
+ const path = dataPath();
440
+ if (!enabled() || resolver === undefined)
441
+ return;
442
+ const minQueryLength = resolver.minQueryLength ?? MIN_QUERY;
443
+ const maxRows = resolver.maxRows ?? MAX_ROWS;
444
+ stop();
445
+ // Bumped BEFORE the early return, not after it. Deleting characters back under the
446
+ // minimum aborts whatever is in flight, and with the generation unchanged that
447
+ // abort arrived at a handler that still believed it was current -- so backspacing
448
+ // reported the source as broken, and left the box marked busy with nothing in
449
+ // flight.
450
+ const mine = (generation += 1);
451
+ if (text.trim().length < minQueryLength) {
452
+ rows.set([]);
453
+ capped.set(null);
454
+ busy.set(false);
455
+ return;
456
+ }
457
+ const controller = new AbortController();
458
+ inFlight = controller;
459
+ timer = setTimeout(() => {
460
+ busy.set(true);
461
+ failed.set(false);
462
+ void resolver
463
+ .resolve({
464
+ kind: 'search',
465
+ source: name,
466
+ path,
467
+ query: text,
468
+ values: [],
469
+ locale: locale(),
470
+ limit: maxRows,
471
+ signal: controller.signal,
472
+ })
473
+ .then((answer) => {
474
+ if (mine !== generation)
475
+ return;
476
+ // Refused whole rather than filtered: a partial list silently lacks the row
477
+ // somebody came for, and they cannot tell that from a source that has none.
478
+ const accepted = acceptRemoteOptions(answer);
479
+ if (accepted === undefined) {
480
+ failed.set(true);
481
+ rows.set([]);
482
+ capped.set(null);
483
+ return;
484
+ }
485
+ const result = capRemoteOptions(accepted, maxRows);
486
+ rows.set(result.shown);
487
+ capped.set(result.capped ? { shown: result.shown.length, total: result.total } : null);
488
+ })
489
+ .catch(() => {
490
+ if (mine !== generation)
491
+ return;
492
+ failed.set(true);
493
+ rows.set([]);
494
+ })
495
+ .finally(() => {
496
+ if (mine === generation)
497
+ busy.set(false);
498
+ });
499
+ }, resolver.debounceMs ?? DEBOUNCE_MS);
500
+ });
501
+ // The names of what is already stored. Without this, a resumed draft, a wizard page
502
+ // change or a datagrid row move — which remounts every control in the row by design
503
+ // — renders an empty box over an answer the form holds.
504
+ //
505
+ // Asked at most ONCE per value, and remembered as "asked" rather than inferred from
506
+ // the answer. A source is entitled not to know a value: a resumed form may hold one
507
+ // the list no longer offers, and a host may implement only `kind: 'search'`. Inferring
508
+ // from the answer meant the map was replaced, the signal changed, the effect re-ran
509
+ // and it asked again — measured in the React binding at 602 requests in 300ms.
510
+ const askedFor = new Set();
511
+ effect(() => {
512
+ const resolver = source();
513
+ const value = storedValue();
514
+ if (!enabled() || resolver === undefined || value === undefined)
515
+ return;
516
+ if (named().has(value) || askedFor.has(value))
517
+ return;
518
+ askedFor.add(value);
519
+ // Registered, so a destroy aborts it. The search request was; this one was not,
520
+ // which left a request outliving the component that asked for it -- and a host
521
+ // honouring the signal had no way to know nobody was listening any more.
522
+ const controller = new AbortController();
523
+ labelRequests.add(controller);
524
+ void resolver
525
+ .resolve({
526
+ kind: 'labels',
527
+ source: definition().optionsSource ?? '',
528
+ path: dataPath(),
529
+ query: '',
530
+ values: [value],
531
+ locale: locale(),
532
+ limit: 1,
533
+ signal: controller.signal,
534
+ })
535
+ .then((answer) => {
536
+ const accepted = acceptRemoteOptions(answer);
537
+ if (accepted === undefined)
538
+ return;
539
+ const next = new Map(named());
540
+ for (const row of accepted)
541
+ next.set(row.value, row.label);
542
+ named.set(next);
543
+ })
544
+ // A name that does not arrive is not an error anybody can act on: the control
545
+ // shows the raw value, exactly as it does for an option with no label.
546
+ .catch(() => undefined)
547
+ .finally(() => labelRequests.delete(controller));
548
+ });
549
+ return {
550
+ rows: rows.asReadonly(),
551
+ named: named.asReadonly(),
552
+ busy: busy.asReadonly(),
553
+ // `computed`, not a bare arrow: the template reads these, and a plain function
554
+ // would re-run on every change detection instead of when its inputs move.
555
+ sourced: computed(() => definition().optionsSource !== undefined),
556
+ unavailable: computed(() => definition().optionsSource !== undefined && source() === undefined),
557
+ status: computed(() => {
558
+ const resolver = source();
559
+ if (resolver === undefined)
560
+ return '';
561
+ if (failed())
562
+ return 'The options could not be loaded. Type to try again.';
563
+ const needs = resolver.minQueryLength ?? MIN_QUERY;
564
+ if (query().trim().length < needs)
565
+ return `Type at least ${String(needs)} characters to search.`;
566
+ // Set only once a request is actually on its way — after the debounce — so
567
+ // nothing announces per keystroke.
568
+ if (busy())
569
+ return 'Searching…';
570
+ const limit = capped();
571
+ if (limit !== null) {
572
+ return `Showing the first ${String(limit.shown)} of ${String(limit.total)} — keep typing to narrow.`;
573
+ }
574
+ return '';
575
+ }),
576
+ };
577
+ }
578
+
579
+ /**
580
+ * Optional by design. A form with no file fields needs no uploader, and a file
581
+ * field without one renders read-only and says why — which beats turning a
582
+ * form that mostly works into a failed injection.
583
+ */
584
+ const FORMANCY_UPLOADER = new InjectionToken('formancy.uploader');
585
+ function injectUploader() {
586
+ return inject(FORMANCY_UPLOADER, { optional: true });
587
+ }
588
+ /** Providing one, for a host that has somewhere to put bytes. */
589
+ function provideFormancyUploader(upload) {
590
+ return { provide: FORMANCY_UPLOADER, useValue: upload };
591
+ }
592
+
143
593
  /**
144
594
  * The built-in unstyled field components — the Angular rendering of the same
145
595
  * decisions React's defaults made. Zero CSS; `data-formancy-part` is the
@@ -157,6 +607,9 @@ class FormancyFieldShell {
157
607
  ...(ngDevMode ? [{ debugName: "field" }] : /* istanbul ignore next */ []));
158
608
  label = input.required(/* @ts-ignore */
159
609
  ...(ngDevMode ? [{ debugName: "label" }] : /* istanbul ignore next */ []));
610
+ /** Inert here; read by tools outside the renderer. See FormancyLayout. */
611
+ path = input('', /* @ts-ignore */
612
+ ...(ngDevMode ? [{ debugName: "path" }] : /* istanbul ignore next */ []));
160
613
  showError = computed(() => {
161
614
  const snapshot = this.field().snapshot();
162
615
  return snapshot.touched && snapshot.errors.length > 0;
@@ -165,8 +618,12 @@ class FormancyFieldShell {
165
618
  errorText = computed(() => this.field().snapshot().errors.join(', '), /* @ts-ignore */
166
619
  ...(ngDevMode ? [{ debugName: "errorText" }] : /* istanbul ignore next */ []));
167
620
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: FormancyFieldShell, deps: [], target: i0.ɵɵFactoryTarget.Component });
168
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.7", type: FormancyFieldShell, isStandalone: true, selector: "formancy-field-shell", inputs: { field: { classPropertyName: "field", publicName: "field", isSignal: true, isRequired: true, transformFunction: null }, label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: true, transformFunction: null } }, ngImport: i0, template: `
169
- <div data-formancy-part="field" [attr.data-state]="showError() ? 'invalid' : 'valid'">
621
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.7", type: FormancyFieldShell, isStandalone: true, selector: "formancy-field-shell", inputs: { field: { classPropertyName: "field", publicName: "field", isSignal: true, isRequired: true, transformFunction: null }, label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: true, transformFunction: null }, path: { classPropertyName: "path", publicName: "path", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: `
622
+ <div
623
+ data-formancy-part="field"
624
+ [attr.data-formancy-field-path]="path()"
625
+ [attr.data-state]="showError() ? 'invalid' : 'valid'"
626
+ >
170
627
  <label data-formancy-part="label" [id]="field().snapshot().props.label.id" [attr.for]="field().snapshot().props.label.for">{{ label() }}</label>
171
628
  <ng-content />
172
629
  @if (showError()) {
@@ -181,7 +638,11 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImpor
181
638
  selector: 'formancy-field-shell',
182
639
  changeDetection: ChangeDetectionStrategy.OnPush,
183
640
  template: `
184
- <div data-formancy-part="field" [attr.data-state]="showError() ? 'invalid' : 'valid'">
641
+ <div
642
+ data-formancy-part="field"
643
+ [attr.data-formancy-field-path]="path()"
644
+ [attr.data-state]="showError() ? 'invalid' : 'valid'"
645
+ >
185
646
  <label data-formancy-part="label" [id]="field().snapshot().props.label.id" [attr.for]="field().snapshot().props.label.for">{{ label() }}</label>
186
647
  <ng-content />
187
648
  @if (showError()) {
@@ -190,7 +651,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImpor
190
651
  </div>
191
652
  `,
192
653
  }]
193
- }], propDecorators: { field: [{ type: i0.Input, args: [{ isSignal: true, alias: "field", required: true }] }], label: [{ type: i0.Input, args: [{ isSignal: true, alias: "label", required: true }] }] } });
654
+ }], propDecorators: { field: [{ type: i0.Input, args: [{ isSignal: true, alias: "field", required: true }] }], label: [{ type: i0.Input, args: [{ isSignal: true, alias: "label", required: true }] }], path: [{ type: i0.Input, args: [{ isSignal: true, alias: "path", required: false }] }] } });
194
655
  /** The state every leaf control shares; components extend it so the templates
195
656
  * stay the only per-type code. Context comes through DI (see registry.ts). */
196
657
  class FieldComponentBase {
@@ -209,19 +670,139 @@ class FieldComponentBase {
209
670
  label: this.engine.text(option.label) ?? option.value,
210
671
  })), /* @ts-ignore */
211
672
  ...(ngDevMode ? [{ debugName: "options" }] : /* istanbul ignore next */ []));
673
+ /**
674
+ * What the control types into, when it has something to type into.
675
+ *
676
+ * A plain `<select>` never writes to it, so its source is asked for everything and
677
+ * shows what fits; the typeahead writes every keystroke.
678
+ */
679
+ sourceQuery = signal('', /* @ts-ignore */
680
+ ...(ngDevMode ? [{ debugName: "sourceQuery" }] : /* istanbul ignore next */ []));
681
+ /** The remote half, or an inert one for the ordinary field that lists its options. */
682
+ remote = injectSourcedOptions(computed(() => {
683
+ const def = this.field.snapshot().def;
684
+ return { key: def.key, ...(def.optionsSource === undefined ? {} : { optionsSource: def.optionsSource }) };
685
+ }), () => this.context.path, computed(() => {
686
+ const value = this.field.snapshot().value;
687
+ return typeof value === 'string' && value !== '' ? value : undefined;
688
+ }), this.sourceQuery, () => this.engine.locale(), computed(() => this.sourceEnabled()));
689
+ /**
690
+ * Whether THIS component is the one that will render the field.
691
+ *
692
+ * True for every control but the select that hands over to the typeahead, which
693
+ * overrides it. Both extend this base, so without the distinction a sourced
694
+ * typeahead asked its source twice and read one set of answers.
695
+ */
696
+ sourceEnabled() {
697
+ return true;
698
+ }
699
+ /**
700
+ * The options to offer: the document's, or the deployment's.
701
+ *
702
+ * The stored answer is always offerable even when the current query does not match
703
+ * it — a control that dropped it would show an empty box over an answer the form
704
+ * holds, and the next blur would look like the person cleared it.
705
+ */
706
+ offered = computed(() => {
707
+ if (!this.remote.sourced())
708
+ return this.options();
709
+ const rows = [...this.remote.rows()];
710
+ const stored = this.field.snapshot().value;
711
+ if (typeof stored === 'string' && stored !== '' && !rows.some((row) => row.value === stored)) {
712
+ rows.unshift({ value: stored, label: this.remote.named().get(stored) ?? stored });
713
+ }
714
+ return rows;
715
+ }, /* @ts-ignore */
716
+ ...(ngDevMode ? [{ debugName: "offered" }] : /* istanbul ignore next */ []));
212
717
  }
718
+ /**
719
+ * A single-line answer, and — with `widget: "scanner"` — a camera route to the same
720
+ * string.
721
+ *
722
+ * The input is the control in both cases, never a second one beside it: typing is the
723
+ * accessibility floor and the fallback at once, so it is what is always there and the
724
+ * scan button is what is sometimes added. With no scanner supplied the markup is the
725
+ * default control exactly, because a Scan button that opens nothing is worse than no
726
+ * button ([0071](../../../docs/decisions/0071-a-scanner-is-supplied-not-built.md)).
727
+ *
728
+ * The React binding renders the same three elements for the same reasons.
729
+ */
213
730
  class FormancyTextField extends FieldComponentBase {
731
+ scan = injectScanner();
732
+ scanning = signal(false, /* @ts-ignore */
733
+ ...(ngDevMode ? [{ debugName: "scanning" }] : /* istanbul ignore next */ []));
734
+ /** A device failure, held here rather than in the field's errors. See above. */
735
+ trouble = signal(undefined, /* @ts-ignore */
736
+ ...(ngDevMode ? [{ debugName: "trouble" }] : /* istanbul ignore next */ []));
214
737
  text = computed(() => {
215
738
  const value = this.field.snapshot().value;
216
739
  return typeof value === 'string' ? value : '';
217
740
  }, /* @ts-ignore */
218
741
  ...(ngDevMode ? [{ debugName: "text" }] : /* istanbul ignore next */ []));
742
+ /**
743
+ * A computed over the field's SIGNAL, not over `engine.getFieldSnapshot` — the
744
+ * widget can arrive with a new document, and a plain method call is not a
745
+ * dependency an OnPush component re-runs for.
746
+ */
747
+ scannable = computed(() => this.scan !== null && this.field.snapshot().def.widget === 'scanner', /* @ts-ignore */
748
+ ...(ngDevMode ? [{ debugName: "scannable" }] : /* istanbul ignore next */ []));
749
+ status = computed(() => this.scanning() ? 'Scanning…' : (this.trouble() ?? ''), /* @ts-ignore */
750
+ ...(ngDevMode ? [{ debugName: "status" }] : /* istanbul ignore next */ []));
219
751
  onInput(event) {
220
- this.field.setValue(event.target.value);
752
+ this.commit(event.target.value);
753
+ }
754
+ /**
755
+ * The ONE place a text field's answer is written, typed or scanned.
756
+ *
757
+ * Structural rather than careful: the parameter is a `string`, so there is no path
758
+ * from the camera to `setValue` that could store something typing could not — the
759
+ * line a widget may never cross
760
+ * ([0065](../../../docs/decisions/0065-a-widget-is-authored-not-registered.md)).
761
+ */
762
+ commit(text) {
763
+ // See the React binding: `<input type="text">` strips CR and LF from anything typed or
764
+ // pasted, so a multi-line code payload stored verbatim would be an answer the default
765
+ // control cannot produce -- the one thing a widget may never do.
766
+ this.field.setValue(text.replace(/[\r\n]/g, ''));
767
+ }
768
+ async read() {
769
+ // Re-entrancy, which `disabled` used to prevent: a second press while a scan is in
770
+ // flight is ignored rather than opening a second camera session.
771
+ if (this.scan === null || this.scanning())
772
+ return;
773
+ this.scanning.set(true);
774
+ this.trouble.set(undefined);
775
+ try {
776
+ const text = await this.scan({ label: this.context.label, path: this.context.path });
777
+ // Nobody scanned anything: the sheet was closed, or they changed their mind.
778
+ // Not a failure, and an apology in a live region for a decision somebody made
779
+ // on purpose is noise.
780
+ if (text === null)
781
+ return;
782
+ if (typeof text !== 'string') {
783
+ // A host written in plain JavaScript can resolve with anything. Reported as
784
+ // the device failure it is, rather than stored — an object in a text field is
785
+ // exactly what `commit` exists to make impossible.
786
+ this.trouble.set('Scanning did not work: the scanner did not return text. Type the value instead.');
787
+ return;
788
+ }
789
+ // Stored as typed, THEN touched — so a value the field's `pattern` refuses
790
+ // shows the engine's own error rather than being dropped. Dropping it would
791
+ // discard the only record of what the camera read and leave the field looking
792
+ // empty, which is the worse of the two failures by some distance.
793
+ this.commit(text);
794
+ this.field.touch();
795
+ }
796
+ catch (error) {
797
+ this.trouble.set(`Scanning did not work: ${error instanceof Error ? error.message : String(error)}. Type the value instead.`);
798
+ }
799
+ finally {
800
+ this.scanning.set(false);
801
+ }
221
802
  }
222
803
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: FormancyTextField, deps: null, target: i0.ɵɵFactoryTarget.Component });
223
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "22.1.7", type: FormancyTextField, isStandalone: true, selector: "formancy-text-field", usesInheritance: true, ngImport: i0, template: `
224
- <formancy-field-shell [field]="field" [label]="context.label">
804
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.7", type: FormancyTextField, isStandalone: true, selector: "formancy-text-field", usesInheritance: true, ngImport: i0, template: `
805
+ <formancy-field-shell [field]="field" [label]="context.label" [path]="context.path">
225
806
  <input
226
807
  type="text"
227
808
  [id]="control().id"
@@ -234,8 +815,29 @@ class FormancyTextField extends FieldComponentBase {
234
815
  (input)="onInput($event)"
235
816
  (blur)="field.touch()"
236
817
  />
818
+ @if (scannable()) {
819
+ <!-- The word is the visible label and the field's name completes the
820
+ accessible one, so three scannable fields on a page do not offer three
821
+ buttons called "Scan" — and the visible text is still contained in the
822
+ accessible name (WCAG 2.5.3). One line, because element-internal
823
+ whitespace leaks into the accessible name. -->
824
+ <!-- Disabled only when the FIELD is, never while scanning: disabling the button
825
+ somebody just pressed blurs it and the browser resets focus to the document
826
+ body, so a keyboard user is returned to the top of the page and told to type
827
+ instead into a field they must find again. Busy is said with aria-busy, which
828
+ does not touch focus, and the guard in read() does what disabled was doing.
829
+ Kept on one line because element-internal whitespace leaks into the accessible
830
+ name. -->
831
+ <button type="button" data-formancy-part="scanner-button" [disabled]="field.snapshot().disabled" [attr.aria-busy]="scanning() ? 'true' : null" (click)="read()">Scan <span data-formancy-part="visually-hidden">{{ context.label }}</span></button>
832
+ <!-- The camera's own progress and its failures, in this field's polite
833
+ region. NOT the error region: that one is the control's describedby
834
+ target, it holds the engine's verdicts, and a refused permission put
835
+ there would describe a hardware problem as a wrong answer. The same
836
+ shape the file field uses for an upload. -->
837
+ <p role="status" data-formancy-part="scanner-status">{{ status() }}</p>
838
+ }
237
839
  </formancy-field-shell>
238
- `, isInline: true, dependencies: [{ kind: "component", type: FormancyFieldShell, selector: "formancy-field-shell", inputs: ["field", "label"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
840
+ `, isInline: true, dependencies: [{ kind: "component", type: FormancyFieldShell, selector: "formancy-field-shell", inputs: ["field", "label", "path"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
239
841
  }
240
842
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: FormancyTextField, decorators: [{
241
843
  type: Component,
@@ -244,7 +846,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImpor
244
846
  changeDetection: ChangeDetectionStrategy.OnPush,
245
847
  imports: [FormancyFieldShell],
246
848
  template: `
247
- <formancy-field-shell [field]="field" [label]="context.label">
849
+ <formancy-field-shell [field]="field" [label]="context.label" [path]="context.path">
248
850
  <input
249
851
  type="text"
250
852
  [id]="control().id"
@@ -257,6 +859,27 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImpor
257
859
  (input)="onInput($event)"
258
860
  (blur)="field.touch()"
259
861
  />
862
+ @if (scannable()) {
863
+ <!-- The word is the visible label and the field's name completes the
864
+ accessible one, so three scannable fields on a page do not offer three
865
+ buttons called "Scan" — and the visible text is still contained in the
866
+ accessible name (WCAG 2.5.3). One line, because element-internal
867
+ whitespace leaks into the accessible name. -->
868
+ <!-- Disabled only when the FIELD is, never while scanning: disabling the button
869
+ somebody just pressed blurs it and the browser resets focus to the document
870
+ body, so a keyboard user is returned to the top of the page and told to type
871
+ instead into a field they must find again. Busy is said with aria-busy, which
872
+ does not touch focus, and the guard in read() does what disabled was doing.
873
+ Kept on one line because element-internal whitespace leaks into the accessible
874
+ name. -->
875
+ <button type="button" data-formancy-part="scanner-button" [disabled]="field.snapshot().disabled" [attr.aria-busy]="scanning() ? 'true' : null" (click)="read()">Scan <span data-formancy-part="visually-hidden">{{ context.label }}</span></button>
876
+ <!-- The camera's own progress and its failures, in this field's polite
877
+ region. NOT the error region: that one is the control's describedby
878
+ target, it holds the engine's verdicts, and a refused permission put
879
+ there would describe a hardware problem as a wrong answer. The same
880
+ shape the file field uses for an upload. -->
881
+ <p role="status" data-formancy-part="scanner-status">{{ status() }}</p>
882
+ }
260
883
  </formancy-field-shell>
261
884
  `,
262
885
  }]
@@ -272,7 +895,7 @@ class FormancyTextareaField extends FieldComponentBase {
272
895
  }
273
896
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: FormancyTextareaField, deps: null, target: i0.ɵɵFactoryTarget.Component });
274
897
  static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "22.1.7", type: FormancyTextareaField, isStandalone: true, selector: "formancy-textarea-field", usesInheritance: true, ngImport: i0, template: `
275
- <formancy-field-shell [field]="field" [label]="context.label">
898
+ <formancy-field-shell [field]="field" [label]="context.label" [path]="context.path">
276
899
  <textarea
277
900
  [id]="control().id"
278
901
  [attr.name]="control().name"
@@ -285,7 +908,7 @@ class FormancyTextareaField extends FieldComponentBase {
285
908
  (blur)="field.touch()"
286
909
  ></textarea>
287
910
  </formancy-field-shell>
288
- `, isInline: true, dependencies: [{ kind: "component", type: FormancyFieldShell, selector: "formancy-field-shell", inputs: ["field", "label"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
911
+ `, isInline: true, dependencies: [{ kind: "component", type: FormancyFieldShell, selector: "formancy-field-shell", inputs: ["field", "label", "path"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
289
912
  }
290
913
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: FormancyTextareaField, decorators: [{
291
914
  type: Component,
@@ -294,7 +917,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImpor
294
917
  changeDetection: ChangeDetectionStrategy.OnPush,
295
918
  imports: [FormancyFieldShell],
296
919
  template: `
297
- <formancy-field-shell [field]="field" [label]="context.label">
920
+ <formancy-field-shell [field]="field" [label]="context.label" [path]="context.path">
298
921
  <textarea
299
922
  [id]="control().id"
300
923
  [attr.name]="control().name"
@@ -322,7 +945,7 @@ class FormancyNumberField extends FieldComponentBase {
322
945
  }
323
946
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: FormancyNumberField, deps: null, target: i0.ɵɵFactoryTarget.Component });
324
947
  static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "22.1.7", type: FormancyNumberField, isStandalone: true, selector: "formancy-number-field", usesInheritance: true, ngImport: i0, template: `
325
- <formancy-field-shell [field]="field" [label]="context.label">
948
+ <formancy-field-shell [field]="field" [label]="context.label" [path]="context.path">
326
949
  <input
327
950
  type="number"
328
951
  [id]="control().id"
@@ -336,7 +959,7 @@ class FormancyNumberField extends FieldComponentBase {
336
959
  (blur)="field.touch()"
337
960
  />
338
961
  </formancy-field-shell>
339
- `, isInline: true, dependencies: [{ kind: "component", type: FormancyFieldShell, selector: "formancy-field-shell", inputs: ["field", "label"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
962
+ `, isInline: true, dependencies: [{ kind: "component", type: FormancyFieldShell, selector: "formancy-field-shell", inputs: ["field", "label", "path"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
340
963
  }
341
964
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: FormancyNumberField, decorators: [{
342
965
  type: Component,
@@ -345,7 +968,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImpor
345
968
  changeDetection: ChangeDetectionStrategy.OnPush,
346
969
  imports: [FormancyFieldShell],
347
970
  template: `
348
- <formancy-field-shell [field]="field" [label]="context.label">
971
+ <formancy-field-shell [field]="field" [label]="context.label" [path]="context.path">
349
972
  <input
350
973
  type="number"
351
974
  [id]="control().id"
@@ -365,12 +988,28 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImpor
365
988
  class FormancyCheckboxField extends FieldComponentBase {
366
989
  checked = computed(() => this.field.snapshot().value === true, /* @ts-ignore */
367
990
  ...(ngDevMode ? [{ debugName: "checked" }] : /* istanbul ignore next */ []));
991
+ /**
992
+ * `widget: "toggle"` is a part name and NOT `role="switch"`.
993
+ *
994
+ * ARIA's switch means a control that takes effect when you operate it, and a form
995
+ * field sets a value submitted later or never — so announcing "switch" describes
996
+ * it incorrectly to the people who rely on the description. A role is also not
997
+ * paint: changing it would make this the first widget to change what a control
998
+ * claims to be, which is the line the widget mechanism exists to hold. The switch
999
+ * is CSS, and conformance keeps finding this by role `checkbox` either way.
1000
+ *
1001
+ * Null rather than absent when there is no widget, because Angular omits an
1002
+ * attribute bound to null — which is what keeps an ordinary checkbox's markup
1003
+ * exactly as it was.
1004
+ */
1005
+ part = computed(() => this.field.snapshot().def.widget === 'toggle' ? 'toggle' : null, /* @ts-ignore */
1006
+ ...(ngDevMode ? [{ debugName: "part" }] : /* istanbul ignore next */ []));
368
1007
  onChange(event) {
369
1008
  this.field.setValue(event.target.checked);
370
1009
  }
371
1010
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: FormancyCheckboxField, deps: null, target: i0.ɵɵFactoryTarget.Component });
372
1011
  static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "22.1.7", type: FormancyCheckboxField, isStandalone: true, selector: "formancy-checkbox-field", usesInheritance: true, ngImport: i0, template: `
373
- <formancy-field-shell [field]="field" [label]="context.label">
1012
+ <formancy-field-shell [field]="field" [label]="context.label" [path]="context.path">
374
1013
  <input
375
1014
  type="checkbox"
376
1015
  [id]="control().id"
@@ -378,13 +1017,14 @@ class FormancyCheckboxField extends FieldComponentBase {
378
1017
  [attr.aria-invalid]="control()['aria-invalid']"
379
1018
  [attr.aria-required]="control()['aria-required']"
380
1019
  [attr.aria-describedby]="control()['aria-describedby']"
1020
+ [attr.data-formancy-part]="part()"
381
1021
  [disabled]="control().disabled === true"
382
1022
  [checked]="checked()"
383
1023
  (change)="onChange($event)"
384
1024
  (blur)="field.touch()"
385
1025
  />
386
1026
  </formancy-field-shell>
387
- `, isInline: true, dependencies: [{ kind: "component", type: FormancyFieldShell, selector: "formancy-field-shell", inputs: ["field", "label"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1027
+ `, isInline: true, dependencies: [{ kind: "component", type: FormancyFieldShell, selector: "formancy-field-shell", inputs: ["field", "label", "path"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
388
1028
  }
389
1029
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: FormancyCheckboxField, decorators: [{
390
1030
  type: Component,
@@ -393,7 +1033,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImpor
393
1033
  changeDetection: ChangeDetectionStrategy.OnPush,
394
1034
  imports: [FormancyFieldShell],
395
1035
  template: `
396
- <formancy-field-shell [field]="field" [label]="context.label">
1036
+ <formancy-field-shell [field]="field" [label]="context.label" [path]="context.path">
397
1037
  <input
398
1038
  type="checkbox"
399
1039
  [id]="control().id"
@@ -401,6 +1041,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImpor
401
1041
  [attr.aria-invalid]="control()['aria-invalid']"
402
1042
  [attr.aria-required]="control()['aria-required']"
403
1043
  [attr.aria-describedby]="control()['aria-describedby']"
1044
+ [attr.data-formancy-part]="part()"
404
1045
  [disabled]="control().disabled === true"
405
1046
  [checked]="checked()"
406
1047
  (change)="onChange($event)"
@@ -422,7 +1063,7 @@ class FormancyDateField extends FieldComponentBase {
422
1063
  }
423
1064
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: FormancyDateField, deps: null, target: i0.ɵɵFactoryTarget.Component });
424
1065
  static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "22.1.7", type: FormancyDateField, isStandalone: true, selector: "formancy-date-field", usesInheritance: true, ngImport: i0, template: `
425
- <formancy-field-shell [field]="field" [label]="context.label">
1066
+ <formancy-field-shell [field]="field" [label]="context.label" [path]="context.path">
426
1067
  <input
427
1068
  type="date"
428
1069
  [id]="control().id"
@@ -436,7 +1077,7 @@ class FormancyDateField extends FieldComponentBase {
436
1077
  (blur)="field.touch()"
437
1078
  />
438
1079
  </formancy-field-shell>
439
- `, isInline: true, dependencies: [{ kind: "component", type: FormancyFieldShell, selector: "formancy-field-shell", inputs: ["field", "label"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1080
+ `, isInline: true, dependencies: [{ kind: "component", type: FormancyFieldShell, selector: "formancy-field-shell", inputs: ["field", "label", "path"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
440
1081
  }
441
1082
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: FormancyDateField, decorators: [{
442
1083
  type: Component,
@@ -445,7 +1086,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImpor
445
1086
  changeDetection: ChangeDetectionStrategy.OnPush,
446
1087
  imports: [FormancyFieldShell],
447
1088
  template: `
448
- <formancy-field-shell [field]="field" [label]="context.label">
1089
+ <formancy-field-shell [field]="field" [label]="context.label" [path]="context.path">
449
1090
  <input
450
1091
  type="date"
451
1092
  [id]="control().id"
@@ -462,7 +1103,554 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImpor
462
1103
  `,
463
1104
  }]
464
1105
  }] });
1106
+ /**
1107
+ * A time of day, as `HH:MM`.
1108
+ *
1109
+ * `<input type="time">` gives the platform's picker, keyboard handling and locale
1110
+ * display — a 12-hour clock where the reader expects one — while its `value` is
1111
+ * always 24-hour `HH:MM`. Exactly the split the format wants: the reader sees their
1112
+ * convention, the answer records one canonical shape.
1113
+ *
1114
+ * No `step`, so the browser offers no seconds. A time answer has none, and a control
1115
+ * offering precision the format discards loses what somebody typed.
1116
+ */
1117
+ class FormancyTimeField extends FieldComponentBase {
1118
+ text = computed(() => {
1119
+ const value = this.field.snapshot().value;
1120
+ return typeof value === 'string' ? value : '';
1121
+ }, /* @ts-ignore */
1122
+ ...(ngDevMode ? [{ debugName: "text" }] : /* istanbul ignore next */ []));
1123
+ /* Bounds handed to the browser as well as checked by the engine. The engine's
1124
+ check is the truth — it runs again on the server — and these let the platform
1125
+ grey out what it will not accept, which beats a message after the fact. */
1126
+ earliest = computed(() => {
1127
+ const bound = this.field.snapshot().def.earliest;
1128
+ return typeof bound === 'string' ? bound : null;
1129
+ }, /* @ts-ignore */
1130
+ ...(ngDevMode ? [{ debugName: "earliest" }] : /* istanbul ignore next */ []));
1131
+ latest = computed(() => {
1132
+ const bound = this.field.snapshot().def.latest;
1133
+ return typeof bound === 'string' ? bound : null;
1134
+ }, /* @ts-ignore */
1135
+ ...(ngDevMode ? [{ debugName: "latest" }] : /* istanbul ignore next */ []));
1136
+ onInput(event) {
1137
+ const raw = event.target.value;
1138
+ this.field.setValue(raw === '' ? null : raw);
1139
+ }
1140
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: FormancyTimeField, deps: null, target: i0.ɵɵFactoryTarget.Component });
1141
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "22.1.7", type: FormancyTimeField, isStandalone: true, selector: "formancy-time-field", usesInheritance: true, ngImport: i0, template: `
1142
+ <formancy-field-shell [field]="field" [label]="context.label" [path]="context.path">
1143
+ <input
1144
+ type="time"
1145
+ [id]="control().id"
1146
+ [attr.name]="control().name"
1147
+ [attr.aria-invalid]="control()['aria-invalid']"
1148
+ [attr.aria-required]="control()['aria-required']"
1149
+ [attr.aria-describedby]="control()['aria-describedby']"
1150
+ [attr.min]="earliest()"
1151
+ [attr.max]="latest()"
1152
+ [disabled]="control().disabled === true"
1153
+ [value]="text()"
1154
+ (input)="onInput($event)"
1155
+ (blur)="field.touch()"
1156
+ />
1157
+ </formancy-field-shell>
1158
+ `, isInline: true, dependencies: [{ kind: "component", type: FormancyFieldShell, selector: "formancy-field-shell", inputs: ["field", "label", "path"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1159
+ }
1160
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: FormancyTimeField, decorators: [{
1161
+ type: Component,
1162
+ args: [{
1163
+ selector: 'formancy-time-field',
1164
+ changeDetection: ChangeDetectionStrategy.OnPush,
1165
+ imports: [FormancyFieldShell],
1166
+ template: `
1167
+ <formancy-field-shell [field]="field" [label]="context.label" [path]="context.path">
1168
+ <input
1169
+ type="time"
1170
+ [id]="control().id"
1171
+ [attr.name]="control().name"
1172
+ [attr.aria-invalid]="control()['aria-invalid']"
1173
+ [attr.aria-required]="control()['aria-required']"
1174
+ [attr.aria-describedby]="control()['aria-describedby']"
1175
+ [attr.min]="earliest()"
1176
+ [attr.max]="latest()"
1177
+ [disabled]="control().disabled === true"
1178
+ [value]="text()"
1179
+ (input)="onInput($event)"
1180
+ (blur)="field.touch()"
1181
+ />
1182
+ </formancy-field-shell>
1183
+ `,
1184
+ }]
1185
+ }] });
1186
+ /**
1187
+ * An instant, stored as `YYYY-MM-DDTHH:MM:SSZ`.
1188
+ *
1189
+ * `datetime-local` because no browser has a zoned datetime input, so the control
1190
+ * shows a local wall clock and this converts. The conversion is why this is not the
1191
+ * date field with another `type`:
1192
+ *
1193
+ * - **In:** the control gives `YYYY-MM-DDTHH:MM` in the reader's own zone, and `new
1194
+ * Date(local)` reads a zoneless string as local time — which is what is wanted at
1195
+ * the moment somebody types, and exactly what `bindTimestamp` refuses for a value
1196
+ * already stored, because the zone is not knowable later.
1197
+ * - **Out:** the stored instant is rendered back into local parts for the control,
1198
+ * never `toISOString()`, which would show UTC in a box the browser labels local.
1199
+ *
1200
+ * Seconds are therefore `00` in any answer a person typed. The format keeps them
1201
+ * because a machine-supplied answer has them, and one fixed width is what makes
1202
+ * ordering work.
1203
+ */
1204
+ class FormancyDateTimeField extends FieldComponentBase {
1205
+ localText = computed(() => {
1206
+ const value = this.field.snapshot().value;
1207
+ if (typeof value !== 'string' || value === '')
1208
+ return '';
1209
+ const instant = new Date(value);
1210
+ if (Number.isNaN(instant.getTime()))
1211
+ return '';
1212
+ const pad = (part) => String(part).padStart(2, '0');
1213
+ return (`${String(instant.getFullYear())}-${pad(instant.getMonth() + 1)}-${pad(instant.getDate())}` +
1214
+ `T${pad(instant.getHours())}:${pad(instant.getMinutes())}`);
1215
+ }, /* @ts-ignore */
1216
+ ...(ngDevMode ? [{ debugName: "localText" }] : /* istanbul ignore next */ []));
1217
+ onInput(event) {
1218
+ const local = event.target.value;
1219
+ if (local === '') {
1220
+ this.field.setValue(null);
1221
+ return;
1222
+ }
1223
+ const instant = new Date(local);
1224
+ // A control can hand back something unparseable mid-edit. Null rather than a
1225
+ // malformed string keeps the stored answer always either empty or canonical,
1226
+ // which is what the engine's shape check assumes.
1227
+ this.field.setValue(Number.isNaN(instant.getTime()) ? null : `${instant.toISOString().slice(0, 19)}Z`);
1228
+ }
1229
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: FormancyDateTimeField, deps: null, target: i0.ɵɵFactoryTarget.Component });
1230
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "22.1.7", type: FormancyDateTimeField, isStandalone: true, selector: "formancy-datetime-field", usesInheritance: true, ngImport: i0, template: `
1231
+ <formancy-field-shell [field]="field" [label]="context.label" [path]="context.path">
1232
+ <input
1233
+ type="datetime-local"
1234
+ [id]="control().id"
1235
+ [attr.name]="control().name"
1236
+ [attr.aria-invalid]="control()['aria-invalid']"
1237
+ [attr.aria-required]="control()['aria-required']"
1238
+ [attr.aria-describedby]="control()['aria-describedby']"
1239
+ [disabled]="control().disabled === true"
1240
+ [value]="localText()"
1241
+ (input)="onInput($event)"
1242
+ (blur)="field.touch()"
1243
+ />
1244
+ </formancy-field-shell>
1245
+ `, isInline: true, dependencies: [{ kind: "component", type: FormancyFieldShell, selector: "formancy-field-shell", inputs: ["field", "label", "path"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1246
+ }
1247
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: FormancyDateTimeField, decorators: [{
1248
+ type: Component,
1249
+ args: [{
1250
+ selector: 'formancy-datetime-field',
1251
+ changeDetection: ChangeDetectionStrategy.OnPush,
1252
+ imports: [FormancyFieldShell],
1253
+ template: `
1254
+ <formancy-field-shell [field]="field" [label]="context.label" [path]="context.path">
1255
+ <input
1256
+ type="datetime-local"
1257
+ [id]="control().id"
1258
+ [attr.name]="control().name"
1259
+ [attr.aria-invalid]="control()['aria-invalid']"
1260
+ [attr.aria-required]="control()['aria-required']"
1261
+ [attr.aria-describedby]="control()['aria-describedby']"
1262
+ [disabled]="control().disabled === true"
1263
+ [value]="localText()"
1264
+ (input)="onInput($event)"
1265
+ (blur)="field.touch()"
1266
+ />
1267
+ </formancy-field-shell>
1268
+ `,
1269
+ }]
1270
+ }] });
1271
+ /**
1272
+ * `widget: "typeahead"` - the same select, narrowed by typing.
1273
+ *
1274
+ * An ARIA 1.2 editable combobox over a listbox popup: a text box with
1275
+ * `role="combobox"` and a `<ul role="listbox">` of `<li role="option">`. DOM focus
1276
+ * never leaves the text box, so the arrowed-over option is named by
1277
+ * `aria-activedescendant` - the only thing that says where somebody is when what
1278
+ * they are moving through does not hold focus.
1279
+ *
1280
+ * **The list element exists while the popup is collapsed.** `aria-expanded` and
1281
+ * `aria-controls` are required properties of the role, and an `aria-controls`
1282
+ * pointing at an element that is not there is an unresolvable IDREF - so the
1283
+ * listbox is rendered and `hidden` rather than created when it opens.
1284
+ *
1285
+ * `aria-autocomplete="list"`, never `"both"`: nothing is written into the box on
1286
+ * the person's behalf, and `"both"` announces an inline completion that does not
1287
+ * exist. No `aria-haspopup`: `listbox` is the role's implicit popup.
1288
+ *
1289
+ * While a source is being asked, the box carries `aria-busy` and is never disabled:
1290
+ * disabling the element somebody just typed into blurs it, and the browser then
1291
+ * resets focus to the document body — the same reason the scanner's button stays
1292
+ * enabled while a scan is in flight.
1293
+ *
1294
+ * `aria-selected` is on the CHOSEN option and nothing else. Following the arrow
1295
+ * keys with it tells a screen reader the answer changed every time somebody
1296
+ * pressed Down to read the next row.
1297
+ *
1298
+ * **It cannot store what somebody typed.** `setValue` is reached from two places
1299
+ * here, with an option's own value or with `null`, and the text goes nowhere but
1300
+ * the filter - which is what makes
1301
+ * [0065](../../../docs/decisions/0065-a-widget-is-authored-not-registered.md)
1302
+ * structural rather than remembered. The filter itself is
1303
+ * `narrowOptionsByLabel` from `@formancy/spec`, the same function React calls, so
1304
+ * a query cannot narrow one way here and another way there.
1305
+ *
1306
+ * Every piece of state is a signal, and the field's own state arrives through
1307
+ * `injectField`, which is a signal fed by `engine.subscribeField`. Reading
1308
+ * `engine.getFieldSnapshot` instead - or wrapping a plain call in `computed()`,
1309
+ * which has no reactive dependency at all - renders this once and never again.
1310
+ * That bug has been written twice in this repository.
1311
+ */
1312
+ class FormancyTypeaheadSelect extends FieldComponentBase {
1313
+ /**
1314
+ * What is in the box while somebody types, or null when the box is simply
1315
+ * showing the answer.
1316
+ *
1317
+ * Two states rather than one string, because "empty because they cleared it" and
1318
+ * "empty because there is no answer" are different facts, and only the first
1319
+ * clears the answer on the way out.
1320
+ */
1321
+ query = signal(null, /* @ts-ignore */
1322
+ ...(ngDevMode ? [{ debugName: "query" }] : /* istanbul ignore next */ []));
1323
+ open = signal(false, /* @ts-ignore */
1324
+ ...(ngDevMode ? [{ debugName: "open" }] : /* istanbul ignore next */ []));
1325
+ /** The arrowed-over option by VALUE, not by index: the filtered list changes on
1326
+ * every keystroke and an index would point at a different row after one. */
1327
+ activeValue = signal(null, /* @ts-ignore */
1328
+ ...(ngDevMode ? [{ debugName: "activeValue" }] : /* istanbul ignore next */ []));
1329
+ /**
1330
+ * The option the form currently holds, looked up in what is OFFERED.
1331
+ *
1332
+ * `options()` is the document's list alone, and a field with `optionsSource` has
1333
+ * none — the schema forbids both — so looking there made `chosen` permanently
1334
+ * undefined for a sourced field, and the box showed an empty string over a stored
1335
+ * answer. React looked it up in the offered list from the start; this is the
1336
+ * divergence that made them two different controls.
1337
+ */
1338
+ chosen = computed(() => {
1339
+ const value = this.field.snapshot().value;
1340
+ return this.offered().find((option) => option.value === value);
1341
+ }, /* @ts-ignore */
1342
+ ...(ngDevMode ? [{ debugName: "chosen" }] : /* istanbul ignore next */ []));
1343
+ /**
1344
+ * A source is the AUTHORITY on what matches: it was handed the query, and
1345
+ * re-folding its rows here would drop ones it matched on data the person cannot
1346
+ * see. A host wanting fetch-once-filter-locally composes `narrowOptionsByLabel` in
1347
+ * its own resolver, which is why that function lives in `@formancy/spec`.
1348
+ */
1349
+ matches = computed(() => this.remote.sourced()
1350
+ ? this.offered()
1351
+ : narrowOptionsByLabel(this.options(), this.query() ?? ''), /* @ts-ignore */
1352
+ ...(ngDevMode ? [{ debugName: "matches" }] : /* istanbul ignore next */ []));
1353
+ /** Collapsed whenever there is nothing on the screen, so `aria-expanded` never
1354
+ * claims a popup a person cannot see. */
1355
+ expanded = computed(() => this.open() && this.matches().length > 0, /* @ts-ignore */
1356
+ ...(ngDevMode ? [{ debugName: "expanded" }] : /* istanbul ignore next */ []));
1357
+ text = computed(() => this.query() ?? this.chosen()?.label ?? '', /* @ts-ignore */
1358
+ ...(ngDevMode ? [{ debugName: "text" }] : /* istanbul ignore next */ []));
1359
+ listboxId = computed(() => `${this.control().id}:listbox`, /* @ts-ignore */
1360
+ ...(ngDevMode ? [{ debugName: "listboxId" }] : /* istanbul ignore next */ []));
1361
+ popupLabel = computed(() => `${this.context.label} suggestions`, /* @ts-ignore */
1362
+ ...(ngDevMode ? [{ debugName: "popupLabel" }] : /* istanbul ignore next */ []));
1363
+ activeIndex = computed(() => this.matches().findIndex((option) => option.value === this.activeValue()), /* @ts-ignore */
1364
+ ...(ngDevMode ? [{ debugName: "activeIndex" }] : /* istanbul ignore next */ []));
1365
+ /** Absent rather than empty when nothing is active: an empty IDREF is a broken
1366
+ * reference, not a way of saying "nothing". Angular omits an attribute bound to
1367
+ * null, which is exactly that. */
1368
+ activeId = computed(() => {
1369
+ const index = this.activeIndex();
1370
+ if (!this.expanded() || index === -1)
1371
+ return null;
1372
+ return this.optionId(this.matches()[index].value);
1373
+ }, /* @ts-ignore */
1374
+ ...(ngDevMode ? [{ debugName: "activeId" }] : /* istanbul ignore next */ []));
1375
+ /**
1376
+ * A source's own words win over "no options match": while a request is in flight,
1377
+ * "nothing matched" is not yet true.
1378
+ */
1379
+ statusMessage = computed(() => {
1380
+ const fromSource = this.remote.status();
1381
+ if (fromSource !== '')
1382
+ return fromSource;
1383
+ return this.open() && this.matches().length === 0 && !this.remote.busy()
1384
+ ? 'No options match'
1385
+ : '';
1386
+ }, /* @ts-ignore */
1387
+ ...(ngDevMode ? [{ debugName: "statusMessage" }] : /* istanbul ignore next */ []));
1388
+ /** What the region is saying, as a word a theme can select on. */
1389
+ statusState = computed(() => {
1390
+ if (this.remote.busy())
1391
+ return 'busy';
1392
+ const message = this.remote.status();
1393
+ if (message.startsWith('The options could not'))
1394
+ return 'failed';
1395
+ if (message !== '')
1396
+ return 'hint';
1397
+ return this.open() && this.matches().length === 0 ? 'empty' : null;
1398
+ }, /* @ts-ignore */
1399
+ ...(ngDevMode ? [{ debugName: "statusState" }] : /* istanbul ignore next */ []));
1400
+ /** One option's element id, in the shape the radio group already uses. */
1401
+ optionId(value) {
1402
+ return `${this.control().id}:option:${value}`;
1403
+ }
1404
+ /**
1405
+ * The one place the query changes, because it is two facts that must never disagree:
1406
+ * what the box shows, and what the source is asked for.
1407
+ *
1408
+ * They were separate signals and only typing wrote the second, so after choosing a
1409
+ * row or leaving the field the source kept answering the abandoned query while the
1410
+ * box showed the answer — and the popup on the next click held rows for a word
1411
+ * nobody had typed. React derives both from one piece of state, which is why it
1412
+ * never had this.
1413
+ */
1414
+ setQuery(next) {
1415
+ this.query.set(next);
1416
+ this.sourceQuery.set(next ?? '');
1417
+ }
1418
+ onInput(event) {
1419
+ this.setQuery(event.target.value);
1420
+ this.open.set(true);
1421
+ // Nothing is active on a keystroke: aria-activedescendant is ABSENT rather
1422
+ // than pointing at a row the person has not moved to.
1423
+ this.activeValue.set(null);
1424
+ }
1425
+ onKeyDown(event) {
1426
+ const matches = this.matches();
1427
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
1428
+ event.preventDefault();
1429
+ if (!this.open()) {
1430
+ this.open.set(true);
1431
+ // Opens ON the answer when there is one, so Down then Enter cannot
1432
+ // quietly change an answer somebody only wanted to look at.
1433
+ const fallback = event.key === 'ArrowDown' ? matches[0] : matches[matches.length - 1];
1434
+ this.activeValue.set(this.chosen()?.value ?? fallback?.value ?? null);
1435
+ return;
1436
+ }
1437
+ this.moveActive(event.key === 'ArrowDown' ? 1 : -1);
1438
+ return;
1439
+ }
1440
+ if ((event.key === 'Home' || event.key === 'End') && this.expanded()) {
1441
+ // The popup's keys while it is open. Left to the caret, a long list is
1442
+ // reachable only by holding Down.
1443
+ event.preventDefault();
1444
+ this.activeValue.set((event.key === 'Home' ? matches[0] : matches[matches.length - 1]).value);
1445
+ return;
1446
+ }
1447
+ if (event.key === 'Enter') {
1448
+ // Only while the list is showing. Otherwise Enter belongs to the form, and a
1449
+ // control that swallowed it would break submitting from the keyboard.
1450
+ if (!this.expanded())
1451
+ return;
1452
+ event.preventDefault();
1453
+ const index = this.activeIndex();
1454
+ if (index === -1) {
1455
+ this.open.set(false);
1456
+ return;
1457
+ }
1458
+ this.choose(matches[index].value);
1459
+ return;
1460
+ }
1461
+ if (event.key === 'Escape') {
1462
+ if (!this.open() && this.query() === null)
1463
+ return;
1464
+ event.preventDefault();
1465
+ // "Never mind about this list", not "delete what I chose earlier": the query
1466
+ // is abandoned and the ANSWER is untouched.
1467
+ this.open.set(false);
1468
+ this.activeValue.set(null);
1469
+ this.setQuery(null);
1470
+ }
1471
+ }
1472
+ choose(value) {
1473
+ this.field.setValue(value);
1474
+ this.setQuery(null);
1475
+ this.open.set(false);
1476
+ this.activeValue.set(null);
1477
+ }
1478
+ onBlur() {
1479
+ this.open.set(false);
1480
+ this.activeValue.set(null);
1481
+ const typed = this.query();
1482
+ if (typed !== null) {
1483
+ // An emptied box is the empty option, and the only route to null. Anything
1484
+ // else typed is abandoned - it was never an answer.
1485
+ if (typed.trim() === '')
1486
+ this.field.setValue(null);
1487
+ this.setQuery(null);
1488
+ }
1489
+ this.field.touch();
1490
+ }
1491
+ moveActive(delta) {
1492
+ const matches = this.matches();
1493
+ if (matches.length === 0)
1494
+ return;
1495
+ const index = this.activeIndex();
1496
+ const from = index === -1 ? (delta > 0 ? -1 : matches.length) : index;
1497
+ // Clamped, not wrapped: Down means further down the list, and a list that
1498
+ // jumps back to the top moves somebody past the end without saying so.
1499
+ const next = Math.min(Math.max(from + delta, 0), matches.length - 1);
1500
+ this.activeValue.set(matches[next].value);
1501
+ }
1502
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: FormancyTypeaheadSelect, deps: null, target: i0.ɵɵFactoryTarget.Component });
1503
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.7", type: FormancyTypeaheadSelect, isStandalone: true, selector: "formancy-typeahead-select", usesInheritance: true, ngImport: i0, template: `
1504
+ <formancy-field-shell [field]="field" [label]="context.label" [path]="context.path">
1505
+ <!-- The popup's containing block, and the reason it is an element rather than
1506
+ nothing at all.
1507
+
1508
+ The popup was absolutely positioned with 'top: auto', on the reasoning that
1509
+ it would then land at its STATIC position -- where it would have sat in the
1510
+ flow, directly under the box. That holds inside a block container and NOT
1511
+ inside a grid or flex one, and every theme lays a field out with
1512
+ 'display: grid'. For an absolutely positioned child of a grid container the
1513
+ static position is the container's own content-box origin, so the list opened
1514
+ over its own label and box rather than under them. Measured in the playground
1515
+ before the fix: the field's top edge was 457px, an in-flow child would have
1516
+ sat at 537px, and the popup sat at 459px.
1517
+
1518
+ So the popup is given a containing block that wraps the control and nothing
1519
+ else, and every theme positions it against that explicitly. The status region
1520
+ stays OUTSIDE it, because it is a row of the field's grid exactly as the
1521
+ error region is. The React binding does the same, for the same reason. -->
1522
+ <div data-formancy-part="typeahead-anchor">
1523
+ <input
1524
+ type="text"
1525
+ role="combobox"
1526
+ [id]="control().id"
1527
+ [attr.name]="control().name"
1528
+ [attr.aria-invalid]="control()['aria-invalid']"
1529
+ [attr.aria-required]="control()['aria-required']"
1530
+ [attr.aria-describedby]="control()['aria-describedby']"
1531
+ [disabled]="control().disabled === true"
1532
+ data-formancy-part="typeahead"
1533
+ autocomplete="off"
1534
+ aria-autocomplete="list"
1535
+ [attr.aria-busy]="remote.busy() ? 'true' : null"
1536
+ [attr.aria-expanded]="expanded()"
1537
+ [attr.aria-controls]="listboxId()"
1538
+ [attr.aria-activedescendant]="activeId()"
1539
+ [value]="text()"
1540
+ (input)="onInput($event)"
1541
+ (click)="open.set(true)"
1542
+ (keydown)="onKeyDown($event)"
1543
+ (blur)="onBlur()"
1544
+ />
1545
+ <!-- Named, because the listbox role is one whose accessible name is required.
1546
+ Not with the name the field already has: two elements answering to the
1547
+ same accessible name make every query by that name ambiguous. -->
1548
+ <ul
1549
+ [id]="listboxId()"
1550
+ role="listbox"
1551
+ [attr.aria-label]="popupLabel()"
1552
+ data-formancy-part="typeahead-listbox"
1553
+ [hidden]="!expanded()"
1554
+ >
1555
+ @for (option of matches(); track option.value) {
1556
+ <li [id]="optionId(option.value)" role="option" data-formancy-part="typeahead-option" [attr.data-active]="option.value === activeValue() ? 'true' : null" [attr.aria-selected]="chosen()?.value === option.value ? 'true' : null" (mousedown)="$event.preventDefault()" (click)="choose(option.value)">{{ option.label }}</li>
1557
+ }
1558
+ </ul>
1559
+ </div>
1560
+ <!-- Present from the start and empty until there is something to say: a live
1561
+ region created at the moment it gets its text is one several screen
1562
+ readers never announce. -->
1563
+ <!-- ONE region, four things it may say, and never the error region: a source
1564
+ being down is not a wrong answer, and the error region is the control's
1565
+ describedby target carrying the engine's verdict. -->
1566
+ <p role="status" data-formancy-part="typeahead-status" [attr.data-state]="statusState()">{{ statusMessage() }}</p>
1567
+ </formancy-field-shell>
1568
+ `, isInline: true, dependencies: [{ kind: "component", type: FormancyFieldShell, selector: "formancy-field-shell", inputs: ["field", "label", "path"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1569
+ }
1570
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: FormancyTypeaheadSelect, decorators: [{
1571
+ type: Component,
1572
+ args: [{
1573
+ selector: 'formancy-typeahead-select',
1574
+ changeDetection: ChangeDetectionStrategy.OnPush,
1575
+ imports: [FormancyFieldShell],
1576
+ template: `
1577
+ <formancy-field-shell [field]="field" [label]="context.label" [path]="context.path">
1578
+ <!-- The popup's containing block, and the reason it is an element rather than
1579
+ nothing at all.
1580
+
1581
+ The popup was absolutely positioned with 'top: auto', on the reasoning that
1582
+ it would then land at its STATIC position -- where it would have sat in the
1583
+ flow, directly under the box. That holds inside a block container and NOT
1584
+ inside a grid or flex one, and every theme lays a field out with
1585
+ 'display: grid'. For an absolutely positioned child of a grid container the
1586
+ static position is the container's own content-box origin, so the list opened
1587
+ over its own label and box rather than under them. Measured in the playground
1588
+ before the fix: the field's top edge was 457px, an in-flow child would have
1589
+ sat at 537px, and the popup sat at 459px.
1590
+
1591
+ So the popup is given a containing block that wraps the control and nothing
1592
+ else, and every theme positions it against that explicitly. The status region
1593
+ stays OUTSIDE it, because it is a row of the field's grid exactly as the
1594
+ error region is. The React binding does the same, for the same reason. -->
1595
+ <div data-formancy-part="typeahead-anchor">
1596
+ <input
1597
+ type="text"
1598
+ role="combobox"
1599
+ [id]="control().id"
1600
+ [attr.name]="control().name"
1601
+ [attr.aria-invalid]="control()['aria-invalid']"
1602
+ [attr.aria-required]="control()['aria-required']"
1603
+ [attr.aria-describedby]="control()['aria-describedby']"
1604
+ [disabled]="control().disabled === true"
1605
+ data-formancy-part="typeahead"
1606
+ autocomplete="off"
1607
+ aria-autocomplete="list"
1608
+ [attr.aria-busy]="remote.busy() ? 'true' : null"
1609
+ [attr.aria-expanded]="expanded()"
1610
+ [attr.aria-controls]="listboxId()"
1611
+ [attr.aria-activedescendant]="activeId()"
1612
+ [value]="text()"
1613
+ (input)="onInput($event)"
1614
+ (click)="open.set(true)"
1615
+ (keydown)="onKeyDown($event)"
1616
+ (blur)="onBlur()"
1617
+ />
1618
+ <!-- Named, because the listbox role is one whose accessible name is required.
1619
+ Not with the name the field already has: two elements answering to the
1620
+ same accessible name make every query by that name ambiguous. -->
1621
+ <ul
1622
+ [id]="listboxId()"
1623
+ role="listbox"
1624
+ [attr.aria-label]="popupLabel()"
1625
+ data-formancy-part="typeahead-listbox"
1626
+ [hidden]="!expanded()"
1627
+ >
1628
+ @for (option of matches(); track option.value) {
1629
+ <li [id]="optionId(option.value)" role="option" data-formancy-part="typeahead-option" [attr.data-active]="option.value === activeValue() ? 'true' : null" [attr.aria-selected]="chosen()?.value === option.value ? 'true' : null" (mousedown)="$event.preventDefault()" (click)="choose(option.value)">{{ option.label }}</li>
1630
+ }
1631
+ </ul>
1632
+ </div>
1633
+ <!-- Present from the start and empty until there is something to say: a live
1634
+ region created at the moment it gets its text is one several screen
1635
+ readers never announce. -->
1636
+ <!-- ONE region, four things it may say, and never the error region: a source
1637
+ being down is not a wrong answer, and the error region is the control's
1638
+ describedby target carrying the engine's verdict. -->
1639
+ <p role="status" data-formancy-part="typeahead-status" [attr.data-state]="statusState()">{{ statusMessage() }}</p>
1640
+ </formancy-field-shell>
1641
+ `,
1642
+ }]
1643
+ }] });
465
1644
  class FormancySelectField extends FieldComponentBase {
1645
+ typeahead = computed(() => this.field.snapshot().def.widget === 'typeahead', /* @ts-ignore */
1646
+ ...(ngDevMode ? [{ debugName: "typeahead" }] : /* istanbul ignore next */ []));
1647
+ /** The name the document gave, for the message when this deployment has no such source. */
1648
+ sourceName = computed(() => this.field.snapshot().def.optionsSource ?? '', /* @ts-ignore */
1649
+ ...(ngDevMode ? [{ debugName: "sourceName" }] : /* istanbul ignore next */ []));
1650
+ /** This one hands over to the typeahead, which does its own asking. */
1651
+ sourceEnabled() {
1652
+ return !this.typeahead();
1653
+ }
466
1654
  selected = computed(() => {
467
1655
  const value = this.field.snapshot().value;
468
1656
  return typeof value === 'string' ? value : '';
@@ -474,7 +1662,27 @@ class FormancySelectField extends FieldComponentBase {
474
1662
  }
475
1663
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: FormancySelectField, deps: null, target: i0.ɵɵFactoryTarget.Component });
476
1664
  static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.7", type: FormancySelectField, isStandalone: true, selector: "formancy-select-field", usesInheritance: true, ngImport: i0, template: `
477
- <formancy-field-shell [field]="field" [label]="context.label">
1665
+ @if (remote.unavailable()) {
1666
+ <!-- The document names a source this deployment does not have. Unlike a missing
1667
+ scanner this costs the whole field -- a select with no options collects
1668
+ nothing -- so it says so where the chooser would be, exactly as the file
1669
+ field does without an uploader.
1670
+
1671
+ BEFORE the widget, and the order is the fix: dispatching to the typeahead
1672
+ first made this message unreachable for the very widget the feature was
1673
+ built for, leaving a working-looking combobox that returned nothing and
1674
+ announced "No options match" -- which says the list has no such row, when
1675
+ the truth is that there is no list. The React binding orders it the same. -->
1676
+ <formancy-field-shell [field]="field" [label]="context.label" [path]="context.path">
1677
+ <p data-formancy-part="options-unavailable">This field's answers come from "{{ sourceName() }}", which this application has not provided.</p>
1678
+ </formancy-field-shell>
1679
+ } @else if (typeahead()) {
1680
+ <!-- A widget changes the CONTROL and nothing else: same field, same
1681
+ accessible name, same stored answer. The registry still wins over every
1682
+ branch, because it replaces the component. -->
1683
+ <formancy-typeahead-select />
1684
+ } @else {
1685
+ <formancy-field-shell [field]="field" [label]="context.label" [path]="context.path">
478
1686
  <select
479
1687
  [id]="control().id"
480
1688
  [attr.name]="control().name"
@@ -490,21 +1698,48 @@ class FormancySelectField extends FieldComponentBase {
490
1698
  heard about. Selectedness is bound per option because a select's
491
1699
  value property is only settable once its options exist. -->
492
1700
  <option value="" [selected]="selected() === ''"></option>
493
- @for (option of options(); track option.value) {
1701
+ @for (option of offered(); track option.value) {
494
1702
  <option [value]="option.value" [selected]="selected() === option.value">{{ option.label }}</option>
495
1703
  }
496
1704
  </select>
1705
+ <!-- Only a sourced select has anything to say: how many rows were left out, or
1706
+ that the source could not be reached. Never the error region, which carries
1707
+ the engine's verdict -- a source being down is not a wrong answer. -->
1708
+ @if (remote.sourced()) {
1709
+ <p role="status" data-formancy-part="select-status">{{ remote.status() }}</p>
1710
+ }
497
1711
  </formancy-field-shell>
498
- `, isInline: true, dependencies: [{ kind: "component", type: FormancyFieldShell, selector: "formancy-field-shell", inputs: ["field", "label"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1712
+ }
1713
+ `, isInline: true, dependencies: [{ kind: "component", type: FormancyFieldShell, selector: "formancy-field-shell", inputs: ["field", "label", "path"] }, { kind: "component", type: FormancyTypeaheadSelect, selector: "formancy-typeahead-select" }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
499
1714
  }
500
1715
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: FormancySelectField, decorators: [{
501
1716
  type: Component,
502
1717
  args: [{
503
1718
  selector: 'formancy-select-field',
504
1719
  changeDetection: ChangeDetectionStrategy.OnPush,
505
- imports: [FormancyFieldShell],
1720
+ imports: [FormancyFieldShell, FormancyTypeaheadSelect],
506
1721
  template: `
507
- <formancy-field-shell [field]="field" [label]="context.label">
1722
+ @if (remote.unavailable()) {
1723
+ <!-- The document names a source this deployment does not have. Unlike a missing
1724
+ scanner this costs the whole field -- a select with no options collects
1725
+ nothing -- so it says so where the chooser would be, exactly as the file
1726
+ field does without an uploader.
1727
+
1728
+ BEFORE the widget, and the order is the fix: dispatching to the typeahead
1729
+ first made this message unreachable for the very widget the feature was
1730
+ built for, leaving a working-looking combobox that returned nothing and
1731
+ announced "No options match" -- which says the list has no such row, when
1732
+ the truth is that there is no list. The React binding orders it the same. -->
1733
+ <formancy-field-shell [field]="field" [label]="context.label" [path]="context.path">
1734
+ <p data-formancy-part="options-unavailable">This field's answers come from "{{ sourceName() }}", which this application has not provided.</p>
1735
+ </formancy-field-shell>
1736
+ } @else if (typeahead()) {
1737
+ <!-- A widget changes the CONTROL and nothing else: same field, same
1738
+ accessible name, same stored answer. The registry still wins over every
1739
+ branch, because it replaces the component. -->
1740
+ <formancy-typeahead-select />
1741
+ } @else {
1742
+ <formancy-field-shell [field]="field" [label]="context.label" [path]="context.path">
508
1743
  <select
509
1744
  [id]="control().id"
510
1745
  [attr.name]="control().name"
@@ -520,11 +1755,18 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImpor
520
1755
  heard about. Selectedness is bound per option because a select's
521
1756
  value property is only settable once its options exist. -->
522
1757
  <option value="" [selected]="selected() === ''"></option>
523
- @for (option of options(); track option.value) {
1758
+ @for (option of offered(); track option.value) {
524
1759
  <option [value]="option.value" [selected]="selected() === option.value">{{ option.label }}</option>
525
1760
  }
526
1761
  </select>
1762
+ <!-- Only a sourced select has anything to say: how many rows were left out, or
1763
+ that the source could not be reached. Never the error region, which carries
1764
+ the engine's verdict -- a source being down is not a wrong answer. -->
1765
+ @if (remote.sourced()) {
1766
+ <p role="status" data-formancy-part="select-status">{{ remote.status() }}</p>
1767
+ }
527
1768
  </formancy-field-shell>
1769
+ }
528
1770
  `,
529
1771
  }]
530
1772
  }] });
@@ -539,82 +1781,886 @@ class FormancyRadioGroupField extends FieldComponentBase {
539
1781
  optionId(option) {
540
1782
  return `${this.field.snapshot().ids.control}:${option.value}`;
541
1783
  }
542
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: FormancyRadioGroupField, deps: null, target: i0.ɵɵFactoryTarget.Component });
543
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.7", type: FormancyRadioGroupField, isStandalone: true, selector: "formancy-radio-group-field", usesInheritance: true, ngImport: i0, template: `
544
- <fieldset
545
- data-formancy-part="field"
546
- [attr.data-state]="showError() ? 'invalid' : 'valid'"
547
- [attr.aria-describedby]="control()['aria-describedby']"
548
- >
549
- <legend data-formancy-part="label">{{ context.label }}</legend>
550
- @for (option of options(); track option.value) {
551
- <span data-formancy-part="radio-option">
1784
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: FormancyRadioGroupField, deps: null, target: i0.ɵɵFactoryTarget.Component });
1785
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.7", type: FormancyRadioGroupField, isStandalone: true, selector: "formancy-radio-group-field", usesInheritance: true, ngImport: i0, template: `
1786
+ <fieldset
1787
+ data-formancy-part="field"
1788
+ [attr.data-formancy-field-path]="context.path"
1789
+ [attr.data-state]="showError() ? 'invalid' : 'valid'"
1790
+ [attr.aria-describedby]="control()['aria-describedby']"
1791
+ >
1792
+ <legend data-formancy-part="label">{{ context.label }}</legend>
1793
+ @if (field.snapshot().required) {
1794
+ <!-- A real element rather than the aria-required attribute, which
1795
+ role=group does not support: assistive technology ignores it
1796
+ there and an auditor reports it as invalid ARIA. The engine puts
1797
+ this id into the group's aria-describedby, so it is announced
1798
+ after the legend. Visible too, because WCAG 1.4.1. -->
1799
+ <span data-formancy-part="required-hint" [id]="field.snapshot().ids.hint">required</span>
1800
+ }
1801
+ @for (option of options(); track option.value) {
1802
+ <span data-formancy-part="radio-option">
1803
+ <input
1804
+ type="radio"
1805
+ [id]="optionId(option)"
1806
+ [attr.name]="control().name"
1807
+ [value]="option.value"
1808
+ [checked]="field.snapshot().value === option.value"
1809
+ (change)="field.setValue(option.value)"
1810
+ (blur)="field.touch()"
1811
+ />
1812
+ <label [attr.for]="optionId(option)">{{ option.label }}</label>
1813
+ </span>
1814
+ }
1815
+ @if (showError()) {
1816
+ <p data-formancy-part="error" [id]="field.snapshot().props.error.id">{{ errorText() }}</p>
1817
+ }
1818
+ </fieldset>
1819
+ `, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
1820
+ }
1821
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: FormancyRadioGroupField, decorators: [{
1822
+ type: Component,
1823
+ args: [{
1824
+ selector: 'formancy-radio-group-field',
1825
+ changeDetection: ChangeDetectionStrategy.OnPush,
1826
+ template: `
1827
+ <fieldset
1828
+ data-formancy-part="field"
1829
+ [attr.data-formancy-field-path]="context.path"
1830
+ [attr.data-state]="showError() ? 'invalid' : 'valid'"
1831
+ [attr.aria-describedby]="control()['aria-describedby']"
1832
+ >
1833
+ <legend data-formancy-part="label">{{ context.label }}</legend>
1834
+ @if (field.snapshot().required) {
1835
+ <!-- A real element rather than the aria-required attribute, which
1836
+ role=group does not support: assistive technology ignores it
1837
+ there and an auditor reports it as invalid ARIA. The engine puts
1838
+ this id into the group's aria-describedby, so it is announced
1839
+ after the legend. Visible too, because WCAG 1.4.1. -->
1840
+ <span data-formancy-part="required-hint" [id]="field.snapshot().ids.hint">required</span>
1841
+ }
1842
+ @for (option of options(); track option.value) {
1843
+ <span data-formancy-part="radio-option">
1844
+ <input
1845
+ type="radio"
1846
+ [id]="optionId(option)"
1847
+ [attr.name]="control().name"
1848
+ [value]="option.value"
1849
+ [checked]="field.snapshot().value === option.value"
1850
+ (change)="field.setValue(option.value)"
1851
+ (blur)="field.touch()"
1852
+ />
1853
+ <label [attr.for]="optionId(option)">{{ option.label }}</label>
1854
+ </span>
1855
+ }
1856
+ @if (showError()) {
1857
+ <p data-formancy-part="error" [id]="field.snapshot().props.error.id">{{ errorText() }}</p>
1858
+ }
1859
+ </fieldset>
1860
+ `,
1861
+ }]
1862
+ }] });
1863
+ /**
1864
+ * The built-in unstyled components. `null` means the type renders nothing here:
1865
+ * hidden and static are non-inputs, and the container types are laid out by
1866
+ * their own machinery, not by a leaf slot.
1867
+ */
1868
+ /**
1869
+ * Text the reader sees that collects nothing — a heading, an explanation, a
1870
+ * notice.
1871
+ *
1872
+ * Not a label, because there is no control for one to label. Not a heading
1873
+ * element either: the spec does not say what level it would be, and guessing
1874
+ * produces a document outline that skips levels.
1875
+ */
1876
+ class FormancyStaticField {
1877
+ context = injectFieldContext();
1878
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: FormancyStaticField, deps: [], target: i0.ɵɵFactoryTarget.Component });
1879
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "22.1.7", type: FormancyStaticField, isStandalone: true, selector: "formancy-static-field", ngImport: i0, template: `<p data-formancy-part="static">{{ context.label }}</p>`, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
1880
+ }
1881
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: FormancyStaticField, decorators: [{
1882
+ type: Component,
1883
+ args: [{
1884
+ selector: 'formancy-static-field',
1885
+ changeDetection: ChangeDetectionStrategy.OnPush,
1886
+ template: `<p data-formancy-part="static">{{ context.label }}</p>`,
1887
+ }]
1888
+ }] });
1889
+ /**
1890
+ * Several answers from a list, every option visible at once.
1891
+ *
1892
+ * A fieldset with a legend, exactly like the radio group, because the
1893
+ * relationship is the same one: several controls answering a single question.
1894
+ * What differs is only that more than one may be chosen.
1895
+ *
1896
+ * "At least one" is a property of the QUESTION, not of any one box, so it
1897
+ * belongs to the group — but not as `aria-required`, which `role="group"`
1898
+ * does not support and assistive technology therefore ignores. The group's
1899
+ * description carries it instead. On every box it would announce each option
1900
+ * as required, which is the opposite of what it means.
1901
+ */
1902
+ class FormancySelectBoxesField extends FieldComponentBase {
1903
+ showError = computed(() => {
1904
+ const snapshot = this.field.snapshot();
1905
+ return snapshot.touched && snapshot.errors.length > 0;
1906
+ }, /* @ts-ignore */
1907
+ ...(ngDevMode ? [{ debugName: "showError" }] : /* istanbul ignore next */ []));
1908
+ errorText = computed(() => this.field.snapshot().errors.join(', '), /* @ts-ignore */
1909
+ ...(ngDevMode ? [{ debugName: "errorText" }] : /* istanbul ignore next */ []));
1910
+ chosen = computed(() => {
1911
+ const value = this.field.snapshot().value;
1912
+ return Array.isArray(value) ? value : [];
1913
+ }, /* @ts-ignore */
1914
+ ...(ngDevMode ? [{ debugName: "chosen" }] : /* istanbul ignore next */ []));
1915
+ isChosen(value) {
1916
+ return this.chosen().includes(value);
1917
+ }
1918
+ optionId(option) {
1919
+ return `${this.field.snapshot().ids.control}:${option.value}`;
1920
+ }
1921
+ toggle(value, event) {
1922
+ const on = event.target.checked;
1923
+ // Rebuilt in the options' own order rather than the order they were
1924
+ // ticked, so two people choosing the same answers store the same array and
1925
+ // the React binding stores it identically.
1926
+ const next = this.options()
1927
+ .map((option) => option.value)
1928
+ .filter((candidate) => (candidate === value ? on : this.isChosen(candidate)));
1929
+ this.field.setValue(next);
1930
+ }
1931
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: FormancySelectBoxesField, deps: null, target: i0.ɵɵFactoryTarget.Component });
1932
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.7", type: FormancySelectBoxesField, isStandalone: true, selector: "formancy-select-boxes-field", usesInheritance: true, ngImport: i0, template: `
1933
+ <fieldset
1934
+ data-formancy-part="field"
1935
+ [attr.data-formancy-field-path]="context.path"
1936
+ [attr.data-state]="showError() ? 'invalid' : 'valid'"
1937
+ [attr.aria-describedby]="control()['aria-describedby']"
1938
+ >
1939
+ <legend data-formancy-part="label">{{ context.label }}</legend>
1940
+ @if (field.snapshot().required) {
1941
+ <!-- A real element rather than the aria-required attribute, which
1942
+ role=group does not support: assistive technology ignores it
1943
+ there and an auditor reports it as invalid ARIA. The engine puts
1944
+ this id into the group's aria-describedby, so it is announced
1945
+ after the legend. Visible too, because WCAG 1.4.1. -->
1946
+ <span data-formancy-part="required-hint" [id]="field.snapshot().ids.hint">required</span>
1947
+ }
1948
+ @for (option of options(); track option.value) {
1949
+ <span data-formancy-part="checkbox-option">
1950
+ <input
1951
+ type="checkbox"
1952
+ [id]="optionId(option)"
1953
+ [attr.name]="control().name"
1954
+ [value]="option.value"
1955
+ [checked]="isChosen(option.value)"
1956
+ [disabled]="field.snapshot().disabled"
1957
+ (change)="toggle(option.value, $event)"
1958
+ (blur)="field.touch()"
1959
+ />
1960
+ <label [attr.for]="optionId(option)">{{ option.label }}</label>
1961
+ </span>
1962
+ }
1963
+ @if (showError()) {
1964
+ <p data-formancy-part="error" [id]="field.snapshot().props.error.id">{{ errorText() }}</p>
1965
+ }
1966
+ </fieldset>
1967
+ `, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
1968
+ }
1969
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: FormancySelectBoxesField, decorators: [{
1970
+ type: Component,
1971
+ args: [{
1972
+ selector: 'formancy-select-boxes-field',
1973
+ changeDetection: ChangeDetectionStrategy.OnPush,
1974
+ template: `
1975
+ <fieldset
1976
+ data-formancy-part="field"
1977
+ [attr.data-formancy-field-path]="context.path"
1978
+ [attr.data-state]="showError() ? 'invalid' : 'valid'"
1979
+ [attr.aria-describedby]="control()['aria-describedby']"
1980
+ >
1981
+ <legend data-formancy-part="label">{{ context.label }}</legend>
1982
+ @if (field.snapshot().required) {
1983
+ <!-- A real element rather than the aria-required attribute, which
1984
+ role=group does not support: assistive technology ignores it
1985
+ there and an auditor reports it as invalid ARIA. The engine puts
1986
+ this id into the group's aria-describedby, so it is announced
1987
+ after the legend. Visible too, because WCAG 1.4.1. -->
1988
+ <span data-formancy-part="required-hint" [id]="field.snapshot().ids.hint">required</span>
1989
+ }
1990
+ @for (option of options(); track option.value) {
1991
+ <span data-formancy-part="checkbox-option">
1992
+ <input
1993
+ type="checkbox"
1994
+ [id]="optionId(option)"
1995
+ [attr.name]="control().name"
1996
+ [value]="option.value"
1997
+ [checked]="isChosen(option.value)"
1998
+ [disabled]="field.snapshot().disabled"
1999
+ (change)="toggle(option.value, $event)"
2000
+ (blur)="field.touch()"
2001
+ />
2002
+ <label [attr.for]="optionId(option)">{{ option.label }}</label>
2003
+ </span>
2004
+ }
2005
+ @if (showError()) {
2006
+ <p data-formancy-part="error" [id]="field.snapshot().props.error.id">{{ errorText() }}</p>
2007
+ }
2008
+ </fieldset>
2009
+ `,
2010
+ }]
2011
+ }] });
2012
+ /**
2013
+ * Formatted text, written as the restricted markup the spec defines.
2014
+ *
2015
+ * **Two surfaces over one value.** By default a toolbar over a textarea, whose
2016
+ * transformations live in `@formancy/spec` so a Bold button cannot mean one
2017
+ * thing here and something else in React. When the host provides an editor
2018
+ * factory, a contenteditable surface instead — which was refused in
2019
+ * [0052](../../../docs/decisions/0052-richtext-is-not-html.md) and admitted in
2020
+ * [0061](../../../docs/decisions/0061-tiptap-over-the-closed-grammar.md) once
2021
+ * the reason was read properly: 0052's argument was against a string of HTML
2022
+ * crossing the boundary, not against contenteditable, and a ProseMirror schema
2023
+ * built from the grammar cannot produce markup the grammar has no way to store.
2024
+ *
2025
+ * The factory is the host's because ProseMirror is larger than this whole
2026
+ * package and most forms have no rich-text field. Its absence is the default and
2027
+ * costs only the WYSIWYG surface.
2028
+ */
2029
+ class FormancyRichTextField extends FieldComponentBase {
2030
+ text = computed(() => {
2031
+ const value = this.field.snapshot().value;
2032
+ return typeof value === 'string' ? value : '';
2033
+ }, /* @ts-ignore */
2034
+ ...(ngDevMode ? [{ debugName: "text" }] : /* istanbul ignore next */ []));
2035
+ active = signal(0, /* @ts-ignore */
2036
+ ...(ngDevMode ? [{ debugName: "active" }] : /* istanbul ignore next */ []));
2037
+ commands = [
2038
+ { command: 'strong', name: 'Bold', glyph: 'B' },
2039
+ { command: 'emphasis', name: 'Italic', glyph: 'I' },
2040
+ { command: 'link', name: 'Link', glyph: '↗' },
2041
+ { command: 'bulletList', name: 'Bulleted list', glyph: '•' },
2042
+ { command: 'orderedList', name: 'Numbered list', glyph: '1.' },
2043
+ ];
2044
+ box = viewChild('box', /* @ts-ignore */
2045
+ ...(ngDevMode ? [{ debugName: "box" }] : /* istanbul ignore next */ []));
2046
+ toolbarButtons = viewChildren('toolbarButton', /* @ts-ignore */
2047
+ ...(ngDevMode ? [{ debugName: "toolbarButtons" }] : /* istanbul ignore next */ []));
2048
+ make = injectRichTextEditorFactory();
2049
+ editorHost = viewChild('editorHost', /* @ts-ignore */
2050
+ ...(ngDevMode ? [{ debugName: "editorHost" }] : /* istanbul ignore next */ []));
2051
+ handle;
2052
+ /**
2053
+ * Mount once, then feed.
2054
+ *
2055
+ * A contenteditable rebuilt when the value changes loses the caret, the
2056
+ * selection and the undo stack, which is the difference between an editor and
2057
+ * a box that fights you. So the effect mounts on its first run and afterwards
2058
+ * only pushes a value that came from somewhere other than this editor —
2059
+ * comparing first, because pushing back the change it just reported would move
2060
+ * the caret to the end after every keystroke.
2061
+ */
2062
+ mounted = effect(() => {
2063
+ const next = this.text();
2064
+ const element = this.editorHost()?.nativeElement;
2065
+ const make = this.make;
2066
+ if (element === undefined || make === null)
2067
+ return;
2068
+ if (this.handle === undefined) {
2069
+ this.handle = make({
2070
+ element,
2071
+ value: next,
2072
+ onChange: (value) => {
2073
+ this.field.setValue(value);
2074
+ },
2075
+ editable: this.field.snapshot().disabled !== true,
2076
+ attributes: this.editorAttributes(),
2077
+ });
2078
+ return;
2079
+ }
2080
+ if (this.handle.value() === next)
2081
+ return;
2082
+ // And never while the person is in the editor.
2083
+ //
2084
+ // Without this the editor reverts its own change. Pressing Bold updates the
2085
+ // document, reports the new answer, and the signal re-runs this effect — but
2086
+ // for one run `next` is still the answer from BEFORE the command. That run
2087
+ // sees a difference, pushes the stale answer back, un-bolds the word and
2088
+ // reports THAT. Observed in the playground: the stored value went to
2089
+ // `**hello**` and back to `hello` on its own.
2090
+ //
2091
+ // A value arriving from elsewhere while somebody is typing is rare; losing
2092
+ // what they just did is not recoverable. So the sync waits for them to leave,
2093
+ // and the comparison above catches it then.
2094
+ if (element.contains(document.activeElement))
2095
+ return;
2096
+ this.handle.setValue(next);
2097
+ }, /* @ts-ignore */
2098
+ ...(ngDevMode ? [{ debugName: "mounted" }] : /* istanbul ignore next */ []));
2099
+ cleanup = inject(DestroyRef).onDestroy(() => {
2100
+ // ProseMirror holds DOM listeners and a plugin state. One left per mounted
2101
+ // form is a leak that only shows up in a long-lived admin app.
2102
+ this.handle?.destroy();
2103
+ this.handle = undefined;
2104
+ });
2105
+ /**
2106
+ * The engine's wiring, passed to the surface rather than invented on it.
2107
+ *
2108
+ * Byte-identical to what the React binding passes, which is the point: the ids
2109
+ * and the describedby composition are computed once in `@formancy/core`, and a
2110
+ * renderer that assembled its own would be the implementation that drifts.
2111
+ * `aria-labelledby` rather than a `<label for>` because the surface is a div,
2112
+ * and `for` does not reach one.
2113
+ */
2114
+ editorAttributes() {
2115
+ const control = this.control();
2116
+ const props = this.field.snapshot().props;
2117
+ const attributes = {
2118
+ id: control.id,
2119
+ 'aria-labelledby': props.label.id,
2120
+ // The editing surface is a CHILD of the mount point, so it is the element
2121
+ // a theme has to style. Named here rather than left as the editor
2122
+ // library's own class, so a theme is not coupled to TipTap.
2123
+ 'data-formancy-part': 'richtext-surface',
2124
+ };
2125
+ const describedby = control['aria-describedby'];
2126
+ if (describedby !== undefined)
2127
+ attributes['aria-describedby'] = describedby;
2128
+ if (control['aria-invalid'] !== undefined)
2129
+ attributes['aria-invalid'] = 'true';
2130
+ if (control['aria-required'] !== undefined)
2131
+ attributes['aria-required'] = 'true';
2132
+ return attributes;
2133
+ }
2134
+ /** Named with the field: a form may have several of these, and "toolbar"
2135
+ * five times says nothing about which question is being answered. */
2136
+ toolbarLabel() {
2137
+ const label = this.context.label;
2138
+ return typeof label === 'string' ? `Formatting for ${label}` : 'Formatting';
2139
+ }
2140
+ press(command) {
2141
+ if (command === 'link') {
2142
+ // A prompt rather than a dialog this package would then own the
2143
+ // accessibility of. A host wanting its own replaces the field through
2144
+ // the component registry.
2145
+ const href = window.prompt('Address for the link');
2146
+ if (href === null || href === '')
2147
+ return;
2148
+ this.run(command, href);
2149
+ return;
2150
+ }
2151
+ this.run(command);
2152
+ }
2153
+ onKey(event) {
2154
+ if (!(event.ctrlKey || event.metaKey))
2155
+ return;
2156
+ const key = event.key.toLowerCase();
2157
+ const command = key === 'b' ? 'strong' : key === 'i' ? 'emphasis' : undefined;
2158
+ if (command === undefined)
2159
+ return;
2160
+ event.preventDefault();
2161
+ this.run(command);
2162
+ }
2163
+ onToolbarKey(event) {
2164
+ const last = this.commands.length - 1;
2165
+ const to = event.key === 'ArrowRight'
2166
+ ? this.active() + 1
2167
+ : event.key === 'ArrowLeft'
2168
+ ? this.active() - 1
2169
+ : event.key === 'Home'
2170
+ ? 0
2171
+ : event.key === 'End'
2172
+ ? last
2173
+ : undefined;
2174
+ if (to === undefined)
2175
+ return;
2176
+ event.preventDefault();
2177
+ const index = (to + this.commands.length) % this.commands.length;
2178
+ this.active.set(index);
2179
+ this.toolbarButtons()[index]?.nativeElement.focus();
2180
+ }
2181
+ run(command, href) {
2182
+ // With a WYSIWYG surface mounted the markers are not what somebody
2183
+ // typed, so inserting them would put literal asterisks into their
2184
+ // answer. The command goes to the editor instead.
2185
+ if (this.handle !== undefined) {
2186
+ this.handle.run(command, href);
2187
+ return;
2188
+ }
2189
+ const element = this.box()?.nativeElement;
2190
+ if (element === undefined)
2191
+ return;
2192
+ const next = applyRichCommand(command, { value: this.text(), start: element.selectionStart, end: element.selectionEnd }, href === undefined ? {} : { href });
2193
+ this.field.setValue(next.value);
2194
+ // After the signal has been written through to the DOM. An editor that
2195
+ // drops the caret to the end after every button is one nobody can use for
2196
+ // a second word.
2197
+ requestAnimationFrame(() => {
2198
+ element.focus();
2199
+ element.setSelectionRange(next.start, next.end);
2200
+ });
2201
+ }
2202
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: FormancyRichTextField, deps: null, target: i0.ɵɵFactoryTarget.Component });
2203
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.7", type: FormancyRichTextField, isStandalone: true, selector: "formancy-rich-text-field", viewQueries: [{ propertyName: "box", first: true, predicate: ["box"], descendants: true, isSignal: true }, { propertyName: "toolbarButtons", predicate: ["toolbarButton"], descendants: true, isSignal: true }, { propertyName: "editorHost", first: true, predicate: ["editorHost"], descendants: true, isSignal: true }], usesInheritance: true, ngImport: i0, template: `
2204
+ <formancy-field-shell [field]="field" [label]="context.label" [path]="context.path">
2205
+ @if (make !== null) {
2206
+ <!-- The toolbar stays. An editor library brings keyboard shortcuts and
2207
+ no toolbar UI, so leaving ours out left Bold reachable by Ctrl+B and
2208
+ by no visible control. One toolbar drives either surface, over the
2209
+ same RichCommand values. The preview does go: the surface IS the
2210
+ preview, which is the whole reason somebody wanted it. -->
2211
+ <div
2212
+ role="toolbar"
2213
+ [attr.aria-label]="toolbarLabel()"
2214
+ data-formancy-part="richtext-toolbar"
2215
+ (keydown)="onToolbarKey($event)"
2216
+ >
2217
+ @for (entry of commands; track entry.command; let i = $index) {
2218
+ <button
2219
+ type="button"
2220
+ #toolbarButton
2221
+ [disabled]="field.snapshot().disabled"
2222
+ [attr.tabindex]="i === active() ? 0 : -1"
2223
+ data-formancy-part="richtext-button"
2224
+ (focus)="active.set(i)"
2225
+ (click)="press(entry.command)"
2226
+ >
2227
+ <span aria-hidden="true">{{ entry.glyph }}</span>
2228
+ <span data-formancy-part="visually-hidden">{{ entry.name }}</span>
2229
+ </button>
2230
+ }
2231
+ </div>
2232
+ <div #editorHost data-formancy-part="richtext-editor" (blur)="field.touch()"></div>
2233
+ } @else {
2234
+ <!-- The ARIA toolbar pattern: ONE tab stop for the row, arrows within
2235
+ it. Five buttons that each took a tab press would put five stops
2236
+ between a keyboard user and the box they came to type in. -->
2237
+ <div
2238
+ role="toolbar"
2239
+ [attr.aria-label]="toolbarLabel()"
2240
+ data-formancy-part="richtext-toolbar"
2241
+ (keydown)="onToolbarKey($event)"
2242
+ >
2243
+ @for (entry of commands; track entry.command; let i = $index) {
2244
+ <button
2245
+ type="button"
2246
+ #toolbarButton
2247
+ [disabled]="field.snapshot().disabled"
2248
+ [attr.tabindex]="i === active() ? 0 : -1"
2249
+ data-formancy-part="richtext-button"
2250
+ (focus)="active.set(i)"
2251
+ (click)="press(entry.command)"
2252
+ >
2253
+ <span aria-hidden="true">{{ entry.glyph }}</span>
2254
+ <span data-formancy-part="visually-hidden">{{ entry.name }}</span>
2255
+ </button>
2256
+ }
2257
+ </div>
2258
+ <textarea
2259
+ #box
2260
+ [id]="control().id"
2261
+ [attr.name]="control().name"
2262
+ [attr.aria-describedby]="control()['aria-describedby']"
2263
+ [attr.aria-invalid]="control()['aria-invalid']"
2264
+ [attr.aria-required]="control()['aria-required']"
2265
+ [disabled]="field.snapshot().disabled"
2266
+ rows="5"
2267
+ [value]="text()"
2268
+ (keydown)="onKey($event)"
2269
+ (input)="field.setValue($any($event.target).value)"
2270
+ (blur)="field.touch()"
2271
+ ></textarea>
2272
+ <div data-formancy-part="richtext-preview">
2273
+ <formancy-rich-text [source]="text()" />
2274
+ </div>
2275
+ }
2276
+ </formancy-field-shell>
2277
+ `, isInline: true, dependencies: [{ kind: "component", type: FormancyFieldShell, selector: "formancy-field-shell", inputs: ["field", "label", "path"] }, { kind: "component", type: FormancyRichText, selector: "formancy-rich-text", inputs: ["source"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
2278
+ }
2279
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: FormancyRichTextField, decorators: [{
2280
+ type: Component,
2281
+ args: [{
2282
+ selector: 'formancy-rich-text-field',
2283
+ changeDetection: ChangeDetectionStrategy.OnPush,
2284
+ imports: [FormancyFieldShell, FormancyRichText],
2285
+ template: `
2286
+ <formancy-field-shell [field]="field" [label]="context.label" [path]="context.path">
2287
+ @if (make !== null) {
2288
+ <!-- The toolbar stays. An editor library brings keyboard shortcuts and
2289
+ no toolbar UI, so leaving ours out left Bold reachable by Ctrl+B and
2290
+ by no visible control. One toolbar drives either surface, over the
2291
+ same RichCommand values. The preview does go: the surface IS the
2292
+ preview, which is the whole reason somebody wanted it. -->
2293
+ <div
2294
+ role="toolbar"
2295
+ [attr.aria-label]="toolbarLabel()"
2296
+ data-formancy-part="richtext-toolbar"
2297
+ (keydown)="onToolbarKey($event)"
2298
+ >
2299
+ @for (entry of commands; track entry.command; let i = $index) {
2300
+ <button
2301
+ type="button"
2302
+ #toolbarButton
2303
+ [disabled]="field.snapshot().disabled"
2304
+ [attr.tabindex]="i === active() ? 0 : -1"
2305
+ data-formancy-part="richtext-button"
2306
+ (focus)="active.set(i)"
2307
+ (click)="press(entry.command)"
2308
+ >
2309
+ <span aria-hidden="true">{{ entry.glyph }}</span>
2310
+ <span data-formancy-part="visually-hidden">{{ entry.name }}</span>
2311
+ </button>
2312
+ }
2313
+ </div>
2314
+ <div #editorHost data-formancy-part="richtext-editor" (blur)="field.touch()"></div>
2315
+ } @else {
2316
+ <!-- The ARIA toolbar pattern: ONE tab stop for the row, arrows within
2317
+ it. Five buttons that each took a tab press would put five stops
2318
+ between a keyboard user and the box they came to type in. -->
2319
+ <div
2320
+ role="toolbar"
2321
+ [attr.aria-label]="toolbarLabel()"
2322
+ data-formancy-part="richtext-toolbar"
2323
+ (keydown)="onToolbarKey($event)"
2324
+ >
2325
+ @for (entry of commands; track entry.command; let i = $index) {
2326
+ <button
2327
+ type="button"
2328
+ #toolbarButton
2329
+ [disabled]="field.snapshot().disabled"
2330
+ [attr.tabindex]="i === active() ? 0 : -1"
2331
+ data-formancy-part="richtext-button"
2332
+ (focus)="active.set(i)"
2333
+ (click)="press(entry.command)"
2334
+ >
2335
+ <span aria-hidden="true">{{ entry.glyph }}</span>
2336
+ <span data-formancy-part="visually-hidden">{{ entry.name }}</span>
2337
+ </button>
2338
+ }
2339
+ </div>
2340
+ <textarea
2341
+ #box
2342
+ [id]="control().id"
2343
+ [attr.name]="control().name"
2344
+ [attr.aria-describedby]="control()['aria-describedby']"
2345
+ [attr.aria-invalid]="control()['aria-invalid']"
2346
+ [attr.aria-required]="control()['aria-required']"
2347
+ [disabled]="field.snapshot().disabled"
2348
+ rows="5"
2349
+ [value]="text()"
2350
+ (keydown)="onKey($event)"
2351
+ (input)="field.setValue($any($event.target).value)"
2352
+ (blur)="field.touch()"
2353
+ ></textarea>
2354
+ <div data-formancy-part="richtext-preview">
2355
+ <formancy-rich-text [source]="text()" />
2356
+ </div>
2357
+ }
2358
+ </formancy-field-shell>
2359
+ `,
2360
+ }]
2361
+ }], propDecorators: { box: [{ type: i0.ViewChild, args: ['box', { isSignal: true }] }], toolbarButtons: [{ type: i0.ViewChildren, args: ['toolbarButton', { isSignal: true }] }], editorHost: [{ type: i0.ViewChild, args: ['editorHost', { isSignal: true }] }] } });
2362
+ /**
2363
+ * Attached files.
2364
+ *
2365
+ * The control picks files; an injected uploader puts them somewhere and
2366
+ * reports what was stored. Without one the field is read-only and says so,
2367
+ * rather than accepting a file it has nowhere to put.
2368
+ */
2369
+ class FormancyFileField extends FieldComponentBase {
2370
+ upload = injectUploader();
2371
+ over = signal(false, /* @ts-ignore */
2372
+ ...(ngDevMode ? [{ debugName: "over" }] : /* istanbul ignore next */ []));
2373
+ /** Attachments taken out of the answer but not yet forgotten, with where
2374
+ * they came from so undoing restores the order as well as the file. */
2375
+ removed = signal([], /* @ts-ignore */
2376
+ ...(ngDevMode ? [{ debugName: "removed" }] : /* istanbul ignore next */ []));
2377
+ onDragOver(event) {
2378
+ // Without preventDefault the browser navigates to the file instead of
2379
+ // letting the page have it, which looks like the form vanishing.
2380
+ event.preventDefault();
2381
+ if (this.field.snapshot().disabled || this.busy())
2382
+ return;
2383
+ this.over.set(true);
2384
+ }
2385
+ onDrop(event) {
2386
+ event.preventDefault();
2387
+ this.over.set(false);
2388
+ if (this.field.snapshot().disabled || this.busy())
2389
+ return;
2390
+ const dropped = event.dataTransfer?.files;
2391
+ if (dropped === undefined || dropped.length === 0)
2392
+ return;
2393
+ void this.store(Array.from(dropped));
2394
+ }
2395
+ undo(id) {
2396
+ const entry = this.removed().find((other) => other.file.id === id);
2397
+ if (entry === undefined)
2398
+ return;
2399
+ this.removed.update((before) => before.filter((other) => other.file.id !== id));
2400
+ const next = [...this.files()];
2401
+ next.splice(Math.min(entry.at, next.length), 0, entry.file);
2402
+ this.field.setValue(next);
2403
+ }
2404
+ busy = signal(false, /* @ts-ignore */
2405
+ ...(ngDevMode ? [{ debugName: "busy" }] : /* istanbul ignore next */ []));
2406
+ failure = signal(undefined, /* @ts-ignore */
2407
+ ...(ngDevMode ? [{ debugName: "failure" }] : /* istanbul ignore next */ []));
2408
+ files = computed(() => {
2409
+ const value = this.field.snapshot().value;
2410
+ return Array.isArray(value) ? value : [];
2411
+ }, /* @ts-ignore */
2412
+ ...(ngDevMode ? [{ debugName: "files" }] : /* istanbul ignore next */ []));
2413
+ status = computed(() => (this.busy() ? 'Uploading…' : (this.failure() ?? '')), /* @ts-ignore */
2414
+ ...(ngDevMode ? [{ debugName: "status" }] : /* istanbul ignore next */ []));
2415
+ acceptAttribute() {
2416
+ const accept = this.field.snapshot().def.accept;
2417
+ return accept === undefined || accept.length === 0 ? null : accept.join(',');
2418
+ }
2419
+ multiple() {
2420
+ const max = this.field.snapshot().def.maxItems;
2421
+ return max === undefined || max > 1;
2422
+ }
2423
+ remove(id) {
2424
+ const at = this.files().findIndex((file) => file.id === id);
2425
+ const going = this.files()[at];
2426
+ if (going === undefined)
2427
+ return;
2428
+ // Out of the answer immediately, so a submit in between is correct, and
2429
+ // remembered with its position so undoing puts it BACK where it was rather
2430
+ // than on the end — the order matters to somebody who numbered their
2431
+ // attachments in a covering note.
2432
+ this.removed.update((before) => [...before, { at, file: going }]);
2433
+ this.field.setValue(this.files().filter((file) => file.id !== id));
2434
+ }
2435
+ pick(event) {
2436
+ const input = event.target;
2437
+ const picked = input.files;
2438
+ if (picked === null || picked.length === 0 || this.upload === null)
2439
+ return;
2440
+ void this.store(Array.from(picked)).finally(() => {
2441
+ input.value = '';
2442
+ });
2443
+ }
2444
+ async store(picked) {
2445
+ this.busy.set(true);
2446
+ this.failure.set(undefined);
2447
+ // Each file succeeds or fails on its own.
2448
+ //
2449
+ // The first version collected them into an array and set the value once, so
2450
+ // a throw on the third of five discarded the two that had ALREADY uploaded:
2451
+ // their bytes were in storage, the submission never mentioned them, the
2452
+ // collector reclaimed them within the day, and the person was told the
2453
+ // upload failed when half of it had not. Whose fault the failure is does not
2454
+ // change who loses the file. The React binding does exactly the same thing.
2455
+ const uploaded = [];
2456
+ const refused = [];
2457
+ for (const file of picked) {
2458
+ try {
2459
+ uploaded.push(await this.upload(file));
2460
+ }
2461
+ catch (error) {
2462
+ refused.push(`${file.name} (${error instanceof Error ? error.message : String(error)})`);
2463
+ }
2464
+ }
2465
+ // Recorded before the failure is reported, so nothing that reached storage
2466
+ // is left unclaimed while somebody reads the message.
2467
+ if (uploaded.length > 0)
2468
+ this.field.setValue([...this.files(), ...uploaded]);
2469
+ if (refused.length > 0) {
2470
+ // Named, because "the upload failed" over a list of five attachments does
2471
+ // not say which one to try again.
2472
+ this.failure.set(refused.length === 1
2473
+ ? `${refused[0]} was not attached.`
2474
+ : `${String(refused.length)} files were not attached: ${refused.join(', ')}.`);
2475
+ }
2476
+ this.busy.set(false);
2477
+ this.field.touch();
2478
+ }
2479
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: FormancyFileField, deps: null, target: i0.ɵɵFactoryTarget.Component });
2480
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.7", type: FormancyFileField, isStandalone: true, selector: "formancy-file-field", usesInheritance: true, ngImport: i0, template: `
2481
+ <formancy-field-shell [field]="field" [label]="context.label" [path]="context.path">
2482
+ @if (upload === null) {
2483
+ <p data-formancy-part="file-unavailable">
2484
+ This form cannot accept files here, because no upload destination has been configured.
2485
+ </p>
2486
+ } @else {
2487
+ <!--
2488
+ The picker inside a drop target, not instead of one.
2489
+
2490
+ Dropping is a pointer gesture with no keyboard equivalent, so it can
2491
+ only ever be a SECOND route (WCAG 2.1.1). The input stays exactly as it
2492
+ was and keeps the field's label and ARIA wiring; the region around it
2493
+ accepts a drop and hands the files to the same function. Two routes,
2494
+ one implementation, the same as the React binding.
2495
+ -->
2496
+ <div
2497
+ data-formancy-part="file-dropzone"
2498
+ [attr.data-state]="over() ? 'over' : null"
2499
+ (dragover)="onDragOver($event)"
2500
+ (dragleave)="over.set(false)"
2501
+ (drop)="onDrop($event)"
2502
+ >
552
2503
  <input
553
- type="radio"
554
- [id]="optionId(option)"
2504
+ type="file"
2505
+ [id]="control().id"
555
2506
  [attr.name]="control().name"
556
- [value]="option.value"
557
- [checked]="field.snapshot().value === option.value"
558
- (change)="field.setValue(option.value)"
559
- (blur)="field.touch()"
2507
+ [attr.aria-describedby]="control()['aria-describedby']"
2508
+ [attr.accept]="acceptAttribute()"
2509
+ [attr.multiple]="multiple() ? '' : null"
2510
+ [disabled]="field.snapshot().disabled || busy()"
2511
+ (change)="pick($event)"
560
2512
  />
561
- <label [attr.for]="optionId(option)">{{ option.label }}</label>
562
- </span>
2513
+ </div>
563
2514
  }
564
- @if (showError()) {
565
- <p data-formancy-part="error" [id]="field.snapshot().props.error.id">{{ errorText() }}</p>
2515
+
2516
+ @if (files().length > 0 || removed().length > 0) {
2517
+ <ul data-formancy-part="file-list">
2518
+ @for (file of files(); track file.id) {
2519
+ <li data-formancy-part="file-item">
2520
+ <span>{{ file.name }}</span>
2521
+ <button
2522
+ type="button"
2523
+ [disabled]="field.snapshot().disabled"
2524
+ (click)="remove(file.id)"
2525
+ >
2526
+ <!-- Named with the file, so a screen reader user hears which
2527
+ attachment a button removes rather than "remove" six
2528
+ times over. -->
2529
+ Remove {{ file.name }}
2530
+ </button>
2531
+ </li>
2532
+ }
2533
+
2534
+ <!--
2535
+ A removed attachment stays visible with a way back. The bytes are
2536
+ still in storage until the unclaimed collector runs, so the removal
2537
+ is recoverable for free, and a misclick on the wrong row of six is
2538
+ the ordinary way somebody loses the evidence they came to attach.
2539
+ -->
2540
+ @for (entry of removed(); track entry.file.id) {
2541
+ <li data-formancy-part="file-item" data-state="removed">
2542
+ <span>{{ entry.file.name }}</span>
2543
+ <button
2544
+ type="button"
2545
+ [disabled]="field.snapshot().disabled"
2546
+ (click)="undo(entry.file.id)"
2547
+ >
2548
+ Undo removing {{ entry.file.name }}
2549
+ </button>
2550
+ </li>
2551
+ }
2552
+ </ul>
566
2553
  }
567
- </fieldset>
568
- `, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
2554
+
2555
+ <!-- One polite region per field for the upload itself: the form's error
2556
+ region belongs to validation, and a failed upload is not one. -->
2557
+ <p role="status" data-formancy-part="file-status">{{ status() }}</p>
2558
+ </formancy-field-shell>
2559
+ `, isInline: true, dependencies: [{ kind: "component", type: FormancyFieldShell, selector: "formancy-field-shell", inputs: ["field", "label", "path"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
569
2560
  }
570
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: FormancyRadioGroupField, decorators: [{
2561
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: FormancyFileField, decorators: [{
571
2562
  type: Component,
572
2563
  args: [{
573
- selector: 'formancy-radio-group-field',
2564
+ selector: 'formancy-file-field',
574
2565
  changeDetection: ChangeDetectionStrategy.OnPush,
2566
+ imports: [FormancyFieldShell],
575
2567
  template: `
576
- <fieldset
577
- data-formancy-part="field"
578
- [attr.data-state]="showError() ? 'invalid' : 'valid'"
579
- [attr.aria-describedby]="control()['aria-describedby']"
580
- >
581
- <legend data-formancy-part="label">{{ context.label }}</legend>
582
- @for (option of options(); track option.value) {
583
- <span data-formancy-part="radio-option">
2568
+ <formancy-field-shell [field]="field" [label]="context.label" [path]="context.path">
2569
+ @if (upload === null) {
2570
+ <p data-formancy-part="file-unavailable">
2571
+ This form cannot accept files here, because no upload destination has been configured.
2572
+ </p>
2573
+ } @else {
2574
+ <!--
2575
+ The picker inside a drop target, not instead of one.
2576
+
2577
+ Dropping is a pointer gesture with no keyboard equivalent, so it can
2578
+ only ever be a SECOND route (WCAG 2.1.1). The input stays exactly as it
2579
+ was and keeps the field's label and ARIA wiring; the region around it
2580
+ accepts a drop and hands the files to the same function. Two routes,
2581
+ one implementation, the same as the React binding.
2582
+ -->
2583
+ <div
2584
+ data-formancy-part="file-dropzone"
2585
+ [attr.data-state]="over() ? 'over' : null"
2586
+ (dragover)="onDragOver($event)"
2587
+ (dragleave)="over.set(false)"
2588
+ (drop)="onDrop($event)"
2589
+ >
584
2590
  <input
585
- type="radio"
586
- [id]="optionId(option)"
2591
+ type="file"
2592
+ [id]="control().id"
587
2593
  [attr.name]="control().name"
588
- [value]="option.value"
589
- [checked]="field.snapshot().value === option.value"
590
- (change)="field.setValue(option.value)"
591
- (blur)="field.touch()"
2594
+ [attr.aria-describedby]="control()['aria-describedby']"
2595
+ [attr.accept]="acceptAttribute()"
2596
+ [attr.multiple]="multiple() ? '' : null"
2597
+ [disabled]="field.snapshot().disabled || busy()"
2598
+ (change)="pick($event)"
592
2599
  />
593
- <label [attr.for]="optionId(option)">{{ option.label }}</label>
594
- </span>
2600
+ </div>
595
2601
  }
596
- @if (showError()) {
597
- <p data-formancy-part="error" [id]="field.snapshot().props.error.id">{{ errorText() }}</p>
2602
+
2603
+ @if (files().length > 0 || removed().length > 0) {
2604
+ <ul data-formancy-part="file-list">
2605
+ @for (file of files(); track file.id) {
2606
+ <li data-formancy-part="file-item">
2607
+ <span>{{ file.name }}</span>
2608
+ <button
2609
+ type="button"
2610
+ [disabled]="field.snapshot().disabled"
2611
+ (click)="remove(file.id)"
2612
+ >
2613
+ <!-- Named with the file, so a screen reader user hears which
2614
+ attachment a button removes rather than "remove" six
2615
+ times over. -->
2616
+ Remove {{ file.name }}
2617
+ </button>
2618
+ </li>
2619
+ }
2620
+
2621
+ <!--
2622
+ A removed attachment stays visible with a way back. The bytes are
2623
+ still in storage until the unclaimed collector runs, so the removal
2624
+ is recoverable for free, and a misclick on the wrong row of six is
2625
+ the ordinary way somebody loses the evidence they came to attach.
2626
+ -->
2627
+ @for (entry of removed(); track entry.file.id) {
2628
+ <li data-formancy-part="file-item" data-state="removed">
2629
+ <span>{{ entry.file.name }}</span>
2630
+ <button
2631
+ type="button"
2632
+ [disabled]="field.snapshot().disabled"
2633
+ (click)="undo(entry.file.id)"
2634
+ >
2635
+ Undo removing {{ entry.file.name }}
2636
+ </button>
2637
+ </li>
2638
+ }
2639
+ </ul>
598
2640
  }
599
- </fieldset>
2641
+
2642
+ <!-- One polite region per field for the upload itself: the form's error
2643
+ region belongs to validation, and a failed upload is not one. -->
2644
+ <p role="status" data-formancy-part="file-status">{{ status() }}</p>
2645
+ </formancy-field-shell>
600
2646
  `,
601
2647
  }]
602
2648
  }] });
603
- /**
604
- * The built-in unstyled components. `null` means the type renders nothing here:
605
- * hidden and static are non-inputs, and the container types are laid out by
606
- * their own machinery, not by a leaf slot.
607
- */
608
2649
  const DEFAULT_FIELD_COMPONENTS = {
609
2650
  text: FormancyTextField,
610
2651
  textarea: FormancyTextareaField,
611
2652
  number: FormancyNumberField,
612
2653
  checkbox: FormancyCheckboxField,
613
2654
  date: FormancyDateField,
2655
+ time: FormancyTimeField,
2656
+ datetime: FormancyDateTimeField,
614
2657
  select: FormancySelectField,
615
2658
  radio: FormancyRadioGroupField,
2659
+ selectboxes: FormancySelectBoxesField,
2660
+ file: FormancyFileField,
2661
+ richtext: FormancyRichTextField,
616
2662
  hidden: null,
617
- static: null,
2663
+ static: FormancyStaticField,
618
2664
  group: null,
619
2665
  page: null,
620
2666
  repeater: null,
@@ -725,10 +2771,80 @@ class FormancyRepeaterSection {
725
2771
  ...(ngDevMode ? [{ debugName: "wire" }] : /* istanbul ignore next */ []));
726
2772
  labels = input(/* @ts-ignore */
727
2773
  ...(ngDevMode ? [undefined, { debugName: "labels" }] : /* istanbul ignore next */ []));
2774
+ /** The repeater's own definition, kept because the column plan reads `widget`,
2775
+ * `columns` and the child labels off it on every render. */
2776
+ definition;
728
2777
  state;
2778
+ /**
2779
+ * The columns the grid shows, in the order it shows them, or an empty list when the
2780
+ * widget was not asked for.
2781
+ *
2782
+ * `columns` without the widget stays inert on purpose: 0066 lets an author write the
2783
+ * arrangement before a renderer honours it, and a repeater that silently became a grid
2784
+ * because somebody sized its columns would be the opposite of that.
2785
+ *
2786
+ * The React binding computes the same list with the same helper, for the same reasons.
2787
+ */
2788
+ plan = computed(() => {
2789
+ const def = this.definition;
2790
+ if (def?.widget !== 'datagrid')
2791
+ return [];
2792
+ return datagridColumns(def, def.columns ?? []).map((entry) => ({
2793
+ key: entry.key,
2794
+ // The author's shortening, else the child's own label, else the labels input,
2795
+ // else the key. Visible text and NOTHING else -- never an id target and never an
2796
+ // aria-label, because a heading that named the answers beneath it is the failure
2797
+ // 0066 separates `header` from `label` to prevent.
2798
+ heading: this.engine.text(entry.column?.header) ??
2799
+ this.engine.text(entry.child?.label) ??
2800
+ this.fallbackFor(`${this.wire()}[0].${entry.key}`) ??
2801
+ entry.key,
2802
+ align: entry.column?.align ?? null,
2803
+ ...(entry.column?.width === undefined ? {} : { width: entry.column.width }),
2804
+ }));
2805
+ }, /* @ts-ignore */
2806
+ ...(ngDevMode ? [{ debugName: "plan" }] : /* istanbul ignore next */ []));
2807
+ /**
2808
+ * The authored ratios, as ONE custom property rather than as `grid-template-columns`.
2809
+ *
2810
+ * A property lays nothing out by itself, so a theme's narrow-screen media query
2811
+ * replaces its own declaration and wins rather than losing to an inline one it cannot
2812
+ * outrank. It also carries a value space no attribute could enumerate: `width` is a
2813
+ * number with `exclusiveMinimum: 0`, so 1.5 is legal and nothing bounds it from above.
2814
+ *
2815
+ * Nothing at all when no column was sized, so the theme's fallback is live code.
2816
+ */
2817
+ trackStyle() {
2818
+ const plan = this.plan();
2819
+ if (!plan.some((entry) => entry.width !== undefined))
2820
+ return {};
2821
+ return {
2822
+ '--fm-datagrid-columns': plan
2823
+ .map((entry) => (entry.width === undefined ? '1fr' : `${String(entry.width)}fr`))
2824
+ .join(' '),
2825
+ };
2826
+ }
2827
+ /**
2828
+ * The controls in one cell: every leaf inside the row that belongs to that column's
2829
+ * child field.
2830
+ *
2831
+ * One child, because a grid's rows are FLAT: a child holding fields of its own is
2832
+ * refused when the document is saved (0078). This walked the whole subtree under the
2833
+ * child while a group could be a column, and every clause that made that walk safe is
2834
+ * gone with the arrangement it served.
2835
+ *
2836
+ * Still a filter over the children that EXIST rather than the wire the column implies,
2837
+ * so a document nobody validated renders an empty cell rather than a field the engine
2838
+ * does not have.
2839
+ */
2840
+ cellChildren(row, key) {
2841
+ const wanted = `${this.wire()}[${String(row.index)}].${key}`;
2842
+ return row.children.filter((child) => child.wire === wanted);
2843
+ }
729
2844
  ngOnInit() {
730
2845
  const wire = this.wire();
731
2846
  const def = this.engine.repeaters().find((candidate) => candidate.wire === wire)?.def;
2847
+ this.definition = def;
732
2848
  const label = this.engine.text(def?.label) ?? this.labels()?.[wire] ?? wire;
733
2849
  const minItems = def?.minItems ?? 0;
734
2850
  const repeater = runInInjectionContext(this.injector, () => {
@@ -754,7 +2870,13 @@ class FormancyRepeaterSection {
754
2870
  // it, and tracking by index would make Angular reuse the wrong DOM
755
2871
  // nodes — moving focus and animating the wrong element.
756
2872
  id: repeater.rowIds()[index] ?? String(index),
757
- wires: this.engine.fieldPaths().filter((candidate) => candidate.startsWith(`${wire}[${index}]`)),
2873
+ children: this.engine
2874
+ .fieldPaths()
2875
+ .filter((candidate) => candidate.startsWith(`${wire}[${index}]`))
2876
+ .map((candidate) => ({
2877
+ key: candidate.slice(`${wire}[${index}]`.length),
2878
+ wire: candidate,
2879
+ })),
758
2880
  }))),
759
2881
  };
760
2882
  }
@@ -767,40 +2889,202 @@ class FormancyRepeaterSection {
767
2889
  @if (state; as s) {
768
2890
  <fieldset data-formancy-part="repeater">
769
2891
  <legend data-formancy-part="repeater-legend">{{ s.label }}</legend>
770
- @for (row of s.rows(); track row.id) {
771
- <div data-formancy-part="row">
772
- @for (instanceWire of row.wires; track instanceWire) {
773
- <formancy-field [path]="instanceWire" [fallbackLabel]="fallbackFor(instanceWire)" />
2892
+ <!-- The buttons a row carries, written once and used by both arrangements.
2893
+ Each one's text sits in its own element so a THEME can clip it and draw a
2894
+ mark instead, which is what a grid wants: "Remove recipient 1 of 1" on
2895
+ three wrapped lines took more room than the answers beside it. Clipped and
2896
+ never removed -- display: none and visibility: hidden both compute the
2897
+ button's name to the empty string, and a button called nothing is worse
2898
+ than a wide one. The renderer draws no mark of its own, because an icon is
2899
+ appearance and appearance belongs to the consumer.
2900
+ Their NAMES are identical in each, because 0068 put the row's position in
2901
+ them and a grid does not change where a person is. -->
2902
+ <ng-template #rowButtons let-row let-count="count">
2903
+ <!-- Position context in the NAME, so a screen-reader user knows
2904
+ which row this button kills without walking the tree. -->
2905
+ <button type="button" data-formancy-part="row-remove" (click)="state!.repeater.removeRow(row.index)"><span data-formancy-part="row-action-text">{{ state!.removeLabel }} {{ row.index + 1 }} of {{ count }}</span></button>
2906
+ <!-- Reordering by button, which is the KEYBOARD route and therefore the
2907
+ primary one: WCAG 2.5.7 requires a non-drag equivalent for any drag,
2908
+ so a drag affordance can only ever be a second route to these.
2909
+ Absent at the ends rather than disabled: a disabled button is still in
2910
+ the tab order in some browsers and announces a control that does
2911
+ nothing. -->
2912
+ @if (row.index > 0) {
2913
+ <button type="button" data-formancy-part="row-up" (click)="state!.repeater.moveRow(row.index, row.index - 1)"><span data-formancy-part="row-action-text">Move {{ state!.label }} {{ row.index + 1 }} of {{ count }} up</span></button>
2914
+ }
2915
+ @if (row.index < count - 1) {
2916
+ <button type="button" data-formancy-part="row-down" (click)="state!.repeater.moveRow(row.index, row.index + 1)"><span data-formancy-part="row-action-text">Move {{ state!.label }} {{ row.index + 1 }} of {{ count }} down</span></button>
2917
+ }
2918
+ </ng-template>
2919
+
2920
+ @if (plan().length > 0) {
2921
+ <!-- A container inside the fieldset rather than the fieldset itself, so the
2922
+ legend and the Add button do not become grid items.
2923
+
2924
+ No role anywhere in here, and that is the decision rather than an
2925
+ omission. role="grid" would take the arrow keys, which the controls in
2926
+ the cells already own -- a select with widget: "typeahead" is legal in a
2927
+ row and claims Up, Down, Home, End, Enter and Escape -- and it would
2928
+ replace twenty tab stops with one. A role is not paint, which is the
2929
+ line 0065 draws. -->
2930
+ <div data-formancy-part="datagrid" [attr.data-columns]="plan().length" [style]="trackStyle()">
2931
+ <!-- Only once there is a row to head, and plain static text. A heading here
2932
+ names nothing, so a theme may delete it at phone width without changing
2933
+ what any control announces -- which is what makes the narrow-screen
2934
+ reflow possible at all. -->
2935
+ @if (s.rows().length > 0) {
2936
+ <div data-formancy-part="datagrid-head">
2937
+ @for (entry of plan(); track entry.key) {
2938
+ <span data-formancy-part="datagrid-heading" [attr.data-align]="entry.align">{{ entry.heading }}</span>
2939
+ }
2940
+ </div>
2941
+ }
2942
+ @for (row of s.rows(); track row.id) {
2943
+ <div data-formancy-part="datagrid-row">
2944
+ @for (entry of plan(); track entry.key) {
2945
+ <!-- Always emitted, even when every field in it renders nothing. A
2946
+ rule that hides one answer must not shift that row's remaining
2947
+ columns out of line with the heading strip and with every other
2948
+ row, which is the whole reason this arrangement exists. -->
2949
+ <div data-formancy-part="datagrid-cell" [attr.data-align]="entry.align">
2950
+ @for (child of cellChildren(row, entry.key); track child.wire) {
2951
+ <formancy-field [path]="child.wire" [fallbackLabel]="fallbackFor(child.wire)" />
2952
+ }
2953
+ </div>
2954
+ }
2955
+ <!-- One cell for all of a row's buttons, because there are two on the
2956
+ first and last rows and three in between. A track whose cell count
2957
+ varied per row is exactly what a table cannot express without a
2958
+ cell that announces a blank. -->
2959
+ <div data-formancy-part="datagrid-actions">
2960
+ <ng-container *ngTemplateOutlet="rowButtons; context: { $implicit: row, count: s.rows().length }" />
2961
+ </div>
2962
+ </div>
774
2963
  }
775
- <!-- Position context in the NAME, so a screen-reader user knows
776
- which row this button kills without walking the tree. -->
777
- <button type="button" (click)="s.repeater.removeRow(row.index)">{{ s.removeLabel }} {{ row.index + 1 }} of {{ s.rows().length }}</button>
778
2964
  </div>
2965
+ } @else {
2966
+ @for (row of s.rows(); track row.id) {
2967
+ <div data-formancy-part="row">
2968
+ <!-- Tracked by the POSITIONAL WIRE, which recreates every control in a
2969
+ row whenever the row moves, so focus is lost on a reorder.
2970
+ Deliberate, and the obstacle is named so the next attempt starts from
2971
+ it: tracking by the field's key instead would let Angular reuse the
2972
+ component, and this component reads its path once in ngOnInit and
2973
+ never rebinds -- so after a removal it would keep the old wire and
2974
+ show the wrong row's answer. A conformance fixture caught exactly
2975
+ that. Reactive path binding has to come first. -->
2976
+ @for (child of row.children; track child.wire) {
2977
+ <formancy-field [path]="child.wire" [fallbackLabel]="fallbackFor(child.wire)" />
2978
+ }
2979
+ <ng-container *ngTemplateOutlet="rowButtons; context: { $implicit: row, count: s.rows().length }" />
2980
+ </div>
2981
+ }
779
2982
  }
780
2983
  <button type="button" (click)="s.repeater.addRow()">{{ s.addLabel }}</button>
781
2984
  </fieldset>
782
2985
  }
783
- `, isInline: true, dependencies: [{ kind: "component", type: FormancyFieldSlot, selector: "formancy-field", inputs: ["path", "fallbackLabel"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
2986
+ `, isInline: true, dependencies: [{ kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "component", type: FormancyFieldSlot, selector: "formancy-field", inputs: ["path", "fallbackLabel"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
784
2987
  }
785
2988
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: FormancyRepeaterSection, decorators: [{
786
2989
  type: Component,
787
2990
  args: [{
788
2991
  selector: 'formancy-repeater',
789
2992
  changeDetection: ChangeDetectionStrategy.OnPush,
790
- imports: [FormancyFieldSlot],
2993
+ imports: [NgTemplateOutlet, FormancyFieldSlot],
791
2994
  template: `
792
2995
  @if (state; as s) {
793
2996
  <fieldset data-formancy-part="repeater">
794
2997
  <legend data-formancy-part="repeater-legend">{{ s.label }}</legend>
795
- @for (row of s.rows(); track row.id) {
796
- <div data-formancy-part="row">
797
- @for (instanceWire of row.wires; track instanceWire) {
798
- <formancy-field [path]="instanceWire" [fallbackLabel]="fallbackFor(instanceWire)" />
2998
+ <!-- The buttons a row carries, written once and used by both arrangements.
2999
+ Each one's text sits in its own element so a THEME can clip it and draw a
3000
+ mark instead, which is what a grid wants: "Remove recipient 1 of 1" on
3001
+ three wrapped lines took more room than the answers beside it. Clipped and
3002
+ never removed -- display: none and visibility: hidden both compute the
3003
+ button's name to the empty string, and a button called nothing is worse
3004
+ than a wide one. The renderer draws no mark of its own, because an icon is
3005
+ appearance and appearance belongs to the consumer.
3006
+ Their NAMES are identical in each, because 0068 put the row's position in
3007
+ them and a grid does not change where a person is. -->
3008
+ <ng-template #rowButtons let-row let-count="count">
3009
+ <!-- Position context in the NAME, so a screen-reader user knows
3010
+ which row this button kills without walking the tree. -->
3011
+ <button type="button" data-formancy-part="row-remove" (click)="state!.repeater.removeRow(row.index)"><span data-formancy-part="row-action-text">{{ state!.removeLabel }} {{ row.index + 1 }} of {{ count }}</span></button>
3012
+ <!-- Reordering by button, which is the KEYBOARD route and therefore the
3013
+ primary one: WCAG 2.5.7 requires a non-drag equivalent for any drag,
3014
+ so a drag affordance can only ever be a second route to these.
3015
+ Absent at the ends rather than disabled: a disabled button is still in
3016
+ the tab order in some browsers and announces a control that does
3017
+ nothing. -->
3018
+ @if (row.index > 0) {
3019
+ <button type="button" data-formancy-part="row-up" (click)="state!.repeater.moveRow(row.index, row.index - 1)"><span data-formancy-part="row-action-text">Move {{ state!.label }} {{ row.index + 1 }} of {{ count }} up</span></button>
3020
+ }
3021
+ @if (row.index < count - 1) {
3022
+ <button type="button" data-formancy-part="row-down" (click)="state!.repeater.moveRow(row.index, row.index + 1)"><span data-formancy-part="row-action-text">Move {{ state!.label }} {{ row.index + 1 }} of {{ count }} down</span></button>
3023
+ }
3024
+ </ng-template>
3025
+
3026
+ @if (plan().length > 0) {
3027
+ <!-- A container inside the fieldset rather than the fieldset itself, so the
3028
+ legend and the Add button do not become grid items.
3029
+
3030
+ No role anywhere in here, and that is the decision rather than an
3031
+ omission. role="grid" would take the arrow keys, which the controls in
3032
+ the cells already own -- a select with widget: "typeahead" is legal in a
3033
+ row and claims Up, Down, Home, End, Enter and Escape -- and it would
3034
+ replace twenty tab stops with one. A role is not paint, which is the
3035
+ line 0065 draws. -->
3036
+ <div data-formancy-part="datagrid" [attr.data-columns]="plan().length" [style]="trackStyle()">
3037
+ <!-- Only once there is a row to head, and plain static text. A heading here
3038
+ names nothing, so a theme may delete it at phone width without changing
3039
+ what any control announces -- which is what makes the narrow-screen
3040
+ reflow possible at all. -->
3041
+ @if (s.rows().length > 0) {
3042
+ <div data-formancy-part="datagrid-head">
3043
+ @for (entry of plan(); track entry.key) {
3044
+ <span data-formancy-part="datagrid-heading" [attr.data-align]="entry.align">{{ entry.heading }}</span>
3045
+ }
3046
+ </div>
3047
+ }
3048
+ @for (row of s.rows(); track row.id) {
3049
+ <div data-formancy-part="datagrid-row">
3050
+ @for (entry of plan(); track entry.key) {
3051
+ <!-- Always emitted, even when every field in it renders nothing. A
3052
+ rule that hides one answer must not shift that row's remaining
3053
+ columns out of line with the heading strip and with every other
3054
+ row, which is the whole reason this arrangement exists. -->
3055
+ <div data-formancy-part="datagrid-cell" [attr.data-align]="entry.align">
3056
+ @for (child of cellChildren(row, entry.key); track child.wire) {
3057
+ <formancy-field [path]="child.wire" [fallbackLabel]="fallbackFor(child.wire)" />
3058
+ }
3059
+ </div>
3060
+ }
3061
+ <!-- One cell for all of a row's buttons, because there are two on the
3062
+ first and last rows and three in between. A track whose cell count
3063
+ varied per row is exactly what a table cannot express without a
3064
+ cell that announces a blank. -->
3065
+ <div data-formancy-part="datagrid-actions">
3066
+ <ng-container *ngTemplateOutlet="rowButtons; context: { $implicit: row, count: s.rows().length }" />
3067
+ </div>
3068
+ </div>
799
3069
  }
800
- <!-- Position context in the NAME, so a screen-reader user knows
801
- which row this button kills without walking the tree. -->
802
- <button type="button" (click)="s.repeater.removeRow(row.index)">{{ s.removeLabel }} {{ row.index + 1 }} of {{ s.rows().length }}</button>
803
3070
  </div>
3071
+ } @else {
3072
+ @for (row of s.rows(); track row.id) {
3073
+ <div data-formancy-part="row">
3074
+ <!-- Tracked by the POSITIONAL WIRE, which recreates every control in a
3075
+ row whenever the row moves, so focus is lost on a reorder.
3076
+ Deliberate, and the obstacle is named so the next attempt starts from
3077
+ it: tracking by the field's key instead would let Angular reuse the
3078
+ component, and this component reads its path once in ngOnInit and
3079
+ never rebinds -- so after a removal it would keep the old wire and
3080
+ show the wrong row's answer. A conformance fixture caught exactly
3081
+ that. Reactive path binding has to come first. -->
3082
+ @for (child of row.children; track child.wire) {
3083
+ <formancy-field [path]="child.wire" [fallbackLabel]="fallbackFor(child.wire)" />
3084
+ }
3085
+ <ng-container *ngTemplateOutlet="rowButtons; context: { $implicit: row, count: s.rows().length }" />
3086
+ </div>
3087
+ }
804
3088
  }
805
3089
  <button type="button" (click)="s.repeater.addRow()">{{ s.addLabel }}</button>
806
3090
  </fieldset>
@@ -808,6 +3092,694 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImpor
808
3092
  `,
809
3093
  }]
810
3094
  }], propDecorators: { wire: [{ type: i0.Input, args: [{ isSignal: true, alias: "wire", required: true }] }], labels: [{ type: i0.Input, args: [{ isSignal: true, alias: "labels", required: false }] }] } });
3095
+ /**
3096
+ * Rendering a named `layouts` entry — fields side by side, in sections, in the
3097
+ * arrangement the document asks for rather than model order. The Angular half
3098
+ * of the same decisions packages/react/src/layout.tsx documents, and
3099
+ * deliberately the same markup.
3100
+ *
3101
+ * Four WCAG criteria shape it, and all four say the DOM is the arrangement:
3102
+ *
3103
+ * - **1.3.2 Meaningful Sequence** and **2.4.3 Focus Order** — children are
3104
+ * emitted in declared order and the stylesheet places them by source order
3105
+ * alone. Nothing here or in CSS may reorder them, or a screen reader and an
3106
+ * eye meet the form in different orders.
3107
+ * - **1.4.10 Reflow** — becoming one column when there is no room for two is a
3108
+ * media query, not a measurement. A layout that reflows only after scripts
3109
+ * have run does not reflow.
3110
+ * - **1.3.1 Info and Relationships** — a row is presentation and gets no
3111
+ * semantics; a labelled section is visibly grouping fields, so it is a real
3112
+ * `group` with an accessible name.
3113
+ *
3114
+ * Every container also carries `data-formancy-layout-path`, the index path of
3115
+ * the node that produced it, matching the React renderer attribute for
3116
+ * attribute. It is inert — nothing in this package reads it — and exists so a
3117
+ * tool outside the renderer can say which node an element on screen came from
3118
+ * without the renderer knowing anything about editing.
3119
+ */
3120
+ /**
3121
+ * One panel at a time, behind a row of tabs — the ARIA tabs pattern, matching
3122
+ * the React binding element for element.
3123
+ *
3124
+ * Written out rather than reached for from a library because the keyboard
3125
+ * behaviour IS the specification: arrows move between tabs, Home and End reach
3126
+ * the ends, and the strip is one tab stop through a roving tabindex, so a form
3127
+ * with twelve tabs does not cost twelve presses to get past.
3128
+ *
3129
+ * **Every panel stays in the DOM.** A closed tab is hidden, not removed. Tabs
3130
+ * are presentation, unlike pages: a field in a closed tab is still validated
3131
+ * and still submitted, so it has to be there to be validated, and the
3132
+ * browser's own find-in-page finds it. Removing it would also throw away what
3133
+ * somebody had typed the moment they looked at another tab.
3134
+ *
3135
+ * **A tab opens before error navigation focuses a control inside it.** An error summary focuses the
3136
+ * first invalid control, and focusing something inside a hidden panel does
3137
+ * nothing at all — the reader is told the form has an error and sent nowhere.
3138
+ */
3139
+ class FormancyTabs {
3140
+ at = input('', /* @ts-ignore */
3141
+ ...(ngDevMode ? [{ debugName: "at" }] : /* istanbul ignore next */ []));
3142
+ stripLabel = input(null, /* @ts-ignore */
3143
+ ...(ngDevMode ? [{ debugName: "stripLabel" }] : /* istanbul ignore next */ []));
3144
+ panels = input.required(/* @ts-ignore */
3145
+ ...(ngDevMode ? [{ debugName: "panels" }] : /* istanbul ignore next */ []));
3146
+ labels = input(undefined, /* @ts-ignore */
3147
+ ...(ngDevMode ? [{ debugName: "labels" }] : /* istanbul ignore next */ []));
3148
+ open = signal(0, /* @ts-ignore */
3149
+ ...(ngDevMode ? [{ debugName: "open" }] : /* istanbul ignore next */ []));
3150
+ changeDetector = inject(ChangeDetectorRef);
3151
+ reveal(index) {
3152
+ this.open.set(index);
3153
+ this.changeDetector.detectChanges();
3154
+ }
3155
+ tabButtons = viewChildren('tab', /* @ts-ignore */
3156
+ ...(ngDevMode ? [{ debugName: "tabButtons" }] : /* istanbul ignore next */ []));
3157
+ engine = injectEngine();
3158
+ /** Set only by a key press, so focus is never taken from elsewhere. */
3159
+ moveFocus = false;
3160
+ static counter = 0;
3161
+ // After the counter, not before: a static read from a field initialiser runs
3162
+ // in declaration order, and the other way round it is NaN on every instance.
3163
+ id = `formancy-tabs-${String((FormancyTabs.counter += 1))}`;
3164
+ constructor() {
3165
+ effect(() => {
3166
+ const index = this.open();
3167
+ if (!this.moveFocus)
3168
+ return;
3169
+ this.moveFocus = false;
3170
+ this.tabButtons()[index]?.nativeElement.focus();
3171
+ });
3172
+ }
3173
+ tabId(index) {
3174
+ return `${this.id}-tab-${String(index)}`;
3175
+ }
3176
+ panelId(index) {
3177
+ return `${this.id}-panel-${String(index)}`;
3178
+ }
3179
+ panelPath(index) {
3180
+ const prefix = this.at();
3181
+ return prefix === '' ? String(index) : `${prefix}.${String(index)}`;
3182
+ }
3183
+ childrenOf(node) {
3184
+ // `LAYOUT_LEAF_KINDS`, not `kind === 'field'`: a `qrcode` node is childless and is
3185
+ // not a field, so the old spelling read `children` off it and got `undefined`.
3186
+ return LAYOUT_LEAF_KINDS.has(node.kind) ? [node] : layoutChildren(node);
3187
+ }
3188
+ /** A tab's name is its section's heading. The validator insists it has one. */
3189
+ nameOf(node, index) {
3190
+ if (node.kind === 'field')
3191
+ return `Tab ${String(index + 1)}`;
3192
+ const schema = this.engine.schema();
3193
+ return (resolveText(schema, node.label, schema.i18n?.defaultLocale ?? '') ??
3194
+ `Tab ${String(index + 1)}`);
3195
+ }
3196
+ onKeyDown(event) {
3197
+ const last = this.panels().length - 1;
3198
+ const current = this.open();
3199
+ const next = event.key === 'ArrowRight'
3200
+ ? Math.min(current + 1, last)
3201
+ : event.key === 'ArrowLeft'
3202
+ ? Math.max(current - 1, 0)
3203
+ : event.key === 'Home'
3204
+ ? 0
3205
+ : event.key === 'End'
3206
+ ? last
3207
+ : undefined;
3208
+ if (next === undefined)
3209
+ return;
3210
+ event.preventDefault();
3211
+ this.moveFocus = true;
3212
+ this.open.set(next);
3213
+ }
3214
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: FormancyTabs, deps: [], target: i0.ɵɵFactoryTarget.Component });
3215
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.7", type: FormancyTabs, isStandalone: true, selector: "formancy-tabs", inputs: { at: { classPropertyName: "at", publicName: "at", isSignal: true, isRequired: false, transformFunction: null }, stripLabel: { classPropertyName: "stripLabel", publicName: "stripLabel", isSignal: true, isRequired: false, transformFunction: null }, panels: { classPropertyName: "panels", publicName: "panels", isSignal: true, isRequired: true, transformFunction: null }, labels: { classPropertyName: "labels", publicName: "labels", isSignal: true, isRequired: false, transformFunction: null } }, viewQueries: [{ propertyName: "tabButtons", predicate: ["tab"], descendants: true, isSignal: true }], ngImport: i0, template: `
3216
+ <div data-formancy-part="layout-tabs" [attr.data-formancy-layout-path]="at()">
3217
+ <div
3218
+ role="tablist"
3219
+ [attr.aria-label]="stripLabel()"
3220
+ data-formancy-part="tablist"
3221
+ (keydown)="onKeyDown($event)"
3222
+ >
3223
+ @for (panel of panels(); track $index; let i = $index) {
3224
+ <button
3225
+ #tab
3226
+ type="button"
3227
+ role="tab"
3228
+ [id]="tabId(i)"
3229
+ [attr.aria-controls]="panelId(i)"
3230
+ [attr.aria-selected]="i === open()"
3231
+ [attr.tabindex]="i === open() ? 0 : -1"
3232
+ data-formancy-part="tab"
3233
+ (click)="open.set(i)"
3234
+ >{{ nameOf(panel, i) }}</button>
3235
+ }
3236
+ </div>
3237
+
3238
+ @for (panel of panels(); track $index; let i = $index) {
3239
+ <div
3240
+ role="tabpanel"
3241
+ [id]="panelId(i)"
3242
+ [attr.aria-labelledby]="tabId(i)"
3243
+ data-formancy-part="tabpanel"
3244
+ [hidden]="i !== open()"
3245
+ (formancy-reveal)="reveal(i)"
3246
+ >
3247
+ <formancy-layout
3248
+ [nodes]="childrenOf(panel)"
3249
+ [labels]="labels()"
3250
+ [at]="panelPath(i)"
3251
+ />
3252
+ </div>
3253
+ }
3254
+ </div>
3255
+ `, isInline: true, dependencies: [{ kind: "component", type: i0.forwardRef(() => FormancyLayout), selector: "formancy-layout", inputs: ["nodes", "labels", "at"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
3256
+ }
3257
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: FormancyTabs, decorators: [{
3258
+ type: Component,
3259
+ args: [{
3260
+ selector: 'formancy-tabs',
3261
+ changeDetection: ChangeDetectionStrategy.OnPush,
3262
+ imports: [forwardRef(() => FormancyLayout)],
3263
+ template: `
3264
+ <div data-formancy-part="layout-tabs" [attr.data-formancy-layout-path]="at()">
3265
+ <div
3266
+ role="tablist"
3267
+ [attr.aria-label]="stripLabel()"
3268
+ data-formancy-part="tablist"
3269
+ (keydown)="onKeyDown($event)"
3270
+ >
3271
+ @for (panel of panels(); track $index; let i = $index) {
3272
+ <button
3273
+ #tab
3274
+ type="button"
3275
+ role="tab"
3276
+ [id]="tabId(i)"
3277
+ [attr.aria-controls]="panelId(i)"
3278
+ [attr.aria-selected]="i === open()"
3279
+ [attr.tabindex]="i === open() ? 0 : -1"
3280
+ data-formancy-part="tab"
3281
+ (click)="open.set(i)"
3282
+ >{{ nameOf(panel, i) }}</button>
3283
+ }
3284
+ </div>
3285
+
3286
+ @for (panel of panels(); track $index; let i = $index) {
3287
+ <div
3288
+ role="tabpanel"
3289
+ [id]="panelId(i)"
3290
+ [attr.aria-labelledby]="tabId(i)"
3291
+ data-formancy-part="tabpanel"
3292
+ [hidden]="i !== open()"
3293
+ (formancy-reveal)="reveal(i)"
3294
+ >
3295
+ <formancy-layout
3296
+ [nodes]="childrenOf(panel)"
3297
+ [labels]="labels()"
3298
+ [at]="panelPath(i)"
3299
+ />
3300
+ </div>
3301
+ }
3302
+ </div>
3303
+ `,
3304
+ }]
3305
+ }], ctorParameters: () => [], propDecorators: { at: [{ type: i0.Input, args: [{ isSignal: true, alias: "at", required: false }] }], stripLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "stripLabel", required: false }] }], panels: [{ type: i0.Input, args: [{ isSignal: true, alias: "panels", required: true }] }], labels: [{ type: i0.Input, args: [{ isSignal: true, alias: "labels", required: false }] }], tabButtons: [{ type: i0.ViewChildren, args: ['tab', { isSignal: true }] }] } });
3306
+ /** One per `formancy-code` on the page, so two codes never share a label id. */
3307
+ let codeInstances = 0;
3308
+ /**
3309
+ * A machine-readable code drawn from an answer the form already holds.
3310
+ *
3311
+ * A component with its OWN field binding rather than a method on the layout: reading the
3312
+ * snapshot from the layout rendered the value once and never again, because a plain method
3313
+ * call is not a signal an OnPush component re-runs for. Measured — the code stayed
3314
+ * `data-state="empty"` after the answer was typed.
3315
+ *
3316
+ * **The accessible content is the value, not the picture.** A picture of a code says
3317
+ * nothing to a screen reader and an alt of "QR code" says nothing either; what somebody
3318
+ * needs is the value, which they can read, copy or dictate. There is no picture at all:
3319
+ * encoding one is a dependency for something a design system may want to draw its own way,
3320
+ * so the renderer emits the value and the hooks and a consumer registers a component for
3321
+ * the drawing. Out of the box a code node shows the value as text and no code — a usable
3322
+ * form with a visible gap, which is the right way round.
3323
+ */
3324
+ class FormancyCode {
3325
+ path = input.required(/* @ts-ignore */
3326
+ ...(ngDevMode ? [{ debugName: "path" }] : /* istanbul ignore next */ []));
3327
+ label = input(/* @ts-ignore */
3328
+ ...(ngDevMode ? [undefined, { debugName: "label" }] : /* istanbul ignore next */ []));
3329
+ /**
3330
+ * The label's own id, so the value can point at it.
3331
+ *
3332
+ * From Angular's `inject(...)`-free counter rather than from the engine: the engine
3333
+ * mints ids for FIELDS, and a code is a layout node with no field of its own. Unique
3334
+ * per component instance, which is what two codes on one page need.
3335
+ */
3336
+ labelId = `formancy-code-${String((codeInstances += 1))}`;
3337
+ engine = injectEngine();
3338
+ destroyRef = inject(DestroyRef);
3339
+ /**
3340
+ * Subscribed, not computed.
3341
+ *
3342
+ * The first version was `computed(() => engine.getFieldSnapshot(...))`, which has no
3343
+ * reactive dependency at all -- `getFieldSnapshot` is a plain call, not a signal -- so
3344
+ * it ran once and never again. Measured twice now, in both renderers: the code stayed
3345
+ * `data-state="empty"` after the answer was typed. A code is a live view of an answer
3346
+ * and has to subscribe like any other reader of one.
3347
+ */
3348
+ value = signal(undefined, /* @ts-ignore */
3349
+ ...(ngDevMode ? [{ debugName: "value" }] : /* istanbul ignore next */ []));
3350
+ ngOnInit() {
3351
+ const parsed = parsePath(this.path());
3352
+ this.value.set(this.engine.getFieldSnapshot(parsed).value);
3353
+ const unsubscribe = this.engine.subscribeField(parsed, () => {
3354
+ this.value.set(this.engine.getFieldSnapshot(parsed).value);
3355
+ });
3356
+ this.destroyRef.onDestroy(unsubscribe);
3357
+ }
3358
+ text = computed(() => {
3359
+ const value = this.value();
3360
+ if (typeof value === 'string')
3361
+ return value;
3362
+ return value === null || value === undefined ? '' : String(value);
3363
+ }, /* @ts-ignore */
3364
+ ...(ngDevMode ? [{ debugName: "text" }] : /* istanbul ignore next */ []));
3365
+ /**
3366
+ * The modules of the code, or undefined when there is nothing to encode.
3367
+ *
3368
+ * One rect per dark module rather than one path: a rect carries its own fill, so a theme
3369
+ * can address them, and the count is bounded by the version (a version 1 code is 23×23).
3370
+ * `track module.key` so Angular reuses rects across a redraw rather than rebuilding the
3371
+ * whole picture on every keystroke.
3372
+ */
3373
+ drawing = computed(() => {
3374
+ const value = this.text();
3375
+ if (value === '')
3376
+ return undefined;
3377
+ const { size, data } = encode(value);
3378
+ const modules = [];
3379
+ for (const [row, cells] of data.entries()) {
3380
+ for (const [column, dark] of cells.entries()) {
3381
+ if (dark)
3382
+ modules.push({ key: `${String(row)}.${String(column)}`, x: column, y: row });
3383
+ }
3384
+ }
3385
+ return { size, modules };
3386
+ }, /* @ts-ignore */
3387
+ ...(ngDevMode ? [{ debugName: "drawing" }] : /* istanbul ignore next */ []));
3388
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: FormancyCode, deps: [], target: i0.ɵɵFactoryTarget.Component });
3389
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.7", type: FormancyCode, isStandalone: true, selector: "formancy-code", inputs: { path: { classPropertyName: "path", publicName: "path", isSignal: true, isRequired: true, transformFunction: null }, label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: `
3390
+ <div data-formancy-part="code" [attr.data-state]="text() === '' ? 'empty' : 'ready'">
3391
+ <!-- Named, and the name is ATTACHED. It was a loose span beside the value, and
3392
+ measured, the value's accessible name was the empty string: a screen reader
3393
+ announced a booking reference with nothing to say what it was. A label that
3394
+ only looks like a label is the failure this repository's describedby
3395
+ composition exists to prevent, in the one place a layout node wires its own.
3396
+ The React binding does the same. -->
3397
+ @if (label(); as caption) {
3398
+ <span [id]="labelId" data-formancy-part="code-label">{{ caption }}</span>
3399
+ }
3400
+ <!-- The drawing. Decorative: aria-hidden, because a picture of a code says nothing
3401
+ to a screen reader and an alt of "QR code" says nothing either. The value below
3402
+ is the content.
3403
+ Drawn from the encoder's matrix rather than with its own renderSVG, which emits
3404
+ white and black fills: a renderer shipping colours is the thing this project
3405
+ exists to avoid, so the modules use currentColor and the light ones are absent.
3406
+ Nothing is drawn for an empty answer -- an empty string encodes to a valid code,
3407
+ and a scannable picture of nothing is worse than no picture. -->
3408
+ @if (drawing(); as code) {
3409
+ <svg
3410
+ data-formancy-part="code-drawing"
3411
+ [attr.viewBox]="'0 0 ' + code.size + ' ' + code.size"
3412
+ aria-hidden="true"
3413
+ focusable="false"
3414
+ shape-rendering="crispEdges"
3415
+ >
3416
+ @for (module of code.modules; track module.key) {
3417
+ <rect [attr.x]="module.x" [attr.y]="module.y" width="1" height="1" fill="currentColor" />
3418
+ }
3419
+ </svg>
3420
+ }
3421
+ <!-- output is a live region, so its text is announced when the answer changes --
3422
+ right for a second view of an answer, and exactly why it must be named: an
3423
+ unnamed live region reads a string out of nowhere. -->
3424
+ <output data-formancy-part="code-value" [attr.aria-labelledby]="label() ? labelId : null">{{ text() }}</output>
3425
+ </div>
3426
+ `, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
3427
+ }
3428
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: FormancyCode, decorators: [{
3429
+ type: Component,
3430
+ args: [{
3431
+ selector: 'formancy-code',
3432
+ changeDetection: ChangeDetectionStrategy.OnPush,
3433
+ template: `
3434
+ <div data-formancy-part="code" [attr.data-state]="text() === '' ? 'empty' : 'ready'">
3435
+ <!-- Named, and the name is ATTACHED. It was a loose span beside the value, and
3436
+ measured, the value's accessible name was the empty string: a screen reader
3437
+ announced a booking reference with nothing to say what it was. A label that
3438
+ only looks like a label is the failure this repository's describedby
3439
+ composition exists to prevent, in the one place a layout node wires its own.
3440
+ The React binding does the same. -->
3441
+ @if (label(); as caption) {
3442
+ <span [id]="labelId" data-formancy-part="code-label">{{ caption }}</span>
3443
+ }
3444
+ <!-- The drawing. Decorative: aria-hidden, because a picture of a code says nothing
3445
+ to a screen reader and an alt of "QR code" says nothing either. The value below
3446
+ is the content.
3447
+ Drawn from the encoder's matrix rather than with its own renderSVG, which emits
3448
+ white and black fills: a renderer shipping colours is the thing this project
3449
+ exists to avoid, so the modules use currentColor and the light ones are absent.
3450
+ Nothing is drawn for an empty answer -- an empty string encodes to a valid code,
3451
+ and a scannable picture of nothing is worse than no picture. -->
3452
+ @if (drawing(); as code) {
3453
+ <svg
3454
+ data-formancy-part="code-drawing"
3455
+ [attr.viewBox]="'0 0 ' + code.size + ' ' + code.size"
3456
+ aria-hidden="true"
3457
+ focusable="false"
3458
+ shape-rendering="crispEdges"
3459
+ >
3460
+ @for (module of code.modules; track module.key) {
3461
+ <rect [attr.x]="module.x" [attr.y]="module.y" width="1" height="1" fill="currentColor" />
3462
+ }
3463
+ </svg>
3464
+ }
3465
+ <!-- output is a live region, so its text is announced when the answer changes --
3466
+ right for a second view of an answer, and exactly why it must be named: an
3467
+ unnamed live region reads a string out of nowhere. -->
3468
+ <output data-formancy-part="code-value" [attr.aria-labelledby]="label() ? labelId : null">{{ text() }}</output>
3469
+ </div>
3470
+ `,
3471
+ }]
3472
+ }], propDecorators: { path: [{ type: i0.Input, args: [{ isSignal: true, alias: "path", required: true }] }], label: [{ type: i0.Input, args: [{ isSignal: true, alias: "label", required: false }] }] } });
3473
+ class FormancyLayout {
3474
+ nodes = input.required(/* @ts-ignore */
3475
+ ...(ngDevMode ? [{ debugName: "nodes" }] : /* istanbul ignore next */ []));
3476
+ labels = input(undefined, /* @ts-ignore */
3477
+ ...(ngDevMode ? [{ debugName: "labels" }] : /* istanbul ignore next */ []));
3478
+ /** Index path of the container these nodes are the children of. */
3479
+ at = input('', /* @ts-ignore */
3480
+ ...(ngDevMode ? [{ debugName: "at" }] : /* istanbul ignore next */ []));
3481
+ engine = injectEngine();
3482
+ constructor() {
3483
+ // The host takes no part in layout. Through CSSOM rather than a stylesheet, for the
3484
+ // reason written above the template: a component style is a `<style>` element a
3485
+ // strict `style-src` blocks, and this has to hold with no CSP configuration at all.
3486
+ inject((ElementRef)).nativeElement.style.setProperty('display', 'contents');
3487
+ }
3488
+ /** The span as a number CSS can count with, and nothing at all for `all`.
3489
+ *
3490
+ * A style OBJECT rather than `[style.--fm-span]`: both set a custom property --
3491
+ * measured, both work -- and the object form lets this return nothing for `all`
3492
+ * without binding an empty string. */
3493
+ spanStyle(node) {
3494
+ return typeof node.span === 'number' ? { '--fm-span': String(node.span) } : {};
3495
+ }
3496
+ /** This node's index path, as the dotted string the attribute carries. */
3497
+ pathOf(index) {
3498
+ const prefix = this.at();
3499
+ return prefix === '' ? String(index) : `${prefix}.${String(index)}`;
3500
+ }
3501
+ /**
3502
+ * Headings are cached per node. Minting an id inside the template would give
3503
+ * a different one on every change-detection pass, leaving aria-labelledby
3504
+ * pointing at an element that no longer exists.
3505
+ */
3506
+ headings = new WeakMap();
3507
+ static counter = 0;
3508
+ /** A tabs node's own name, for the tab strip. Null when it has none. */
3509
+ stripLabelFor(node) {
3510
+ if (node.kind !== 'tabs')
3511
+ return null;
3512
+ const schema = this.engine.schema();
3513
+ return resolveText(schema, node.label, schema.i18n?.defaultLocale ?? '') ?? null;
3514
+ }
3515
+ isRepeater(path) {
3516
+ return this.engine.repeaterPaths().includes(path);
3517
+ }
3518
+ headingFor(node) {
3519
+ const cached = this.headings.get(node);
3520
+ if (cached !== undefined)
3521
+ return cached;
3522
+ const schema = this.engine.schema();
3523
+ const label = node.kind === 'field' ? undefined : node.label;
3524
+ const text = resolveText(schema, label, schema.i18n?.defaultLocale ?? '');
3525
+ const heading = text === undefined || text === ''
3526
+ ? null
3527
+ : { id: `formancy-section-${String((FormancyLayout.counter += 1))}`, text };
3528
+ this.headings.set(node, heading);
3529
+ return heading;
3530
+ }
3531
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: FormancyLayout, deps: [], target: i0.ɵɵFactoryTarget.Component });
3532
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.7", type: FormancyLayout, isStandalone: true, selector: "formancy-layout", inputs: { nodes: { classPropertyName: "nodes", publicName: "nodes", isSignal: true, isRequired: true, transformFunction: null }, labels: { classPropertyName: "labels", publicName: "labels", isSignal: true, isRequired: false, transformFunction: null }, at: { classPropertyName: "at", publicName: "at", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: `
3533
+ @for (node of nodes(); track $index; let i = $index) {
3534
+ <!-- A node that spans gets a cell to span WITH, and one that does not is left
3535
+ exactly as it was: a direct child of the container, so the markup of a form
3536
+ using no span is unchanged.
3537
+
3538
+ The body is an ng-template rather than the same @if chain written twice,
3539
+ because two copies of a nine-branch chain is two places for them to drift.
3540
+
3541
+ Two channels for one fact, and the reason is arithmetic: data-span is the
3542
+ authored value, which a selector can match, and --fm-span is the same number
3543
+ where CSS can COUNT with it, because 'grid-column: span attr(data-span)' is
3544
+ not a thing. 'all' needs no number -- it is 1 / -1 whatever the column count
3545
+ -- so it carries no property and the theme's fallback covers the rest.
3546
+
3547
+ The React binding does the same, for the same reasons. -->
3548
+ @if (node.span !== undefined) {
3549
+ <div
3550
+ data-formancy-part="layout-cell"
3551
+ [attr.data-span]="node.span"
3552
+ [style]="spanStyle(node)"
3553
+ >
3554
+ <ng-container *ngTemplateOutlet="nodeBody; context: { $implicit: node, i: i }" />
3555
+ </div>
3556
+ } @else {
3557
+ <ng-container *ngTemplateOutlet="nodeBody; context: { $implicit: node, i: i }" />
3558
+ }
3559
+ }
3560
+
3561
+ <ng-template #nodeBody let-node let-i="i">
3562
+ @if (node.kind === 'field') {
3563
+ @if (isRepeater(node.path)) {
3564
+ <formancy-repeater [wire]="node.path" [labels]="labels()" />
3565
+ } @else {
3566
+ <formancy-field [path]="node.path" />
3567
+ }
3568
+ } @else if (node.kind === 'row') {
3569
+ <!-- Presentation only: two fields being beside each other is not a
3570
+ relationship the author described, and announcing "group" around
3571
+ every pair would be noise. -->
3572
+ <div
3573
+ data-formancy-part="layout-row"
3574
+ [attr.data-formancy-layout-path]="pathOf(i)"
3575
+ [attr.data-columns]="node.children.length"
3576
+ >
3577
+ <formancy-layout [nodes]="node.children" [labels]="labels()" [at]="pathOf(i)" />
3578
+ </div>
3579
+ } @else if (node.kind === 'column') {
3580
+ <div data-formancy-part="layout-column" [attr.data-formancy-layout-path]="pathOf(i)">
3581
+ <formancy-layout [nodes]="node.children" [labels]="labels()" [at]="pathOf(i)" />
3582
+ </div>
3583
+ } @else if (node.kind === 'tabs') {
3584
+ <formancy-tabs
3585
+ [panels]="node.children"
3586
+ [labels]="labels()"
3587
+ [at]="pathOf(i)"
3588
+ [stripLabel]="stripLabelFor(node)"
3589
+ />
3590
+ } @else if (node.kind === 'qrcode') {
3591
+ <!-- The accessible content is the VALUE, not the picture. A picture of a code
3592
+ says nothing to a screen reader and an alt of "QR code" says nothing
3593
+ either; what somebody needs is the value, which they can read, copy or
3594
+ dictate. And there is no picture: encoding one is a dependency (a matrix,
3595
+ mask patterns, Reed-Solomon) for something a design system may want to draw
3596
+ its own way, so the renderer emits the value and the hooks and a consumer
3597
+ registers a component for the drawing. Out of the box a code node shows the
3598
+ value as text and no code -- a usable form with a visible gap, which is the
3599
+ right way round. -->
3600
+ <formancy-code
3601
+ [path]="node.path"
3602
+ [label]="headingFor(node)?.text"
3603
+ [attr.data-formancy-layout-path]="pathOf(i)"
3604
+ />
3605
+ } @else if (node.kind === 'table') {
3606
+ <!-- A grid, not a <table>. Laying fields out in columns is not
3607
+ tabular data, and marking it up as a table would announce rows and
3608
+ columns that mean nothing (WCAG 1.3.1). The column count is data
3609
+ so the stylesheet can collapse it with a media query. -->
3610
+ <div
3611
+ data-formancy-part="layout-table"
3612
+ [attr.data-formancy-layout-path]="pathOf(i)"
3613
+ [attr.data-columns]="node.columns"
3614
+ [attr.role]="headingFor(node) ? 'group' : null"
3615
+ [attr.aria-labelledby]="headingFor(node)?.id ?? null"
3616
+ >
3617
+ @if (headingFor(node); as heading) {
3618
+ <p [id]="heading.id" data-formancy-part="layout-section-heading">{{ heading.text }}</p>
3619
+ }
3620
+ <formancy-layout [nodes]="node.children" [labels]="labels()" [at]="pathOf(i)" />
3621
+ </div>
3622
+ } @else if (headingFor(node); as heading) {
3623
+ <div
3624
+ data-formancy-part="layout-section"
3625
+ [attr.data-formancy-layout-path]="pathOf(i)"
3626
+ role="group"
3627
+ [attr.aria-labelledby]="heading.id"
3628
+ >
3629
+ <p [id]="heading.id" data-formancy-part="layout-section-heading">{{ heading.text }}</p>
3630
+ <formancy-layout [nodes]="node.children" [labels]="labels()" [at]="pathOf(i)" />
3631
+ </div>
3632
+ } @else {
3633
+ <!-- A group with no accessible name is announced as "group" and tells
3634
+ nobody anything, so an unlabelled section stays a box. -->
3635
+ <div data-formancy-part="layout-section" [attr.data-formancy-layout-path]="pathOf(i)">
3636
+ <formancy-layout [nodes]="node.children" [labels]="labels()" [at]="pathOf(i)" />
3637
+ </div>
3638
+ }
3639
+ </ng-template>
3640
+ `, isInline: true, dependencies: [{ kind: "component", type: FormancyLayout, selector: "formancy-layout", inputs: ["nodes", "labels", "at"] }, { kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "component", type: FormancyFieldSlot, selector: "formancy-field", inputs: ["path", "fallbackLabel"] }, { kind: "component", type: FormancyRepeaterSection, selector: "formancy-repeater", inputs: ["wire", "labels"] }, { kind: "component", type: FormancyTabs, selector: "formancy-tabs", inputs: ["at", "stripLabel", "panels", "labels"] }, { kind: "component", type: FormancyCode, selector: "formancy-code", inputs: ["path", "label"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
3641
+ }
3642
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: FormancyLayout, decorators: [{
3643
+ type: Component,
3644
+ args: [{
3645
+ selector: 'formancy-layout',
3646
+ changeDetection: ChangeDetectionStrategy.OnPush,
3647
+ imports: [NgTemplateOutlet, FormancyFieldSlot, FormancyRepeaterSection, FormancyTabs, FormancyCode],
3648
+ /**
3649
+ * Undoing an element rather than styling one.
3650
+ *
3651
+ * Angular gives every component a host element. This one recurses, so a container's
3652
+ * children arrive wrapped in a `<formancy-layout>` that React does not emit — and a
3653
+ * wrapper that takes part in layout is the only grid item its parent has, which is why
3654
+ * a two-column table produced one column in this renderer
3655
+ * ([0073](../../../docs/decisions/0073-a-host-element-is-not-a-layout.md)).
3656
+ *
3657
+ * `display: contents` removes the box and keeps the children, so the consumer's grid
3658
+ * sees what it sees in React. It also removes the element from the accessibility
3659
+ * tree, which is right: it has no role and names nothing.
3660
+ *
3661
+ * Set in the CONSTRUCTOR through CSSOM, and **never as a component style**: Angular
3662
+ * emits one as a `<style>` element that `style-src 'self'` blocks without a nonce,
3663
+ * which would silently restore the bug above. CSP does not govern CSSOM
3664
+ * ([0079](../../../docs/decisions/0079-a-host-is-undone-without-a-stylesheet.md),
3665
+ * which supersedes 0073's mechanism). `layout.test.ts` asserts both halves: the
3666
+ * display is `contents`, and no `<style>` is what says so.
3667
+ *
3668
+ * It also restores [0008](../../../docs/decisions/0008-layered-packages.md)'s "nothing
3669
+ * below the component kit ships a CSS file" rather than amending it, which is the better
3670
+ * outcome for a rule about who owns appearance.
3671
+ */
3672
+ template: `
3673
+ @for (node of nodes(); track $index; let i = $index) {
3674
+ <!-- A node that spans gets a cell to span WITH, and one that does not is left
3675
+ exactly as it was: a direct child of the container, so the markup of a form
3676
+ using no span is unchanged.
3677
+
3678
+ The body is an ng-template rather than the same @if chain written twice,
3679
+ because two copies of a nine-branch chain is two places for them to drift.
3680
+
3681
+ Two channels for one fact, and the reason is arithmetic: data-span is the
3682
+ authored value, which a selector can match, and --fm-span is the same number
3683
+ where CSS can COUNT with it, because 'grid-column: span attr(data-span)' is
3684
+ not a thing. 'all' needs no number -- it is 1 / -1 whatever the column count
3685
+ -- so it carries no property and the theme's fallback covers the rest.
3686
+
3687
+ The React binding does the same, for the same reasons. -->
3688
+ @if (node.span !== undefined) {
3689
+ <div
3690
+ data-formancy-part="layout-cell"
3691
+ [attr.data-span]="node.span"
3692
+ [style]="spanStyle(node)"
3693
+ >
3694
+ <ng-container *ngTemplateOutlet="nodeBody; context: { $implicit: node, i: i }" />
3695
+ </div>
3696
+ } @else {
3697
+ <ng-container *ngTemplateOutlet="nodeBody; context: { $implicit: node, i: i }" />
3698
+ }
3699
+ }
3700
+
3701
+ <ng-template #nodeBody let-node let-i="i">
3702
+ @if (node.kind === 'field') {
3703
+ @if (isRepeater(node.path)) {
3704
+ <formancy-repeater [wire]="node.path" [labels]="labels()" />
3705
+ } @else {
3706
+ <formancy-field [path]="node.path" />
3707
+ }
3708
+ } @else if (node.kind === 'row') {
3709
+ <!-- Presentation only: two fields being beside each other is not a
3710
+ relationship the author described, and announcing "group" around
3711
+ every pair would be noise. -->
3712
+ <div
3713
+ data-formancy-part="layout-row"
3714
+ [attr.data-formancy-layout-path]="pathOf(i)"
3715
+ [attr.data-columns]="node.children.length"
3716
+ >
3717
+ <formancy-layout [nodes]="node.children" [labels]="labels()" [at]="pathOf(i)" />
3718
+ </div>
3719
+ } @else if (node.kind === 'column') {
3720
+ <div data-formancy-part="layout-column" [attr.data-formancy-layout-path]="pathOf(i)">
3721
+ <formancy-layout [nodes]="node.children" [labels]="labels()" [at]="pathOf(i)" />
3722
+ </div>
3723
+ } @else if (node.kind === 'tabs') {
3724
+ <formancy-tabs
3725
+ [panels]="node.children"
3726
+ [labels]="labels()"
3727
+ [at]="pathOf(i)"
3728
+ [stripLabel]="stripLabelFor(node)"
3729
+ />
3730
+ } @else if (node.kind === 'qrcode') {
3731
+ <!-- The accessible content is the VALUE, not the picture. A picture of a code
3732
+ says nothing to a screen reader and an alt of "QR code" says nothing
3733
+ either; what somebody needs is the value, which they can read, copy or
3734
+ dictate. And there is no picture: encoding one is a dependency (a matrix,
3735
+ mask patterns, Reed-Solomon) for something a design system may want to draw
3736
+ its own way, so the renderer emits the value and the hooks and a consumer
3737
+ registers a component for the drawing. Out of the box a code node shows the
3738
+ value as text and no code -- a usable form with a visible gap, which is the
3739
+ right way round. -->
3740
+ <formancy-code
3741
+ [path]="node.path"
3742
+ [label]="headingFor(node)?.text"
3743
+ [attr.data-formancy-layout-path]="pathOf(i)"
3744
+ />
3745
+ } @else if (node.kind === 'table') {
3746
+ <!-- A grid, not a <table>. Laying fields out in columns is not
3747
+ tabular data, and marking it up as a table would announce rows and
3748
+ columns that mean nothing (WCAG 1.3.1). The column count is data
3749
+ so the stylesheet can collapse it with a media query. -->
3750
+ <div
3751
+ data-formancy-part="layout-table"
3752
+ [attr.data-formancy-layout-path]="pathOf(i)"
3753
+ [attr.data-columns]="node.columns"
3754
+ [attr.role]="headingFor(node) ? 'group' : null"
3755
+ [attr.aria-labelledby]="headingFor(node)?.id ?? null"
3756
+ >
3757
+ @if (headingFor(node); as heading) {
3758
+ <p [id]="heading.id" data-formancy-part="layout-section-heading">{{ heading.text }}</p>
3759
+ }
3760
+ <formancy-layout [nodes]="node.children" [labels]="labels()" [at]="pathOf(i)" />
3761
+ </div>
3762
+ } @else if (headingFor(node); as heading) {
3763
+ <div
3764
+ data-formancy-part="layout-section"
3765
+ [attr.data-formancy-layout-path]="pathOf(i)"
3766
+ role="group"
3767
+ [attr.aria-labelledby]="heading.id"
3768
+ >
3769
+ <p [id]="heading.id" data-formancy-part="layout-section-heading">{{ heading.text }}</p>
3770
+ <formancy-layout [nodes]="node.children" [labels]="labels()" [at]="pathOf(i)" />
3771
+ </div>
3772
+ } @else {
3773
+ <!-- A group with no accessible name is announced as "group" and tells
3774
+ nobody anything, so an unlabelled section stays a box. -->
3775
+ <div data-formancy-part="layout-section" [attr.data-formancy-layout-path]="pathOf(i)">
3776
+ <formancy-layout [nodes]="node.children" [labels]="labels()" [at]="pathOf(i)" />
3777
+ </div>
3778
+ }
3779
+ </ng-template>
3780
+ `,
3781
+ }]
3782
+ }], ctorParameters: () => [], propDecorators: { nodes: [{ type: i0.Input, args: [{ isSignal: true, alias: "nodes", required: true }] }], labels: [{ type: i0.Input, args: [{ isSignal: true, alias: "labels", required: false }] }], at: [{ type: i0.Input, args: [{ isSignal: true, alias: "at", required: false }] }] } });
811
3783
  /**
812
3784
  * Renders the whole form from the engine: one slot per field, resolved through
813
3785
  * the registry. Slots subscribe individually, so a keystroke re-renders one
@@ -828,6 +3800,12 @@ class FormancyForm {
828
3800
  */
829
3801
  labels = input(/* @ts-ignore */
830
3802
  ...(ngDevMode ? [undefined, { debugName: "labels" }] : /* istanbul ignore next */ []));
3803
+ /**
3804
+ * Render a named entry from the schema's `layouts` instead of model order.
3805
+ * Unknown or absent, the form falls back to model order.
3806
+ */
3807
+ layout = input(/* @ts-ignore */
3808
+ ...(ngDevMode ? [undefined, { debugName: "layout" }] : /* istanbul ignore next */ []));
831
3809
  submitLabel = input(/* @ts-ignore */
832
3810
  ...(ngDevMode ? [undefined, { debugName: "submitLabel" }] : /* istanbul ignore next */ []));
833
3811
  submitted = output();
@@ -853,6 +3831,17 @@ class FormancyForm {
853
3831
  return this.repeaterWires.filter((wire) => this.engine.pageOf(parsePath(wire)) === wizard.page());
854
3832
  }, /* @ts-ignore */
855
3833
  ...(ngDevMode ? [{ debugName: "repeatersOnPage" }] : /* istanbul ignore next */ []));
3834
+ /**
3835
+ * The nodes of the named layout, or undefined to fall back to model order —
3836
+ * a mistyped layout name should not produce an empty form.
3837
+ */
3838
+ arrangement = computed(() => {
3839
+ const name = this.layout();
3840
+ if (name === undefined)
3841
+ return undefined;
3842
+ return this.engine.schema().layouts?.find((candidate) => candidate.name === name)?.nodes;
3843
+ }, /* @ts-ignore */
3844
+ ...(ngDevMode ? [{ debugName: "arrangement" }] : /* istanbul ignore next */ []));
856
3845
  fallbackFor(wire) {
857
3846
  return this.labels()?.[wire];
858
3847
  }
@@ -876,7 +3865,7 @@ class FormancyForm {
876
3865
  : { ok: false, errors: outcome.errors });
877
3866
  }
878
3867
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: FormancyForm, deps: [], target: i0.ɵɵFactoryTarget.Component });
879
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.7", type: FormancyForm, isStandalone: true, selector: "formancy-form", inputs: { labels: { classPropertyName: "labels", publicName: "labels", isSignal: true, isRequired: false, transformFunction: null }, submitLabel: { classPropertyName: "submitLabel", publicName: "submitLabel", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { submitted: "submitted" }, ngImport: i0, template: `
3868
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.7", type: FormancyForm, isStandalone: true, selector: "formancy-form", inputs: { labels: { classPropertyName: "labels", publicName: "labels", isSignal: true, isRequired: false, transformFunction: null }, layout: { classPropertyName: "layout", publicName: "layout", isSignal: true, isRequired: false, transformFunction: null }, submitLabel: { classPropertyName: "submitLabel", publicName: "submitLabel", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { submitted: "submitted" }, ngImport: i0, template: `
880
3869
  @if (wizard; as w) {
881
3870
  <nav data-formancy-part="stepper" aria-label="Progress">
882
3871
  <ol>
@@ -901,6 +3890,9 @@ class FormancyForm {
901
3890
  <button type="button" data-formancy-part="submit" (click)="onSubmit()">{{ submitLabel() ?? 'Submit' }}</button>
902
3891
  }
903
3892
  </div>
3893
+ } @else if (arrangement(); as nodes) {
3894
+ <formancy-layout [nodes]="nodes" [labels]="labels()" />
3895
+ <button type="button" data-formancy-part="submit" (click)="onSubmit()">{{ submitLabel() ?? 'Submit' }}</button>
904
3896
  } @else {
905
3897
  @for (wire of staticWires; track wire) {
906
3898
  <formancy-field [path]="wire" [fallbackLabel]="fallbackFor(wire)" />
@@ -910,14 +3902,14 @@ class FormancyForm {
910
3902
  }
911
3903
  <button type="button" data-formancy-part="submit" (click)="onSubmit()">{{ submitLabel() ?? 'Submit' }}</button>
912
3904
  }
913
- `, isInline: true, dependencies: [{ kind: "component", type: FormancyFieldSlot, selector: "formancy-field", inputs: ["path", "fallbackLabel"] }, { kind: "component", type: FormancyRepeaterSection, selector: "formancy-repeater", inputs: ["wire", "labels"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
3905
+ `, isInline: true, dependencies: [{ kind: "component", type: FormancyFieldSlot, selector: "formancy-field", inputs: ["path", "fallbackLabel"] }, { kind: "component", type: FormancyRepeaterSection, selector: "formancy-repeater", inputs: ["wire", "labels"] }, { kind: "component", type: FormancyLayout, selector: "formancy-layout", inputs: ["nodes", "labels", "at"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
914
3906
  }
915
3907
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: FormancyForm, decorators: [{
916
3908
  type: Component,
917
3909
  args: [{
918
3910
  selector: 'formancy-form',
919
3911
  changeDetection: ChangeDetectionStrategy.OnPush,
920
- imports: [FormancyFieldSlot, FormancyRepeaterSection],
3912
+ imports: [FormancyFieldSlot, FormancyRepeaterSection, FormancyLayout],
921
3913
  template: `
922
3914
  @if (wizard; as w) {
923
3915
  <nav data-formancy-part="stepper" aria-label="Progress">
@@ -943,6 +3935,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImpor
943
3935
  <button type="button" data-formancy-part="submit" (click)="onSubmit()">{{ submitLabel() ?? 'Submit' }}</button>
944
3936
  }
945
3937
  </div>
3938
+ } @else if (arrangement(); as nodes) {
3939
+ <formancy-layout [nodes]="nodes" [labels]="labels()" />
3940
+ <button type="button" data-formancy-part="submit" (click)="onSubmit()">{{ submitLabel() ?? 'Submit' }}</button>
946
3941
  } @else {
947
3942
  @for (wire of staticWires; track wire) {
948
3943
  <formancy-field [path]="wire" [fallbackLabel]="fallbackFor(wire)" />
@@ -954,7 +3949,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImpor
954
3949
  }
955
3950
  `,
956
3951
  }]
957
- }], propDecorators: { labels: [{ type: i0.Input, args: [{ isSignal: true, alias: "labels", required: false }] }], submitLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "submitLabel", required: false }] }], submitted: [{ type: i0.Output, args: ["submitted"] }] } });
3952
+ }], propDecorators: { labels: [{ type: i0.Input, args: [{ isSignal: true, alias: "labels", required: false }] }], layout: [{ type: i0.Input, args: [{ isSignal: true, alias: "layout", required: false }] }], submitLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "submitLabel", required: false }] }], submitted: [{ type: i0.Output, args: ["submitted"] }] } });
958
3953
 
959
3954
  /**
960
3955
  * The error summary a failed submit focuses.
@@ -1003,7 +3998,7 @@ class FormancyErrorSummary {
1003
3998
  focusControl(event, path) {
1004
3999
  // The hash alone scrolls but does not focus; do both.
1005
4000
  event.preventDefault();
1006
- document.getElementById(this.controlIdOf(path))?.focus();
4001
+ focusControl(document.getElementById(this.controlIdOf(path)));
1007
4002
  }
1008
4003
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: FormancyErrorSummary, deps: [], target: i0.ɵɵFactoryTarget.Component });
1009
4004
  static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.7", type: FormancyErrorSummary, isStandalone: true, selector: "formancy-error-summary", inputs: { labels: { classPropertyName: "labels", publicName: "labels", isSignal: true, isRequired: false, transformFunction: null } }, viewQueries: [{ propertyName: "region", first: true, predicate: ["region"], descendants: true, isSignal: true }], ngImport: i0, template: `
@@ -1039,9 +4034,109 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImpor
1039
4034
  }]
1040
4035
  }], ctorParameters: () => [], propDecorators: { labels: [{ type: i0.Input, args: [{ isSignal: true, alias: "labels", required: false }] }], region: [{ type: i0.ViewChild, args: ['region', { isSignal: true }] }] } });
1041
4036
 
4037
+ class FormancyResumeNotice {
4038
+ /** Omitted when the draft came back unchanged. */
4039
+ migration = input(undefined, /* @ts-ignore */
4040
+ ...(ngDevMode ? [{ debugName: "migration" }] : /* istanbul ignore next */ []));
4041
+ /** Question wording for a field key, since a key is not what the form asked. */
4042
+ labels = input({}, /* @ts-ignore */
4043
+ ...(ngDevMode ? [{ debugName: "labels" }] : /* istanbul ignore next */ []));
4044
+ setAside = computed(() => (this.migration()?.changes ?? [])
4045
+ .map((change) => change.path)
4046
+ .filter((path) => path !== undefined), /* @ts-ignore */
4047
+ ...(ngDevMode ? [{ debugName: "setAside" }] : /* istanbul ignore next */ []));
4048
+ summary = computed(() => {
4049
+ const count = this.setAside().length;
4050
+ return count === 1
4051
+ ? 'One question is no longer on this form. Your answer to it is still kept with the rest and will be sent with them — it is just not shown here any more.'
4052
+ : `${String(count)} questions are no longer on this form. Your answers to them are still kept with the rest and will be sent with them — they are just not shown here any more.`;
4053
+ }, /* @ts-ignore */
4054
+ ...(ngDevMode ? [{ debugName: "summary" }] : /* istanbul ignore next */ []));
4055
+ region = viewChild('region', /* @ts-ignore */
4056
+ ...(ngDevMode ? [{ debugName: "region" }] : /* istanbul ignore next */ []));
4057
+ focused = afterRenderEffect(() => {
4058
+ if (this.migration() === undefined)
4059
+ return;
4060
+ this.region()?.nativeElement.focus();
4061
+ });
4062
+ labelFor(path) {
4063
+ return this.labels()[path] ?? path;
4064
+ }
4065
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: FormancyResumeNotice, deps: [], target: i0.ɵɵFactoryTarget.Component });
4066
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.7", type: FormancyResumeNotice, isStandalone: true, selector: "formancy-resume-notice", inputs: { migration: { classPropertyName: "migration", publicName: "migration", isSignal: true, isRequired: false, transformFunction: null }, labels: { classPropertyName: "labels", publicName: "labels", isSignal: true, isRequired: false, transformFunction: null } }, viewQueries: [{ propertyName: "region", first: true, predicate: ["region"], descendants: true, isSignal: true }], ngImport: i0, template: `
4067
+ @if (migration() !== undefined) {
4068
+ <div
4069
+ role="region"
4070
+ aria-label="This form changed while you were away"
4071
+ data-formancy-part="resume-notice"
4072
+ [attr.data-state]="migration()!.severity"
4073
+ tabindex="-1"
4074
+ #region
4075
+ >
4076
+ <h2 data-formancy-part="resume-notice-heading">This form changed while you were away</h2>
4077
+
4078
+ @if (migration()!.severity === 'breaking') {
4079
+ <p>
4080
+ It changed too much for your answers to be moved across, so this is being shown as you
4081
+ left it and <strong>cannot be submitted</strong>. Starting again will give you the
4082
+ current form.
4083
+ </p>
4084
+ } @else {
4085
+ <p>{{ summary() }}</p>
4086
+ @if (setAside().length > 0) {
4087
+ <ul data-formancy-part="resume-notice-list">
4088
+ @for (path of setAside(); track path) {
4089
+ <li>{{ labelFor(path) }}</li>
4090
+ }
4091
+ </ul>
4092
+ }
4093
+ }
4094
+ </div>
4095
+ }
4096
+ `, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
4097
+ }
4098
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.7", ngImport: i0, type: FormancyResumeNotice, decorators: [{
4099
+ type: Component,
4100
+ args: [{
4101
+ selector: 'formancy-resume-notice',
4102
+ changeDetection: ChangeDetectionStrategy.OnPush,
4103
+ template: `
4104
+ @if (migration() !== undefined) {
4105
+ <div
4106
+ role="region"
4107
+ aria-label="This form changed while you were away"
4108
+ data-formancy-part="resume-notice"
4109
+ [attr.data-state]="migration()!.severity"
4110
+ tabindex="-1"
4111
+ #region
4112
+ >
4113
+ <h2 data-formancy-part="resume-notice-heading">This form changed while you were away</h2>
4114
+
4115
+ @if (migration()!.severity === 'breaking') {
4116
+ <p>
4117
+ It changed too much for your answers to be moved across, so this is being shown as you
4118
+ left it and <strong>cannot be submitted</strong>. Starting again will give you the
4119
+ current form.
4120
+ </p>
4121
+ } @else {
4122
+ <p>{{ summary() }}</p>
4123
+ @if (setAside().length > 0) {
4124
+ <ul data-formancy-part="resume-notice-list">
4125
+ @for (path of setAside(); track path) {
4126
+ <li>{{ labelFor(path) }}</li>
4127
+ }
4128
+ </ul>
4129
+ }
4130
+ }
4131
+ </div>
4132
+ }
4133
+ `,
4134
+ }]
4135
+ }], propDecorators: { migration: [{ type: i0.Input, args: [{ isSignal: true, alias: "migration", required: false }] }], labels: [{ type: i0.Input, args: [{ isSignal: true, alias: "labels", required: false }] }], region: [{ type: i0.ViewChild, args: ['region', { isSignal: true }] }] } });
4136
+
1042
4137
  /**
1043
4138
  * Generated bundle index. Do not edit.
1044
4139
  */
1045
4140
 
1046
- export { DEFAULT_FIELD_COMPONENTS, FORMANCY_ENGINE, FORMANCY_FIELD_CONTEXT, FORMANCY_REGISTRY, FormancyCheckboxField, FormancyComponentOutlet, FormancyDateField, FormancyErrorSummary, FormancyFieldShell, FormancyFieldSlot, FormancyForm, FormancyNumberField, FormancyRadioGroupField, FormancyRepeaterSection, FormancySelectField, FormancyTextField, FormancyTextareaField, injectEngine, injectField, injectFieldContext, injectRepeater, injectSubmit, injectWizard, provideFormancy, provideFormancyRegistry };
4141
+ export { DEFAULT_FIELD_COMPONENTS, FORMANCY_ENGINE, FORMANCY_FIELD_CONTEXT, FORMANCY_OPTIONS_SOURCES, FORMANCY_REGISTRY, FORMANCY_RICH_TEXT_EDITOR, FORMANCY_SCANNER, FORMANCY_UPLOADER, FormancyCheckboxField, FormancyComponentOutlet, FormancyDateField, FormancyErrorSummary, FormancyFieldShell, FormancyFieldSlot, FormancyForm, FormancyLayout, FormancyNumberField, FormancyRadioGroupField, FormancyRepeaterSection, FormancyResumeNotice, FormancyRichInline, FormancyRichText, FormancySelectField, FormancyTextField, FormancyTextareaField, injectEngine, injectField, injectFieldContext, injectOptionsSources, injectRepeater, injectRichTextEditorFactory, injectScanner, injectSubmit, injectUploader, injectWizard, provideFormancy, provideFormancyOptionsSources, provideFormancyRegistry, provideFormancyRichTextEditor, provideFormancyScanner, provideFormancyUploader };
1047
4142
  //# sourceMappingURL=formancy-angular.mjs.map