@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.
- package/fesm2022/formancy-angular.mjs +3199 -104
- package/fesm2022/formancy-angular.mjs.map +1 -1
- package/package.json +5 -4
- package/types/formancy-angular.d.ts +580 -61
|
@@ -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,
|
|
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
|
-
//
|
|
65
|
-
//
|
|
66
|
-
|
|
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)
|
|
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
|
|
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
|
|
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.
|
|
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: "
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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-
|
|
547
|
-
[attr.
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
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="
|
|
554
|
-
[id]="
|
|
2504
|
+
type="file"
|
|
2505
|
+
[id]="control().id"
|
|
555
2506
|
[attr.name]="control().name"
|
|
556
|
-
[
|
|
557
|
-
[
|
|
558
|
-
|
|
559
|
-
|
|
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
|
-
|
|
562
|
-
</span>
|
|
2513
|
+
</div>
|
|
563
2514
|
}
|
|
564
|
-
|
|
565
|
-
|
|
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
|
-
|
|
568
|
-
|
|
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:
|
|
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-
|
|
2564
|
+
selector: 'formancy-file-field',
|
|
574
2565
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
2566
|
+
imports: [FormancyFieldShell],
|
|
575
2567
|
template: `
|
|
576
|
-
<
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
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="
|
|
586
|
-
[id]="
|
|
2591
|
+
type="file"
|
|
2592
|
+
[id]="control().id"
|
|
587
2593
|
[attr.name]="control().name"
|
|
588
|
-
[
|
|
589
|
-
[
|
|
590
|
-
|
|
591
|
-
|
|
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
|
-
|
|
594
|
-
</span>
|
|
2600
|
+
</div>
|
|
595
2601
|
}
|
|
596
|
-
|
|
597
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
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
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
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))
|
|
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
|