@mosaicoo/form-angular 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -0,0 +1,1047 @@
|
|
|
1
|
+
import * as i0 from '@angular/core';
|
|
2
|
+
import { input, signal, effect, untracked, computed, ViewEncapsulation, ChangeDetectionStrategy, Component, output } from '@angular/core';
|
|
3
|
+
import { importForm, createLegacyImporter, createFormEngine, collectInputs } from '@mosaicoo/form-core';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Renders one schema node (input, container or static) and recurses into
|
|
7
|
+
* children. State lives in the headless engine — this component only projects
|
|
8
|
+
* it and forwards user interaction. `tick` changes whenever the engine emits,
|
|
9
|
+
* invalidating the OnPush view.
|
|
10
|
+
*/
|
|
11
|
+
class FormNodeComponent {
|
|
12
|
+
node = input.required(...(ngDevMode ? [{ debugName: "node" }] : /* istanbul ignore next */ []));
|
|
13
|
+
engine = input.required(...(ngDevMode ? [{ debugName: "engine" }] : /* istanbul ignore next */ []));
|
|
14
|
+
/** Monotonic counter bumped on every engine event — invalidates the view. */
|
|
15
|
+
tick = input.required(...(ngDevMode ? [{ debugName: "tick" }] : /* istanbul ignore next */ []));
|
|
16
|
+
/** Data-path prefix (set by array rows and nested groups above this node). */
|
|
17
|
+
scope = input('', ...(ngDevMode ? [{ debugName: "scope" }] : /* istanbul ignore next */ []));
|
|
18
|
+
/** Suppresses the panel chrome (used when a tab already framed the child). */
|
|
19
|
+
bare = input(false, ...(ngDevMode ? [{ debugName: "bare" }] : /* istanbul ignore next */ []));
|
|
20
|
+
/** Host-registered providers for `optionsSource: provider` fields. */
|
|
21
|
+
providers = input({}, ...(ngDevMode ? [{ debugName: "providers" }] : /* istanbul ignore next */ []));
|
|
22
|
+
activeTab = signal(0, ...(ngDevMode ? [{ debugName: "activeTab" }] : /* istanbul ignore next */ []));
|
|
23
|
+
/** Options resolved from a dynamic source (provider or remote URL). */
|
|
24
|
+
resolvedOptions = signal(null, ...(ngDevMode ? [{ debugName: "resolvedOptions" }] : /* istanbul ignore next */ []));
|
|
25
|
+
/** Snapshot of the observed refresh source at the last resolution. */
|
|
26
|
+
lastRefreshKey = null;
|
|
27
|
+
constructor() {
|
|
28
|
+
effect(() => {
|
|
29
|
+
const node = this.node();
|
|
30
|
+
const registry = this.providers();
|
|
31
|
+
if (node.kind !== 'input' || !node.optionsSource) {
|
|
32
|
+
this.resolvedOptions.set(null);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const source = node.optionsSource;
|
|
36
|
+
// With refreshOn, engine events re-run this effect; the refresh key
|
|
37
|
+
// then decides whether the observed data actually changed.
|
|
38
|
+
if (source.refreshOn)
|
|
39
|
+
this.tick();
|
|
40
|
+
const engine = untracked(() => this.engine());
|
|
41
|
+
const path = untracked(() => this.path());
|
|
42
|
+
const refreshKey = !source.refreshOn
|
|
43
|
+
? 'static'
|
|
44
|
+
: source.refreshOn === 'data'
|
|
45
|
+
? JSON.stringify(engine.getData())
|
|
46
|
+
: JSON.stringify(engine.getValue(source.refreshOn) ?? null);
|
|
47
|
+
if (refreshKey === this.lastRefreshKey)
|
|
48
|
+
return;
|
|
49
|
+
this.lastRefreshKey = refreshKey;
|
|
50
|
+
if (source.type === 'provider') {
|
|
51
|
+
const provider = registry[source.name];
|
|
52
|
+
if (!provider)
|
|
53
|
+
return; // no provider: static options/fallback stand
|
|
54
|
+
Promise.resolve(provider({ field: node, path, data: engine.getData(), args: source.args }))
|
|
55
|
+
.then((options) => this.resolvedOptions.set(options))
|
|
56
|
+
.catch(() => { }); // provider failure keeps the static fallback
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
fetch(source.url)
|
|
60
|
+
.then((response) => (response.ok ? response.json() : []))
|
|
61
|
+
.then((list) => this.resolvedOptions.set((Array.isArray(list) ? list : []).map((item) => ({
|
|
62
|
+
label: String(item[source.labelKey ?? 'label'] ?? item[source.valueKey ?? 'value'] ?? ''),
|
|
63
|
+
value: (item[source.valueKey ?? 'value'] ?? ''),
|
|
64
|
+
}))))
|
|
65
|
+
.catch(() => { });
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
/** Effective options: dynamically resolved first, static as fallback. */
|
|
70
|
+
options() {
|
|
71
|
+
return this.resolvedOptions() ?? this.field().options ?? [];
|
|
72
|
+
}
|
|
73
|
+
// -- typed views of the node ---------------------------------------------
|
|
74
|
+
field() {
|
|
75
|
+
return this.node();
|
|
76
|
+
}
|
|
77
|
+
container() {
|
|
78
|
+
return this.node();
|
|
79
|
+
}
|
|
80
|
+
content() {
|
|
81
|
+
return this.node().content ?? '';
|
|
82
|
+
}
|
|
83
|
+
buttonAction() {
|
|
84
|
+
return String(this.node().props?.['action'] ?? 'submit');
|
|
85
|
+
}
|
|
86
|
+
sourceType() {
|
|
87
|
+
return String(this.node().props?.['sourceType'] ?? '');
|
|
88
|
+
}
|
|
89
|
+
rowsProp() {
|
|
90
|
+
const rows = this.node().props?.['rows'];
|
|
91
|
+
return typeof rows === 'number' ? rows : null;
|
|
92
|
+
}
|
|
93
|
+
// -- paths and visibility -------------------------------------------------
|
|
94
|
+
/** Data path of this node (inputs) or of its scope (containers). */
|
|
95
|
+
path = computed(() => {
|
|
96
|
+
const scope = this.scope();
|
|
97
|
+
const node = this.node();
|
|
98
|
+
if (node.kind === 'input')
|
|
99
|
+
return scope ? `${scope}.${node.key}` : node.key;
|
|
100
|
+
if (node.kind === 'container' && node.dataScope !== 'inherit') {
|
|
101
|
+
return scope ? `${scope}.${node.key}` : node.key;
|
|
102
|
+
}
|
|
103
|
+
return scope;
|
|
104
|
+
}, ...(ngDevMode ? [{ debugName: "path" }] : /* istanbul ignore next */ []));
|
|
105
|
+
/** Scope handed to children (nested/array containers extend it). */
|
|
106
|
+
childScope = computed(() => {
|
|
107
|
+
const node = this.node();
|
|
108
|
+
if (node.kind === 'container' && node.dataScope === 'nested')
|
|
109
|
+
return this.path();
|
|
110
|
+
return this.scope();
|
|
111
|
+
}, ...(ngDevMode ? [{ debugName: "childScope" }] : /* istanbul ignore next */ []));
|
|
112
|
+
rowScope(index) {
|
|
113
|
+
return `${this.path()}.${index}`;
|
|
114
|
+
}
|
|
115
|
+
visible() {
|
|
116
|
+
this.tick();
|
|
117
|
+
const node = this.node();
|
|
118
|
+
const key = node.kind === 'input' ? this.path() : node.key;
|
|
119
|
+
return this.engine().isVisible(key);
|
|
120
|
+
}
|
|
121
|
+
containerLayout() {
|
|
122
|
+
const node = this.container();
|
|
123
|
+
if (node.dataScope === 'array')
|
|
124
|
+
return 'array';
|
|
125
|
+
if (node.type === 'tabs')
|
|
126
|
+
return 'tabs';
|
|
127
|
+
if (node.type === 'columns' && node.columns?.length)
|
|
128
|
+
return 'columns';
|
|
129
|
+
return 'block';
|
|
130
|
+
}
|
|
131
|
+
columns() {
|
|
132
|
+
const node = this.container();
|
|
133
|
+
return (node.columns ?? []).map((column) => column.children
|
|
134
|
+
.map((index) => node.children[index])
|
|
135
|
+
.filter((child) => child !== undefined));
|
|
136
|
+
}
|
|
137
|
+
columnsTemplate() {
|
|
138
|
+
const cols = this.container().columns ?? [];
|
|
139
|
+
const total = cols.reduce((sum, c) => sum + c.width, 0) || 12;
|
|
140
|
+
return cols.map((c) => `${((c.width / total) * 100).toFixed(3)}fr`).join(' ');
|
|
141
|
+
}
|
|
142
|
+
// -- values ---------------------------------------------------------------
|
|
143
|
+
value() {
|
|
144
|
+
this.tick();
|
|
145
|
+
return this.engine().getValue(this.path());
|
|
146
|
+
}
|
|
147
|
+
stringValue() {
|
|
148
|
+
const value = this.value();
|
|
149
|
+
return value === undefined || value === null ? '' : String(value);
|
|
150
|
+
}
|
|
151
|
+
mapValue() {
|
|
152
|
+
const value = this.value();
|
|
153
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
154
|
+
? value
|
|
155
|
+
: {};
|
|
156
|
+
}
|
|
157
|
+
rows() {
|
|
158
|
+
const value = this.value();
|
|
159
|
+
return Array.isArray(value) ? value : [];
|
|
160
|
+
}
|
|
161
|
+
required() {
|
|
162
|
+
return this.field().validators?.some((v) => v.type === 'required') ?? false;
|
|
163
|
+
}
|
|
164
|
+
errors() {
|
|
165
|
+
this.tick();
|
|
166
|
+
return this.engine().getFieldErrors(this.path());
|
|
167
|
+
}
|
|
168
|
+
controlId() {
|
|
169
|
+
return `mform-${this.path().replace(/\./g, '-')}`;
|
|
170
|
+
}
|
|
171
|
+
describedBy() {
|
|
172
|
+
const parts = [];
|
|
173
|
+
if (this.field().description)
|
|
174
|
+
parts.push(`${this.controlId()}-desc`);
|
|
175
|
+
if (this.errors().length > 0)
|
|
176
|
+
parts.push(`${this.controlId()}-err`);
|
|
177
|
+
return parts.length ? parts.join(' ') : null;
|
|
178
|
+
}
|
|
179
|
+
inputType() {
|
|
180
|
+
switch (this.field().type) {
|
|
181
|
+
case 'number':
|
|
182
|
+
case 'currency':
|
|
183
|
+
return 'number';
|
|
184
|
+
case 'email':
|
|
185
|
+
return 'email';
|
|
186
|
+
case 'password':
|
|
187
|
+
return 'password';
|
|
188
|
+
case 'phone':
|
|
189
|
+
return 'tel';
|
|
190
|
+
case 'url':
|
|
191
|
+
return 'url';
|
|
192
|
+
case 'time':
|
|
193
|
+
return 'time';
|
|
194
|
+
case 'datetime':
|
|
195
|
+
return 'datetime-local';
|
|
196
|
+
case 'day':
|
|
197
|
+
return 'date';
|
|
198
|
+
default:
|
|
199
|
+
return 'text';
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
// -- interaction ----------------------------------------------------------
|
|
203
|
+
setValue(value) {
|
|
204
|
+
this.engine().setValue(this.path(), value);
|
|
205
|
+
}
|
|
206
|
+
setString(event) {
|
|
207
|
+
const raw = event.target.value;
|
|
208
|
+
if (this.field().dataType === 'number') {
|
|
209
|
+
const parsed = raw === '' ? undefined : Number(raw);
|
|
210
|
+
this.setValue(parsed !== undefined && Number.isFinite(parsed) ? parsed : undefined);
|
|
211
|
+
}
|
|
212
|
+
else {
|
|
213
|
+
this.setValue(raw);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
setSelect(event) {
|
|
217
|
+
this.setValue(event.target.value);
|
|
218
|
+
}
|
|
219
|
+
setChecked(event) {
|
|
220
|
+
this.setValue(event.target.checked);
|
|
221
|
+
}
|
|
222
|
+
toggleMap(option, event) {
|
|
223
|
+
const checked = event.target.checked;
|
|
224
|
+
this.setValue({ ...this.mapValue(), [option]: checked });
|
|
225
|
+
}
|
|
226
|
+
setFiles(event) {
|
|
227
|
+
const files = event.target.files;
|
|
228
|
+
this.setValue(files ? Array.from(files).map((f) => f.name) : []);
|
|
229
|
+
}
|
|
230
|
+
touch() {
|
|
231
|
+
this.engine().markTouched(this.path());
|
|
232
|
+
this.engine().validateField(this.path());
|
|
233
|
+
}
|
|
234
|
+
addRow() {
|
|
235
|
+
this.engine().addRow(this.path());
|
|
236
|
+
}
|
|
237
|
+
removeRow(index) {
|
|
238
|
+
this.engine().removeRow(this.path(), index);
|
|
239
|
+
}
|
|
240
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.19", ngImport: i0, type: FormNodeComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
241
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.19", type: FormNodeComponent, isStandalone: true, selector: "mform-node", inputs: { node: { classPropertyName: "node", publicName: "node", isSignal: true, isRequired: true, transformFunction: null }, engine: { classPropertyName: "engine", publicName: "engine", isSignal: true, isRequired: true, transformFunction: null }, tick: { classPropertyName: "tick", publicName: "tick", isSignal: true, isRequired: true, transformFunction: null }, scope: { classPropertyName: "scope", publicName: "scope", isSignal: true, isRequired: false, transformFunction: null }, bare: { classPropertyName: "bare", publicName: "bare", isSignal: true, isRequired: false, transformFunction: null }, providers: { classPropertyName: "providers", publicName: "providers", isSignal: true, isRequired: false, transformFunction: null } }, host: { classAttribute: "mform-node" }, ngImport: i0, template: `
|
|
242
|
+
@let n = node();
|
|
243
|
+
@if (visible()) {
|
|
244
|
+
@switch (n.kind) {
|
|
245
|
+
@case ('static') {
|
|
246
|
+
@if (n.type === 'button') {
|
|
247
|
+
<button
|
|
248
|
+
class="mform-btn"
|
|
249
|
+
[type]="buttonAction() === 'submit' ? 'submit' : 'button'"
|
|
250
|
+
>
|
|
251
|
+
{{ n.label || 'Submit' }}
|
|
252
|
+
</button>
|
|
253
|
+
} @else if (n.type === 'content') {
|
|
254
|
+
<div class="mform-content" [innerHTML]="content()"></div>
|
|
255
|
+
} @else {
|
|
256
|
+
<div class="mform-unsupported" role="note">
|
|
257
|
+
{{ n.label || n.key }} — unsupported content
|
|
258
|
+
</div>
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
@case ('container') {
|
|
262
|
+
@switch (containerLayout()) {
|
|
263
|
+
@case ('columns') {
|
|
264
|
+
<div class="mform-columns" [style.grid-template-columns]="columnsTemplate()">
|
|
265
|
+
@for (column of columns(); track $index) {
|
|
266
|
+
<div class="mform-column">
|
|
267
|
+
@for (child of column; track child.key) {
|
|
268
|
+
<mform-node [node]="child" [engine]="engine()" [tick]="tick()" [providers]="providers()" [scope]="childScope()" />
|
|
269
|
+
}
|
|
270
|
+
</div>
|
|
271
|
+
}
|
|
272
|
+
</div>
|
|
273
|
+
}
|
|
274
|
+
@case ('tabs') {
|
|
275
|
+
<div class="mform-tabs">
|
|
276
|
+
<div class="mform-tabbar" role="tablist">
|
|
277
|
+
@for (tab of container().children; track tab.key; let i = $index) {
|
|
278
|
+
<button
|
|
279
|
+
type="button"
|
|
280
|
+
class="mform-tab"
|
|
281
|
+
role="tab"
|
|
282
|
+
[class.is-active]="activeTab() === i"
|
|
283
|
+
[attr.aria-selected]="activeTab() === i"
|
|
284
|
+
(click)="activeTab.set(i)"
|
|
285
|
+
>
|
|
286
|
+
{{ tab.label || 'Tab ' + (i + 1) }}
|
|
287
|
+
</button>
|
|
288
|
+
}
|
|
289
|
+
</div>
|
|
290
|
+
@for (tab of container().children; track tab.key; let i = $index) {
|
|
291
|
+
@if (activeTab() === i) {
|
|
292
|
+
<div class="mform-tabpanel" role="tabpanel">
|
|
293
|
+
<mform-node [node]="tab" [engine]="engine()" [tick]="tick()" [providers]="providers()" [scope]="childScope()" [bare]="true" />
|
|
294
|
+
</div>
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
</div>
|
|
298
|
+
}
|
|
299
|
+
@case ('array') {
|
|
300
|
+
<section class="mform-panel mform-grid">
|
|
301
|
+
@if (container().label) {
|
|
302
|
+
<header class="mform-panel-head">{{ container().label }}</header>
|
|
303
|
+
}
|
|
304
|
+
<div class="mform-panel-body">
|
|
305
|
+
@for (row of rows(); track $index; let i = $index) {
|
|
306
|
+
<div class="mform-grid-row" role="group" [attr.aria-label]="(container().label || container().key) + ' ' + (i + 1)">
|
|
307
|
+
<div class="mform-grid-fields">
|
|
308
|
+
@for (child of container().children; track child.key) {
|
|
309
|
+
<mform-node [node]="child" [engine]="engine()" [tick]="tick()" [providers]="providers()" [scope]="rowScope(i)" />
|
|
310
|
+
}
|
|
311
|
+
</div>
|
|
312
|
+
<button
|
|
313
|
+
type="button"
|
|
314
|
+
class="mform-row-remove"
|
|
315
|
+
(click)="removeRow(i)"
|
|
316
|
+
[attr.aria-label]="'Remove row ' + (i + 1)"
|
|
317
|
+
>
|
|
318
|
+
✕
|
|
319
|
+
</button>
|
|
320
|
+
</div>
|
|
321
|
+
}
|
|
322
|
+
<button type="button" class="mform-row-add" (click)="addRow()">+ Add</button>
|
|
323
|
+
</div>
|
|
324
|
+
</section>
|
|
325
|
+
}
|
|
326
|
+
@default {
|
|
327
|
+
@if (container().label && !bare()) {
|
|
328
|
+
<section class="mform-panel">
|
|
329
|
+
<header class="mform-panel-head">{{ container().label }}</header>
|
|
330
|
+
<div class="mform-panel-body">
|
|
331
|
+
@for (child of container().children; track child.key) {
|
|
332
|
+
<mform-node [node]="child" [engine]="engine()" [tick]="tick()" [providers]="providers()" [scope]="childScope()" />
|
|
333
|
+
}
|
|
334
|
+
</div>
|
|
335
|
+
</section>
|
|
336
|
+
} @else {
|
|
337
|
+
<div class="mform-group">
|
|
338
|
+
@for (child of container().children; track child.key) {
|
|
339
|
+
<mform-node [node]="child" [engine]="engine()" [tick]="tick()" [providers]="providers()" [scope]="childScope()" />
|
|
340
|
+
}
|
|
341
|
+
</div>
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
@case ('input') {
|
|
347
|
+
@if (field().type !== 'hidden') {
|
|
348
|
+
<div class="mform-field" [class.has-error]="errors().length > 0">
|
|
349
|
+
@if (field().type !== 'checkbox') {
|
|
350
|
+
<label class="mform-label" [attr.for]="controlId()">
|
|
351
|
+
{{ field().label || field().key }}
|
|
352
|
+
@if (required()) {
|
|
353
|
+
<span class="mform-req" aria-hidden="true">*</span>
|
|
354
|
+
}
|
|
355
|
+
</label>
|
|
356
|
+
}
|
|
357
|
+
@switch (field().type) {
|
|
358
|
+
@case ('textarea') {
|
|
359
|
+
<textarea
|
|
360
|
+
class="mform-control"
|
|
361
|
+
[id]="controlId()"
|
|
362
|
+
[value]="stringValue()"
|
|
363
|
+
[placeholder]="field().placeholder || ''"
|
|
364
|
+
[disabled]="field().disabled === true"
|
|
365
|
+
[attr.rows]="rowsProp()"
|
|
366
|
+
[attr.aria-required]="required() || null"
|
|
367
|
+
[attr.aria-invalid]="errors().length > 0 || null"
|
|
368
|
+
[attr.aria-describedby]="describedBy()"
|
|
369
|
+
(input)="setString($event)"
|
|
370
|
+
(blur)="touch()"
|
|
371
|
+
></textarea>
|
|
372
|
+
}
|
|
373
|
+
@case ('select') {
|
|
374
|
+
<select
|
|
375
|
+
class="mform-control"
|
|
376
|
+
[id]="controlId()"
|
|
377
|
+
[disabled]="field().disabled === true"
|
|
378
|
+
[attr.aria-required]="required() || null"
|
|
379
|
+
[attr.aria-invalid]="errors().length > 0 || null"
|
|
380
|
+
[attr.aria-describedby]="describedBy()"
|
|
381
|
+
(change)="setSelect($event)"
|
|
382
|
+
(blur)="touch()"
|
|
383
|
+
>
|
|
384
|
+
<option value="" [selected]="stringValue() === ''"></option>
|
|
385
|
+
@for (option of options(); track option.value) {
|
|
386
|
+
<option
|
|
387
|
+
[value]="option.value"
|
|
388
|
+
[selected]="stringValue() === '' + option.value"
|
|
389
|
+
>
|
|
390
|
+
{{ option.label }}
|
|
391
|
+
</option>
|
|
392
|
+
}
|
|
393
|
+
</select>
|
|
394
|
+
}
|
|
395
|
+
@case ('radio') {
|
|
396
|
+
<div class="mform-choices" role="radiogroup" [attr.aria-label]="field().label || field().key">
|
|
397
|
+
@for (option of options(); track option.value) {
|
|
398
|
+
<label class="mform-check">
|
|
399
|
+
<input
|
|
400
|
+
type="radio"
|
|
401
|
+
[name]="controlId()"
|
|
402
|
+
[checked]="stringValue() === '' + option.value"
|
|
403
|
+
[disabled]="field().disabled === true"
|
|
404
|
+
(change)="setValue(option.value)"
|
|
405
|
+
(blur)="touch()"
|
|
406
|
+
/>
|
|
407
|
+
<span>{{ option.label }}</span>
|
|
408
|
+
</label>
|
|
409
|
+
}
|
|
410
|
+
</div>
|
|
411
|
+
}
|
|
412
|
+
@case ('checkbox') {
|
|
413
|
+
<label class="mform-check">
|
|
414
|
+
<input
|
|
415
|
+
type="checkbox"
|
|
416
|
+
[id]="controlId()"
|
|
417
|
+
[checked]="value() === true"
|
|
418
|
+
[disabled]="field().disabled === true"
|
|
419
|
+
[attr.aria-invalid]="errors().length > 0 || null"
|
|
420
|
+
[attr.aria-describedby]="describedBy()"
|
|
421
|
+
(change)="setChecked($event)"
|
|
422
|
+
(blur)="touch()"
|
|
423
|
+
/>
|
|
424
|
+
<span>
|
|
425
|
+
{{ field().label || field().key }}
|
|
426
|
+
@if (required()) {
|
|
427
|
+
<span class="mform-req" aria-hidden="true">*</span>
|
|
428
|
+
}
|
|
429
|
+
</span>
|
|
430
|
+
</label>
|
|
431
|
+
}
|
|
432
|
+
@case ('checkbox-group') {
|
|
433
|
+
<div class="mform-choices" role="group" [attr.aria-label]="field().label || field().key">
|
|
434
|
+
@for (option of options(); track option.value) {
|
|
435
|
+
<label class="mform-check">
|
|
436
|
+
<input
|
|
437
|
+
type="checkbox"
|
|
438
|
+
[checked]="mapValue()['' + option.value] === true"
|
|
439
|
+
[disabled]="field().disabled === true"
|
|
440
|
+
(change)="toggleMap('' + option.value, $event)"
|
|
441
|
+
(blur)="touch()"
|
|
442
|
+
/>
|
|
443
|
+
<span>{{ option.label }}</span>
|
|
444
|
+
</label>
|
|
445
|
+
}
|
|
446
|
+
</div>
|
|
447
|
+
}
|
|
448
|
+
@case ('file') {
|
|
449
|
+
<input
|
|
450
|
+
class="mform-control"
|
|
451
|
+
type="file"
|
|
452
|
+
multiple
|
|
453
|
+
[id]="controlId()"
|
|
454
|
+
[disabled]="field().disabled === true"
|
|
455
|
+
[attr.aria-describedby]="describedBy()"
|
|
456
|
+
(change)="setFiles($event)"
|
|
457
|
+
(blur)="touch()"
|
|
458
|
+
/>
|
|
459
|
+
}
|
|
460
|
+
@case ('unsupported') {
|
|
461
|
+
<input
|
|
462
|
+
class="mform-control"
|
|
463
|
+
type="text"
|
|
464
|
+
disabled
|
|
465
|
+
[id]="controlId()"
|
|
466
|
+
[value]="'Unsupported field (' + (sourceType() || 'unknown') + ')'"
|
|
467
|
+
/>
|
|
468
|
+
}
|
|
469
|
+
@default {
|
|
470
|
+
<input
|
|
471
|
+
class="mform-control"
|
|
472
|
+
[type]="inputType()"
|
|
473
|
+
[id]="controlId()"
|
|
474
|
+
[value]="stringValue()"
|
|
475
|
+
[placeholder]="field().placeholder || ''"
|
|
476
|
+
[disabled]="field().disabled === true"
|
|
477
|
+
[attr.aria-required]="required() || null"
|
|
478
|
+
[attr.aria-invalid]="errors().length > 0 || null"
|
|
479
|
+
[attr.aria-describedby]="describedBy()"
|
|
480
|
+
(input)="setString($event)"
|
|
481
|
+
(blur)="touch()"
|
|
482
|
+
/>
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
@if (field().description) {
|
|
486
|
+
<div class="mform-desc" [id]="controlId() + '-desc'">{{ field().description }}</div>
|
|
487
|
+
}
|
|
488
|
+
@for (error of errors(); track error.rule) {
|
|
489
|
+
<div class="mform-error" [id]="controlId() + '-err'" role="alert">{{ error.message }}</div>
|
|
490
|
+
}
|
|
491
|
+
</div>
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
`, isInline: true, dependencies: [{ kind: "component", type: FormNodeComponent, selector: "mform-node", inputs: ["node", "engine", "tick", "scope", "bare", "providers"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush, encapsulation: i0.ViewEncapsulation.None });
|
|
497
|
+
}
|
|
498
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.19", ngImport: i0, type: FormNodeComponent, decorators: [{
|
|
499
|
+
type: Component,
|
|
500
|
+
args: [{
|
|
501
|
+
selector: 'mform-node',
|
|
502
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
503
|
+
encapsulation: ViewEncapsulation.None,
|
|
504
|
+
host: { class: 'mform-node' },
|
|
505
|
+
template: `
|
|
506
|
+
@let n = node();
|
|
507
|
+
@if (visible()) {
|
|
508
|
+
@switch (n.kind) {
|
|
509
|
+
@case ('static') {
|
|
510
|
+
@if (n.type === 'button') {
|
|
511
|
+
<button
|
|
512
|
+
class="mform-btn"
|
|
513
|
+
[type]="buttonAction() === 'submit' ? 'submit' : 'button'"
|
|
514
|
+
>
|
|
515
|
+
{{ n.label || 'Submit' }}
|
|
516
|
+
</button>
|
|
517
|
+
} @else if (n.type === 'content') {
|
|
518
|
+
<div class="mform-content" [innerHTML]="content()"></div>
|
|
519
|
+
} @else {
|
|
520
|
+
<div class="mform-unsupported" role="note">
|
|
521
|
+
{{ n.label || n.key }} — unsupported content
|
|
522
|
+
</div>
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
@case ('container') {
|
|
526
|
+
@switch (containerLayout()) {
|
|
527
|
+
@case ('columns') {
|
|
528
|
+
<div class="mform-columns" [style.grid-template-columns]="columnsTemplate()">
|
|
529
|
+
@for (column of columns(); track $index) {
|
|
530
|
+
<div class="mform-column">
|
|
531
|
+
@for (child of column; track child.key) {
|
|
532
|
+
<mform-node [node]="child" [engine]="engine()" [tick]="tick()" [providers]="providers()" [scope]="childScope()" />
|
|
533
|
+
}
|
|
534
|
+
</div>
|
|
535
|
+
}
|
|
536
|
+
</div>
|
|
537
|
+
}
|
|
538
|
+
@case ('tabs') {
|
|
539
|
+
<div class="mform-tabs">
|
|
540
|
+
<div class="mform-tabbar" role="tablist">
|
|
541
|
+
@for (tab of container().children; track tab.key; let i = $index) {
|
|
542
|
+
<button
|
|
543
|
+
type="button"
|
|
544
|
+
class="mform-tab"
|
|
545
|
+
role="tab"
|
|
546
|
+
[class.is-active]="activeTab() === i"
|
|
547
|
+
[attr.aria-selected]="activeTab() === i"
|
|
548
|
+
(click)="activeTab.set(i)"
|
|
549
|
+
>
|
|
550
|
+
{{ tab.label || 'Tab ' + (i + 1) }}
|
|
551
|
+
</button>
|
|
552
|
+
}
|
|
553
|
+
</div>
|
|
554
|
+
@for (tab of container().children; track tab.key; let i = $index) {
|
|
555
|
+
@if (activeTab() === i) {
|
|
556
|
+
<div class="mform-tabpanel" role="tabpanel">
|
|
557
|
+
<mform-node [node]="tab" [engine]="engine()" [tick]="tick()" [providers]="providers()" [scope]="childScope()" [bare]="true" />
|
|
558
|
+
</div>
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
</div>
|
|
562
|
+
}
|
|
563
|
+
@case ('array') {
|
|
564
|
+
<section class="mform-panel mform-grid">
|
|
565
|
+
@if (container().label) {
|
|
566
|
+
<header class="mform-panel-head">{{ container().label }}</header>
|
|
567
|
+
}
|
|
568
|
+
<div class="mform-panel-body">
|
|
569
|
+
@for (row of rows(); track $index; let i = $index) {
|
|
570
|
+
<div class="mform-grid-row" role="group" [attr.aria-label]="(container().label || container().key) + ' ' + (i + 1)">
|
|
571
|
+
<div class="mform-grid-fields">
|
|
572
|
+
@for (child of container().children; track child.key) {
|
|
573
|
+
<mform-node [node]="child" [engine]="engine()" [tick]="tick()" [providers]="providers()" [scope]="rowScope(i)" />
|
|
574
|
+
}
|
|
575
|
+
</div>
|
|
576
|
+
<button
|
|
577
|
+
type="button"
|
|
578
|
+
class="mform-row-remove"
|
|
579
|
+
(click)="removeRow(i)"
|
|
580
|
+
[attr.aria-label]="'Remove row ' + (i + 1)"
|
|
581
|
+
>
|
|
582
|
+
✕
|
|
583
|
+
</button>
|
|
584
|
+
</div>
|
|
585
|
+
}
|
|
586
|
+
<button type="button" class="mform-row-add" (click)="addRow()">+ Add</button>
|
|
587
|
+
</div>
|
|
588
|
+
</section>
|
|
589
|
+
}
|
|
590
|
+
@default {
|
|
591
|
+
@if (container().label && !bare()) {
|
|
592
|
+
<section class="mform-panel">
|
|
593
|
+
<header class="mform-panel-head">{{ container().label }}</header>
|
|
594
|
+
<div class="mform-panel-body">
|
|
595
|
+
@for (child of container().children; track child.key) {
|
|
596
|
+
<mform-node [node]="child" [engine]="engine()" [tick]="tick()" [providers]="providers()" [scope]="childScope()" />
|
|
597
|
+
}
|
|
598
|
+
</div>
|
|
599
|
+
</section>
|
|
600
|
+
} @else {
|
|
601
|
+
<div class="mform-group">
|
|
602
|
+
@for (child of container().children; track child.key) {
|
|
603
|
+
<mform-node [node]="child" [engine]="engine()" [tick]="tick()" [providers]="providers()" [scope]="childScope()" />
|
|
604
|
+
}
|
|
605
|
+
</div>
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
@case ('input') {
|
|
611
|
+
@if (field().type !== 'hidden') {
|
|
612
|
+
<div class="mform-field" [class.has-error]="errors().length > 0">
|
|
613
|
+
@if (field().type !== 'checkbox') {
|
|
614
|
+
<label class="mform-label" [attr.for]="controlId()">
|
|
615
|
+
{{ field().label || field().key }}
|
|
616
|
+
@if (required()) {
|
|
617
|
+
<span class="mform-req" aria-hidden="true">*</span>
|
|
618
|
+
}
|
|
619
|
+
</label>
|
|
620
|
+
}
|
|
621
|
+
@switch (field().type) {
|
|
622
|
+
@case ('textarea') {
|
|
623
|
+
<textarea
|
|
624
|
+
class="mform-control"
|
|
625
|
+
[id]="controlId()"
|
|
626
|
+
[value]="stringValue()"
|
|
627
|
+
[placeholder]="field().placeholder || ''"
|
|
628
|
+
[disabled]="field().disabled === true"
|
|
629
|
+
[attr.rows]="rowsProp()"
|
|
630
|
+
[attr.aria-required]="required() || null"
|
|
631
|
+
[attr.aria-invalid]="errors().length > 0 || null"
|
|
632
|
+
[attr.aria-describedby]="describedBy()"
|
|
633
|
+
(input)="setString($event)"
|
|
634
|
+
(blur)="touch()"
|
|
635
|
+
></textarea>
|
|
636
|
+
}
|
|
637
|
+
@case ('select') {
|
|
638
|
+
<select
|
|
639
|
+
class="mform-control"
|
|
640
|
+
[id]="controlId()"
|
|
641
|
+
[disabled]="field().disabled === true"
|
|
642
|
+
[attr.aria-required]="required() || null"
|
|
643
|
+
[attr.aria-invalid]="errors().length > 0 || null"
|
|
644
|
+
[attr.aria-describedby]="describedBy()"
|
|
645
|
+
(change)="setSelect($event)"
|
|
646
|
+
(blur)="touch()"
|
|
647
|
+
>
|
|
648
|
+
<option value="" [selected]="stringValue() === ''"></option>
|
|
649
|
+
@for (option of options(); track option.value) {
|
|
650
|
+
<option
|
|
651
|
+
[value]="option.value"
|
|
652
|
+
[selected]="stringValue() === '' + option.value"
|
|
653
|
+
>
|
|
654
|
+
{{ option.label }}
|
|
655
|
+
</option>
|
|
656
|
+
}
|
|
657
|
+
</select>
|
|
658
|
+
}
|
|
659
|
+
@case ('radio') {
|
|
660
|
+
<div class="mform-choices" role="radiogroup" [attr.aria-label]="field().label || field().key">
|
|
661
|
+
@for (option of options(); track option.value) {
|
|
662
|
+
<label class="mform-check">
|
|
663
|
+
<input
|
|
664
|
+
type="radio"
|
|
665
|
+
[name]="controlId()"
|
|
666
|
+
[checked]="stringValue() === '' + option.value"
|
|
667
|
+
[disabled]="field().disabled === true"
|
|
668
|
+
(change)="setValue(option.value)"
|
|
669
|
+
(blur)="touch()"
|
|
670
|
+
/>
|
|
671
|
+
<span>{{ option.label }}</span>
|
|
672
|
+
</label>
|
|
673
|
+
}
|
|
674
|
+
</div>
|
|
675
|
+
}
|
|
676
|
+
@case ('checkbox') {
|
|
677
|
+
<label class="mform-check">
|
|
678
|
+
<input
|
|
679
|
+
type="checkbox"
|
|
680
|
+
[id]="controlId()"
|
|
681
|
+
[checked]="value() === true"
|
|
682
|
+
[disabled]="field().disabled === true"
|
|
683
|
+
[attr.aria-invalid]="errors().length > 0 || null"
|
|
684
|
+
[attr.aria-describedby]="describedBy()"
|
|
685
|
+
(change)="setChecked($event)"
|
|
686
|
+
(blur)="touch()"
|
|
687
|
+
/>
|
|
688
|
+
<span>
|
|
689
|
+
{{ field().label || field().key }}
|
|
690
|
+
@if (required()) {
|
|
691
|
+
<span class="mform-req" aria-hidden="true">*</span>
|
|
692
|
+
}
|
|
693
|
+
</span>
|
|
694
|
+
</label>
|
|
695
|
+
}
|
|
696
|
+
@case ('checkbox-group') {
|
|
697
|
+
<div class="mform-choices" role="group" [attr.aria-label]="field().label || field().key">
|
|
698
|
+
@for (option of options(); track option.value) {
|
|
699
|
+
<label class="mform-check">
|
|
700
|
+
<input
|
|
701
|
+
type="checkbox"
|
|
702
|
+
[checked]="mapValue()['' + option.value] === true"
|
|
703
|
+
[disabled]="field().disabled === true"
|
|
704
|
+
(change)="toggleMap('' + option.value, $event)"
|
|
705
|
+
(blur)="touch()"
|
|
706
|
+
/>
|
|
707
|
+
<span>{{ option.label }}</span>
|
|
708
|
+
</label>
|
|
709
|
+
}
|
|
710
|
+
</div>
|
|
711
|
+
}
|
|
712
|
+
@case ('file') {
|
|
713
|
+
<input
|
|
714
|
+
class="mform-control"
|
|
715
|
+
type="file"
|
|
716
|
+
multiple
|
|
717
|
+
[id]="controlId()"
|
|
718
|
+
[disabled]="field().disabled === true"
|
|
719
|
+
[attr.aria-describedby]="describedBy()"
|
|
720
|
+
(change)="setFiles($event)"
|
|
721
|
+
(blur)="touch()"
|
|
722
|
+
/>
|
|
723
|
+
}
|
|
724
|
+
@case ('unsupported') {
|
|
725
|
+
<input
|
|
726
|
+
class="mform-control"
|
|
727
|
+
type="text"
|
|
728
|
+
disabled
|
|
729
|
+
[id]="controlId()"
|
|
730
|
+
[value]="'Unsupported field (' + (sourceType() || 'unknown') + ')'"
|
|
731
|
+
/>
|
|
732
|
+
}
|
|
733
|
+
@default {
|
|
734
|
+
<input
|
|
735
|
+
class="mform-control"
|
|
736
|
+
[type]="inputType()"
|
|
737
|
+
[id]="controlId()"
|
|
738
|
+
[value]="stringValue()"
|
|
739
|
+
[placeholder]="field().placeholder || ''"
|
|
740
|
+
[disabled]="field().disabled === true"
|
|
741
|
+
[attr.aria-required]="required() || null"
|
|
742
|
+
[attr.aria-invalid]="errors().length > 0 || null"
|
|
743
|
+
[attr.aria-describedby]="describedBy()"
|
|
744
|
+
(input)="setString($event)"
|
|
745
|
+
(blur)="touch()"
|
|
746
|
+
/>
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
@if (field().description) {
|
|
750
|
+
<div class="mform-desc" [id]="controlId() + '-desc'">{{ field().description }}</div>
|
|
751
|
+
}
|
|
752
|
+
@for (error of errors(); track error.rule) {
|
|
753
|
+
<div class="mform-error" [id]="controlId() + '-err'" role="alert">{{ error.message }}</div>
|
|
754
|
+
}
|
|
755
|
+
</div>
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
`,
|
|
761
|
+
}]
|
|
762
|
+
}], ctorParameters: () => [], propDecorators: { node: [{ type: i0.Input, args: [{ isSignal: true, alias: "node", required: true }] }], engine: [{ type: i0.Input, args: [{ isSignal: true, alias: "engine", required: true }] }], tick: [{ type: i0.Input, args: [{ isSignal: true, alias: "tick", required: true }] }], scope: [{ type: i0.Input, args: [{ isSignal: true, alias: "scope", required: false }] }], bare: [{ type: i0.Input, args: [{ isSignal: true, alias: "bare", required: false }] }], providers: [{ type: i0.Input, args: [{ isSignal: true, alias: "providers", required: false }] }] } });
|
|
763
|
+
|
|
764
|
+
/**
|
|
765
|
+
* `<mform-renderer>` — renders a form at runtime from a `FormSchema` (or any
|
|
766
|
+
* definition a registered importer understands) and manages its lifecycle:
|
|
767
|
+
* values, validation, conditional visibility and submission. Wizard schemas
|
|
768
|
+
* (`settings.display: 'wizard'`) render as steps with per-step validation.
|
|
769
|
+
*
|
|
770
|
+
* Theming: every visual primitive reads a `--mform-*` CSS custom property
|
|
771
|
+
* with a self-contained fallback, so the component looks finished on its own
|
|
772
|
+
* and adopts the host design system when the host overrides the tokens.
|
|
773
|
+
*/
|
|
774
|
+
class FormRendererComponent {
|
|
775
|
+
/** Native schema input. Takes precedence over `source`. */
|
|
776
|
+
schema = input(null, ...(ngDevMode ? [{ debugName: "schema" }] : /* istanbul ignore next */ []));
|
|
777
|
+
/** Foreign definition (object or JSON string) resolved via importers. */
|
|
778
|
+
source = input(null, ...(ngDevMode ? [{ debugName: "source" }] : /* istanbul ignore next */ []));
|
|
779
|
+
/** Initial data merged over schema defaults. */
|
|
780
|
+
initialData = input(null, ...(ngDevMode ? [{ debugName: "initialData" }] : /* istanbul ignore next */ []));
|
|
781
|
+
/** Named providers resolving `optionsSource: provider` fields. */
|
|
782
|
+
optionsProviders = input({}, ...(ngDevMode ? [{ debugName: "optionsProviders" }] : /* istanbul ignore next */ []));
|
|
783
|
+
/** Host-supplied message resolver (localization of validation messages). */
|
|
784
|
+
messages = input(null, ...(ngDevMode ? [{ debugName: "messages" }] : /* istanbul ignore next */ []));
|
|
785
|
+
/** Fixed renderer texts (override to localize). `{{count}}` in `summary`. */
|
|
786
|
+
labels = input({
|
|
787
|
+
previous: 'Previous',
|
|
788
|
+
next: 'Next',
|
|
789
|
+
submit: 'Submit',
|
|
790
|
+
summary: '{{count}} field(s) need attention:',
|
|
791
|
+
}, { ...(ngDevMode ? { debugName: "labels" } : /* istanbul ignore next */ {}), transform: (value) => ({
|
|
792
|
+
previous: 'Previous',
|
|
793
|
+
next: 'Next',
|
|
794
|
+
submit: 'Submit',
|
|
795
|
+
summary: '{{count}} field(s) need attention:',
|
|
796
|
+
...value,
|
|
797
|
+
}) });
|
|
798
|
+
summaryTitle() {
|
|
799
|
+
return this.labels().summary.replace(/\{\{\s*count\s*\}\}/g, String(this.errorSummary().length));
|
|
800
|
+
}
|
|
801
|
+
/** Fired on submit; `ok` is false when validation failed. */
|
|
802
|
+
submitted = output();
|
|
803
|
+
/** Fired on every value change with the full data snapshot. */
|
|
804
|
+
valueChanged = output();
|
|
805
|
+
/** Bumped on every engine event; nodes read it to refresh. */
|
|
806
|
+
tick = signal(0, ...(ngDevMode ? [{ debugName: "tick" }] : /* istanbul ignore next */ []));
|
|
807
|
+
/** Active wizard step index. */
|
|
808
|
+
currentStep = signal(0, ...(ngDevMode ? [{ debugName: "currentStep" }] : /* istanbul ignore next */ []));
|
|
809
|
+
resolved = computed(() => {
|
|
810
|
+
const native = this.schema();
|
|
811
|
+
if (native)
|
|
812
|
+
return { schema: native, warnings: [] };
|
|
813
|
+
const source = this.source();
|
|
814
|
+
if (source === null || source === undefined)
|
|
815
|
+
return { schema: null, warnings: [] };
|
|
816
|
+
try {
|
|
817
|
+
const result = importForm(source, [createLegacyImporter()]);
|
|
818
|
+
return { schema: result.schema, warnings: result.warnings };
|
|
819
|
+
}
|
|
820
|
+
catch {
|
|
821
|
+
return {
|
|
822
|
+
schema: null,
|
|
823
|
+
warnings: [
|
|
824
|
+
{ code: 'unreadable-source', message: 'The form definition could not be read.' },
|
|
825
|
+
],
|
|
826
|
+
};
|
|
827
|
+
}
|
|
828
|
+
}, ...(ngDevMode ? [{ debugName: "resolved" }] : /* istanbul ignore next */ []));
|
|
829
|
+
/** Import degradations for the current `source`, if any. */
|
|
830
|
+
importWarnings = computed(() => this.resolved().warnings, ...(ngDevMode ? [{ debugName: "importWarnings" }] : /* istanbul ignore next */ []));
|
|
831
|
+
engine = computed(() => {
|
|
832
|
+
const schema = this.resolved().schema;
|
|
833
|
+
return schema
|
|
834
|
+
? createFormEngine(schema, {
|
|
835
|
+
initialData: this.initialData() ?? undefined,
|
|
836
|
+
messages: this.messages() ?? undefined,
|
|
837
|
+
})
|
|
838
|
+
: null;
|
|
839
|
+
}, ...(ngDevMode ? [{ debugName: "engine" }] : /* istanbul ignore next */ []));
|
|
840
|
+
isWizard = computed(() => this.resolved().schema?.settings?.display === 'wizard' && this.steps().length > 0, ...(ngDevMode ? [{ debugName: "isWizard" }] : /* istanbul ignore next */ []));
|
|
841
|
+
/** Wizard pages: the top-level containers of the schema. */
|
|
842
|
+
steps = computed(() => (this.resolved().schema?.fields ?? []).filter((node) => node.kind === 'container'), ...(ngDevMode ? [{ debugName: "steps" }] : /* istanbul ignore next */ []));
|
|
843
|
+
errorSummary = signal([], ...(ngDevMode ? [{ debugName: "errorSummary" }] : /* istanbul ignore next */ []));
|
|
844
|
+
constructor() {
|
|
845
|
+
effect((onCleanup) => {
|
|
846
|
+
const engine = this.engine();
|
|
847
|
+
this.errorSummary.set([]);
|
|
848
|
+
this.tick.set(0);
|
|
849
|
+
this.currentStep.set(0);
|
|
850
|
+
if (!engine)
|
|
851
|
+
return;
|
|
852
|
+
const unsubscribe = engine.subscribe((event) => {
|
|
853
|
+
this.tick.update((v) => v + 1);
|
|
854
|
+
if (event.type === 'value-change') {
|
|
855
|
+
this.valueChanged.emit(engine.getData());
|
|
856
|
+
}
|
|
857
|
+
if (event.type === 'validation') {
|
|
858
|
+
// Keep the banner in sync as the user fixes fields after a submit.
|
|
859
|
+
if (this.errorSummary().length > 0) {
|
|
860
|
+
this.errorSummary.set(event.errors);
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
});
|
|
864
|
+
onCleanup(unsubscribe);
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
// -- wizard navigation ----------------------------------------------------
|
|
868
|
+
previousStep() {
|
|
869
|
+
this.errorSummary.set([]);
|
|
870
|
+
this.currentStep.update((step) => Math.max(0, step - 1));
|
|
871
|
+
}
|
|
872
|
+
nextStep() {
|
|
873
|
+
const engine = this.engine();
|
|
874
|
+
const step = this.steps()[this.currentStep()];
|
|
875
|
+
if (!engine || !step)
|
|
876
|
+
return;
|
|
877
|
+
const errors = engine.validateContainer(step.key);
|
|
878
|
+
if (errors.length > 0) {
|
|
879
|
+
this.errorSummary.set(errors);
|
|
880
|
+
this.focusFirstError(errors);
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
883
|
+
this.errorSummary.set([]);
|
|
884
|
+
this.currentStep.update((index) => Math.min(this.steps().length - 1, index + 1));
|
|
885
|
+
}
|
|
886
|
+
// -- submission -----------------------------------------------------------
|
|
887
|
+
onSubmit(event) {
|
|
888
|
+
event.preventDefault();
|
|
889
|
+
const engine = this.engine();
|
|
890
|
+
if (!engine)
|
|
891
|
+
return;
|
|
892
|
+
const result = engine.submit();
|
|
893
|
+
this.errorSummary.set(result.errors);
|
|
894
|
+
if (!result.ok) {
|
|
895
|
+
if (this.isWizard()) {
|
|
896
|
+
const stepIndex = this.stepIndexForPath(result.errors[0]?.path ?? '');
|
|
897
|
+
if (stepIndex >= 0)
|
|
898
|
+
this.currentStep.set(stepIndex);
|
|
899
|
+
}
|
|
900
|
+
this.focusFirstError(result.errors);
|
|
901
|
+
}
|
|
902
|
+
this.submitted.emit(result);
|
|
903
|
+
}
|
|
904
|
+
/** Finds the wizard step that owns a (possibly row-indexed) data path. */
|
|
905
|
+
stepIndexForPath(path) {
|
|
906
|
+
const pattern = path
|
|
907
|
+
.split('.')
|
|
908
|
+
.map((part) => (/^\d+$/.test(part) ? '*' : part))
|
|
909
|
+
.join('.');
|
|
910
|
+
return this.steps().findIndex((step) => collectInputs({ version: 1, fields: step.children }).some((entry) => entry.path === pattern));
|
|
911
|
+
}
|
|
912
|
+
focusFirstError(errors) {
|
|
913
|
+
const first = errors[0];
|
|
914
|
+
if (!first)
|
|
915
|
+
return;
|
|
916
|
+
const id = `mform-${first.path.replace(/\./g, '-')}`;
|
|
917
|
+
// Focus after the view updated with the error states.
|
|
918
|
+
queueMicrotask(() => document.getElementById(id)?.focus());
|
|
919
|
+
}
|
|
920
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.19", ngImport: i0, type: FormRendererComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
921
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.19", type: FormRendererComponent, isStandalone: true, selector: "mform-renderer", inputs: { schema: { classPropertyName: "schema", publicName: "schema", isSignal: true, isRequired: false, transformFunction: null }, source: { classPropertyName: "source", publicName: "source", isSignal: true, isRequired: false, transformFunction: null }, initialData: { classPropertyName: "initialData", publicName: "initialData", isSignal: true, isRequired: false, transformFunction: null }, optionsProviders: { classPropertyName: "optionsProviders", publicName: "optionsProviders", isSignal: true, isRequired: false, transformFunction: null }, messages: { classPropertyName: "messages", publicName: "messages", isSignal: true, isRequired: false, transformFunction: null }, labels: { classPropertyName: "labels", publicName: "labels", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { submitted: "submitted", valueChanged: "valueChanged" }, ngImport: i0, template: `
|
|
922
|
+
@if (engine(); as engine) {
|
|
923
|
+
<form class="mform-root" novalidate (submit)="onSubmit($event)">
|
|
924
|
+
@if (errorSummary().length > 0) {
|
|
925
|
+
<div class="mform-summary" role="alert" tabindex="-1">
|
|
926
|
+
<strong>{{ summaryTitle() }}</strong>
|
|
927
|
+
<ul>
|
|
928
|
+
@for (error of errorSummary(); track error.path + error.rule) {
|
|
929
|
+
<li>{{ error.message }}</li>
|
|
930
|
+
}
|
|
931
|
+
</ul>
|
|
932
|
+
</div>
|
|
933
|
+
}
|
|
934
|
+
@if (isWizard()) {
|
|
935
|
+
<ol class="mform-steps">
|
|
936
|
+
@for (step of steps(); track step.key; let i = $index) {
|
|
937
|
+
<li
|
|
938
|
+
class="mform-step"
|
|
939
|
+
[class.is-active]="currentStep() === i"
|
|
940
|
+
[class.is-done]="i < currentStep()"
|
|
941
|
+
[attr.aria-current]="currentStep() === i ? 'step' : null"
|
|
942
|
+
>
|
|
943
|
+
<span class="mform-step-index">{{ i + 1 }}</span>
|
|
944
|
+
<span class="mform-step-label">{{ step.label || 'Step ' + (i + 1) }}</span>
|
|
945
|
+
</li>
|
|
946
|
+
}
|
|
947
|
+
</ol>
|
|
948
|
+
@if (steps()[currentStep()]; as step) {
|
|
949
|
+
<mform-node
|
|
950
|
+
[node]="step"
|
|
951
|
+
[engine]="engine"
|
|
952
|
+
[tick]="tick()"
|
|
953
|
+
[providers]="optionsProviders()"
|
|
954
|
+
[bare]="true"
|
|
955
|
+
/>
|
|
956
|
+
}
|
|
957
|
+
<div class="mform-wizard-nav">
|
|
958
|
+
@if (currentStep() > 0) {
|
|
959
|
+
<button type="button" class="mform-btn mform-btn-secondary" (click)="previousStep()">
|
|
960
|
+
{{ labels().previous }}
|
|
961
|
+
</button>
|
|
962
|
+
}
|
|
963
|
+
@if (currentStep() < steps().length - 1) {
|
|
964
|
+
<button type="button" class="mform-btn" (click)="nextStep()">
|
|
965
|
+
{{ labels().next }}
|
|
966
|
+
</button>
|
|
967
|
+
} @else {
|
|
968
|
+
<button type="submit" class="mform-btn">{{ labels().submit }}</button>
|
|
969
|
+
}
|
|
970
|
+
</div>
|
|
971
|
+
} @else {
|
|
972
|
+
@for (node of engine.schema.fields; track node.key) {
|
|
973
|
+
<mform-node [node]="node" [engine]="engine" [tick]="tick()" [providers]="optionsProviders()" />
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
</form>
|
|
977
|
+
}
|
|
978
|
+
`, isInline: true, styles: [".mform-root{display:flex;flex-direction:column;gap:var(--mform-gap, 16px);font-family:var(--mform-font, system-ui, \"Segoe UI\", sans-serif);color:var(--mform-text, #1f1b2e)}.mform-node{display:contents}.mform-field{display:flex;flex-direction:column;gap:6px}.mform-label{font-size:var(--mform-label-size, 13px);font-weight:var(--mform-label-weight, 600)}.mform-req{color:var(--mform-danger, #c62842);margin-left:2px}.mform-control{font:inherit;width:100%;padding:var(--mform-control-padding, 9px 12px);border:1px solid var(--mform-border, #d9d5e6);border-radius:var(--mform-control-radius, 8px);background:var(--mform-control-bg, #fff);color:inherit;box-sizing:border-box}textarea.mform-control{min-height:72px;resize:vertical}.mform-control:focus-visible{outline:2px solid var(--mform-focus, #8e6ff0);outline-offset:1px;border-color:var(--mform-focus, #8e6ff0)}.mform-field.has-error .mform-control{border-color:var(--mform-danger, #c62842)}.mform-desc{font-size:12px;color:var(--mform-muted, #6b6580)}.mform-error{font-size:12px;color:var(--mform-danger, #c62842)}.mform-choices{display:flex;flex-direction:column;gap:6px}.mform-check{display:flex;align-items:center;gap:8px;font-size:14px;cursor:pointer}.mform-check input{width:16px;height:16px;accent-color:var(--mform-primary, #6d4bd0)}.mform-panel{border:1px solid var(--mform-border, #d9d5e6);border-radius:var(--mform-radius, 10px);overflow:hidden}.mform-panel-head{background:var(--mform-panel-head-bg, linear-gradient(90deg, #efeaff, #f8f6ff));color:var(--mform-panel-head-text, inherit);padding:10px 16px;font-weight:600;font-size:14px;border-bottom:1px solid var(--mform-border, #d9d5e6)}.mform-panel-body,.mform-group,.mform-column,.mform-tabpanel{display:flex;flex-direction:column;gap:var(--mform-gap, 16px)}.mform-panel-body{padding:var(--mform-panel-padding, 16px)}.mform-columns{display:grid;gap:var(--mform-gap, 16px)}.mform-tabbar{display:flex;gap:4px;border-bottom:2px solid var(--mform-border, #d9d5e6);margin-bottom:14px}.mform-tab{padding:8px 18px;font:inherit;font-size:14px;border:none;background:none;color:var(--mform-muted, #6b6580);cursor:pointer}.mform-tab.is-active{color:var(--mform-primary, #6d4bd0);font-weight:600;border-bottom:2px solid var(--mform-primary, #6d4bd0);margin-bottom:-2px}.mform-grid-row{display:flex;align-items:flex-start;gap:8px;padding:10px 0;border-bottom:1px dashed var(--mform-border, #d9d5e6)}.mform-grid-fields{flex:1;display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:var(--mform-gap, 16px)}.mform-row-remove{border:none;background:none;color:var(--mform-muted, #6b6580);font:inherit;cursor:pointer;padding:6px 8px}.mform-row-remove:hover{color:var(--mform-danger, #c62842)}.mform-row-add{align-self:flex-start;background:none;border:1px dashed var(--mform-primary, #6d4bd0);color:var(--mform-primary, #6d4bd0);padding:7px 14px;border-radius:var(--mform-control-radius, 8px);font:inherit;font-size:13px;cursor:pointer}.mform-btn{align-self:flex-start;background:var(--mform-primary, #6d4bd0);color:var(--mform-primary-contrast, #fff);border:none;border-radius:var(--mform-control-radius, 8px);padding:11px 26px;font:inherit;font-weight:600;cursor:pointer}.mform-btn:hover{filter:brightness(1.08)}.mform-btn:focus-visible{outline:2px solid var(--mform-focus, #8e6ff0);outline-offset:2px}.mform-content{font-size:14px}.mform-unsupported{font-size:13px;color:var(--mform-muted, #6b6580);border:1px dashed var(--mform-border, #d9d5e6);border-radius:var(--mform-radius, 10px);padding:10px 14px}.mform-steps{display:flex;gap:18px;list-style:none;margin:0 0 4px;padding:0 0 12px;border-bottom:1px solid var(--mform-border, #d9d5e6);flex-wrap:wrap}.mform-step{display:flex;align-items:center;gap:8px;font-size:13px;color:var(--mform-muted, #6b6580)}.mform-step-index{display:inline-flex;align-items:center;justify-content:center;width:22px;height:22px;border-radius:50%;border:1.5px solid var(--mform-border, #d9d5e6);font-size:11px;font-weight:600}.mform-step.is-active{color:var(--mform-primary, #6d4bd0);font-weight:600}.mform-step.is-active .mform-step-index{border-color:var(--mform-primary, #6d4bd0);background:var(--mform-primary, #6d4bd0);color:var(--mform-primary-contrast, #fff)}.mform-step.is-done .mform-step-index{border-color:var(--mform-primary, #6d4bd0);color:var(--mform-primary, #6d4bd0)}.mform-wizard-nav{display:flex;gap:10px;padding-top:4px}.mform-btn-secondary{background:none;border:1px solid var(--mform-primary, #6d4bd0);color:var(--mform-primary, #6d4bd0)}.mform-summary{border:1px solid var(--mform-danger, #c62842);background:var(--mform-danger-bg, #fdf0f2);color:var(--mform-danger, #c62842);border-radius:var(--mform-radius, 10px);padding:12px 16px;font-size:13px}.mform-summary ul{margin:6px 0 0;padding-left:18px}\n"], dependencies: [{ kind: "component", type: FormNodeComponent, selector: "mform-node", inputs: ["node", "engine", "tick", "scope", "bare", "providers"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush, encapsulation: i0.ViewEncapsulation.None });
|
|
979
|
+
}
|
|
980
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.19", ngImport: i0, type: FormRendererComponent, decorators: [{
|
|
981
|
+
type: Component,
|
|
982
|
+
args: [{ selector: 'mform-renderer', changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, imports: [FormNodeComponent], template: `
|
|
983
|
+
@if (engine(); as engine) {
|
|
984
|
+
<form class="mform-root" novalidate (submit)="onSubmit($event)">
|
|
985
|
+
@if (errorSummary().length > 0) {
|
|
986
|
+
<div class="mform-summary" role="alert" tabindex="-1">
|
|
987
|
+
<strong>{{ summaryTitle() }}</strong>
|
|
988
|
+
<ul>
|
|
989
|
+
@for (error of errorSummary(); track error.path + error.rule) {
|
|
990
|
+
<li>{{ error.message }}</li>
|
|
991
|
+
}
|
|
992
|
+
</ul>
|
|
993
|
+
</div>
|
|
994
|
+
}
|
|
995
|
+
@if (isWizard()) {
|
|
996
|
+
<ol class="mform-steps">
|
|
997
|
+
@for (step of steps(); track step.key; let i = $index) {
|
|
998
|
+
<li
|
|
999
|
+
class="mform-step"
|
|
1000
|
+
[class.is-active]="currentStep() === i"
|
|
1001
|
+
[class.is-done]="i < currentStep()"
|
|
1002
|
+
[attr.aria-current]="currentStep() === i ? 'step' : null"
|
|
1003
|
+
>
|
|
1004
|
+
<span class="mform-step-index">{{ i + 1 }}</span>
|
|
1005
|
+
<span class="mform-step-label">{{ step.label || 'Step ' + (i + 1) }}</span>
|
|
1006
|
+
</li>
|
|
1007
|
+
}
|
|
1008
|
+
</ol>
|
|
1009
|
+
@if (steps()[currentStep()]; as step) {
|
|
1010
|
+
<mform-node
|
|
1011
|
+
[node]="step"
|
|
1012
|
+
[engine]="engine"
|
|
1013
|
+
[tick]="tick()"
|
|
1014
|
+
[providers]="optionsProviders()"
|
|
1015
|
+
[bare]="true"
|
|
1016
|
+
/>
|
|
1017
|
+
}
|
|
1018
|
+
<div class="mform-wizard-nav">
|
|
1019
|
+
@if (currentStep() > 0) {
|
|
1020
|
+
<button type="button" class="mform-btn mform-btn-secondary" (click)="previousStep()">
|
|
1021
|
+
{{ labels().previous }}
|
|
1022
|
+
</button>
|
|
1023
|
+
}
|
|
1024
|
+
@if (currentStep() < steps().length - 1) {
|
|
1025
|
+
<button type="button" class="mform-btn" (click)="nextStep()">
|
|
1026
|
+
{{ labels().next }}
|
|
1027
|
+
</button>
|
|
1028
|
+
} @else {
|
|
1029
|
+
<button type="submit" class="mform-btn">{{ labels().submit }}</button>
|
|
1030
|
+
}
|
|
1031
|
+
</div>
|
|
1032
|
+
} @else {
|
|
1033
|
+
@for (node of engine.schema.fields; track node.key) {
|
|
1034
|
+
<mform-node [node]="node" [engine]="engine" [tick]="tick()" [providers]="optionsProviders()" />
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
</form>
|
|
1038
|
+
}
|
|
1039
|
+
`, styles: [".mform-root{display:flex;flex-direction:column;gap:var(--mform-gap, 16px);font-family:var(--mform-font, system-ui, \"Segoe UI\", sans-serif);color:var(--mform-text, #1f1b2e)}.mform-node{display:contents}.mform-field{display:flex;flex-direction:column;gap:6px}.mform-label{font-size:var(--mform-label-size, 13px);font-weight:var(--mform-label-weight, 600)}.mform-req{color:var(--mform-danger, #c62842);margin-left:2px}.mform-control{font:inherit;width:100%;padding:var(--mform-control-padding, 9px 12px);border:1px solid var(--mform-border, #d9d5e6);border-radius:var(--mform-control-radius, 8px);background:var(--mform-control-bg, #fff);color:inherit;box-sizing:border-box}textarea.mform-control{min-height:72px;resize:vertical}.mform-control:focus-visible{outline:2px solid var(--mform-focus, #8e6ff0);outline-offset:1px;border-color:var(--mform-focus, #8e6ff0)}.mform-field.has-error .mform-control{border-color:var(--mform-danger, #c62842)}.mform-desc{font-size:12px;color:var(--mform-muted, #6b6580)}.mform-error{font-size:12px;color:var(--mform-danger, #c62842)}.mform-choices{display:flex;flex-direction:column;gap:6px}.mform-check{display:flex;align-items:center;gap:8px;font-size:14px;cursor:pointer}.mform-check input{width:16px;height:16px;accent-color:var(--mform-primary, #6d4bd0)}.mform-panel{border:1px solid var(--mform-border, #d9d5e6);border-radius:var(--mform-radius, 10px);overflow:hidden}.mform-panel-head{background:var(--mform-panel-head-bg, linear-gradient(90deg, #efeaff, #f8f6ff));color:var(--mform-panel-head-text, inherit);padding:10px 16px;font-weight:600;font-size:14px;border-bottom:1px solid var(--mform-border, #d9d5e6)}.mform-panel-body,.mform-group,.mform-column,.mform-tabpanel{display:flex;flex-direction:column;gap:var(--mform-gap, 16px)}.mform-panel-body{padding:var(--mform-panel-padding, 16px)}.mform-columns{display:grid;gap:var(--mform-gap, 16px)}.mform-tabbar{display:flex;gap:4px;border-bottom:2px solid var(--mform-border, #d9d5e6);margin-bottom:14px}.mform-tab{padding:8px 18px;font:inherit;font-size:14px;border:none;background:none;color:var(--mform-muted, #6b6580);cursor:pointer}.mform-tab.is-active{color:var(--mform-primary, #6d4bd0);font-weight:600;border-bottom:2px solid var(--mform-primary, #6d4bd0);margin-bottom:-2px}.mform-grid-row{display:flex;align-items:flex-start;gap:8px;padding:10px 0;border-bottom:1px dashed var(--mform-border, #d9d5e6)}.mform-grid-fields{flex:1;display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:var(--mform-gap, 16px)}.mform-row-remove{border:none;background:none;color:var(--mform-muted, #6b6580);font:inherit;cursor:pointer;padding:6px 8px}.mform-row-remove:hover{color:var(--mform-danger, #c62842)}.mform-row-add{align-self:flex-start;background:none;border:1px dashed var(--mform-primary, #6d4bd0);color:var(--mform-primary, #6d4bd0);padding:7px 14px;border-radius:var(--mform-control-radius, 8px);font:inherit;font-size:13px;cursor:pointer}.mform-btn{align-self:flex-start;background:var(--mform-primary, #6d4bd0);color:var(--mform-primary-contrast, #fff);border:none;border-radius:var(--mform-control-radius, 8px);padding:11px 26px;font:inherit;font-weight:600;cursor:pointer}.mform-btn:hover{filter:brightness(1.08)}.mform-btn:focus-visible{outline:2px solid var(--mform-focus, #8e6ff0);outline-offset:2px}.mform-content{font-size:14px}.mform-unsupported{font-size:13px;color:var(--mform-muted, #6b6580);border:1px dashed var(--mform-border, #d9d5e6);border-radius:var(--mform-radius, 10px);padding:10px 14px}.mform-steps{display:flex;gap:18px;list-style:none;margin:0 0 4px;padding:0 0 12px;border-bottom:1px solid var(--mform-border, #d9d5e6);flex-wrap:wrap}.mform-step{display:flex;align-items:center;gap:8px;font-size:13px;color:var(--mform-muted, #6b6580)}.mform-step-index{display:inline-flex;align-items:center;justify-content:center;width:22px;height:22px;border-radius:50%;border:1.5px solid var(--mform-border, #d9d5e6);font-size:11px;font-weight:600}.mform-step.is-active{color:var(--mform-primary, #6d4bd0);font-weight:600}.mform-step.is-active .mform-step-index{border-color:var(--mform-primary, #6d4bd0);background:var(--mform-primary, #6d4bd0);color:var(--mform-primary-contrast, #fff)}.mform-step.is-done .mform-step-index{border-color:var(--mform-primary, #6d4bd0);color:var(--mform-primary, #6d4bd0)}.mform-wizard-nav{display:flex;gap:10px;padding-top:4px}.mform-btn-secondary{background:none;border:1px solid var(--mform-primary, #6d4bd0);color:var(--mform-primary, #6d4bd0)}.mform-summary{border:1px solid var(--mform-danger, #c62842);background:var(--mform-danger-bg, #fdf0f2);color:var(--mform-danger, #c62842);border-radius:var(--mform-radius, 10px);padding:12px 16px;font-size:13px}.mform-summary ul{margin:6px 0 0;padding-left:18px}\n"] }]
|
|
1040
|
+
}], ctorParameters: () => [], propDecorators: { schema: [{ type: i0.Input, args: [{ isSignal: true, alias: "schema", required: false }] }], source: [{ type: i0.Input, args: [{ isSignal: true, alias: "source", required: false }] }], initialData: [{ type: i0.Input, args: [{ isSignal: true, alias: "initialData", required: false }] }], optionsProviders: [{ type: i0.Input, args: [{ isSignal: true, alias: "optionsProviders", required: false }] }], messages: [{ type: i0.Input, args: [{ isSignal: true, alias: "messages", required: false }] }], labels: [{ type: i0.Input, args: [{ isSignal: true, alias: "labels", required: false }] }], submitted: [{ type: i0.Output, args: ["submitted"] }], valueChanged: [{ type: i0.Output, args: ["valueChanged"] }] } });
|
|
1041
|
+
|
|
1042
|
+
/**
|
|
1043
|
+
* Generated bundle index. Do not edit.
|
|
1044
|
+
*/
|
|
1045
|
+
|
|
1046
|
+
export { FormNodeComponent, FormRendererComponent };
|
|
1047
|
+
//# sourceMappingURL=mosaicoo-form-angular.mjs.map
|