@theredhead/lucid-forms 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,2789 @@
1
+ import * as i0 from '@angular/core';
2
+ import { computed, signal, InjectionToken, makeEnvironmentProviders, inject, Injectable, input, viewChild, ViewContainerRef, effect, untracked, ChangeDetectionStrategy, Component, output, forwardRef } from '@angular/core';
3
+ import { UppercaseTextAdapter, CronTextAdapter, UuidTextAdapter, SlugTextAdapter, ColorTextAdapter, TimeTextAdapter, DateTextAdapter, PercentageTextAdapter, HexadecimalTextAdapter, DecimalTextAdapter, FloatTextAdapter, IntegerTextAdapter, MoneyTextAdapter, CreditCardTextAdapter, PhoneTextAdapter, IPAddressTextAdapter, UrlTextAdapter, EmailTextAdapter, UIMediaPlayer, UIImage, UIRichTextView, UIFileUpload, UIRichTextEditor, UISlider, UIColorPicker, DateInputAdapter, UIInput, UIAutocomplete, UIRadioGroup, UIToggle, UICheckbox, UIDropdownList, UIIcons, UIIcon, ModalRef, ModalService } from '@theredhead/lucid-kit';
4
+ import * as i1 from '@theredhead/lucid-foundation';
5
+ import { LoggerFactory, UISurface } from '@theredhead/lucid-foundation';
6
+ import { JsonPipe } from '@angular/common';
7
+
8
+ // ── Condition types ───────────────────────────────────────────────────
9
+ /**
10
+ * Check whether a `Condition` is a `ConditionGroup` (has nested
11
+ * `conditions` array) rather than a single `FieldCondition`.
12
+ */
13
+ function isConditionGroup(c) {
14
+ return "conditions" in c && Array.isArray(c.conditions);
15
+ }
16
+
17
+ // ── Form schema types ────────────────────────────────────────────────
18
+ // ── Flair helpers ───────────────────────────────────────────────────
19
+ /**
20
+ * Known flair component keys. Flair items are purely presentational
21
+ * elements that do not collect user input.
22
+ */
23
+ const FLAIR_COMPONENTS = [
24
+ "flair:richtext",
25
+ "flair:image",
26
+ "flair:media",
27
+ ];
28
+ /**
29
+ * Returns `true` if the given component key is a flair (non-data)
30
+ * component.
31
+ */
32
+ function isFlairComponent(component) {
33
+ return FLAIR_COMPONENTS.includes(component);
34
+ }
35
+
36
+ // ── Validation engine ────────────────────────────────────────────────
37
+ /** Registry of custom validators keyed by `validatorId`. */
38
+ const customValidators = new Map();
39
+ /**
40
+ * Register a custom validator that can be referenced by
41
+ * `{ type: "custom", params: { validatorId: "myId" } }` in a
42
+ * form schema.
43
+ */
44
+ function registerCustomValidator(id, fn) {
45
+ customValidators.set(id, fn);
46
+ }
47
+ // ── Built-in validator implementations ────────────────────────────────
48
+ function isEmpty(v) {
49
+ if (v === null || v === undefined)
50
+ return true;
51
+ if (typeof v === "boolean")
52
+ return !v;
53
+ if (typeof v === "string")
54
+ return v.trim().length === 0;
55
+ if (Array.isArray(v))
56
+ return v.length === 0;
57
+ return false;
58
+ }
59
+ function validateRequired(value, _params, message) {
60
+ return isEmpty(value)
61
+ ? { type: "required", message: message ?? "This field is required." }
62
+ : null;
63
+ }
64
+ function validateMinLength(value, params, message) {
65
+ const min = params["min"];
66
+ if (typeof value !== "string")
67
+ return null;
68
+ return value.length < min
69
+ ? {
70
+ type: "minLength",
71
+ message: message ?? `Must be at least ${min} characters.`,
72
+ }
73
+ : null;
74
+ }
75
+ function validateMaxLength(value, params, message) {
76
+ const max = params["max"];
77
+ if (typeof value !== "string")
78
+ return null;
79
+ return value.length > max
80
+ ? {
81
+ type: "maxLength",
82
+ message: message ?? `Must be at most ${max} characters.`,
83
+ }
84
+ : null;
85
+ }
86
+ function validateMin(value, params, message) {
87
+ const min = params["min"];
88
+ if (typeof value !== "number")
89
+ return null;
90
+ return value < min
91
+ ? { type: "min", message: message ?? `Must be at least ${min}.` }
92
+ : null;
93
+ }
94
+ function validateMax(value, params, message) {
95
+ const max = params["max"];
96
+ if (typeof value !== "number")
97
+ return null;
98
+ return value > max
99
+ ? { type: "max", message: message ?? `Must be at most ${max}.` }
100
+ : null;
101
+ }
102
+ function validatePattern(value, params, message) {
103
+ const pattern = params["pattern"];
104
+ if (typeof value !== "string" || value.length === 0)
105
+ return null;
106
+ const re = new RegExp(pattern);
107
+ return !re.test(value)
108
+ ? {
109
+ type: "pattern",
110
+ message: message ?? `Does not match the required pattern.`,
111
+ }
112
+ : null;
113
+ }
114
+ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
115
+ function validateEmail(value, _params, message) {
116
+ if (typeof value !== "string" || value.length === 0)
117
+ return null;
118
+ return !EMAIL_RE.test(value)
119
+ ? {
120
+ type: "email",
121
+ message: message ?? "Enter a valid e-mail address.",
122
+ }
123
+ : null;
124
+ }
125
+ function validateCustom(value, params, message) {
126
+ const id = params["validatorId"];
127
+ const fn = customValidators.get(id);
128
+ if (!fn) {
129
+ return { type: "custom", message: `Unknown validator: "${id}".` };
130
+ }
131
+ const result = fn(value, params);
132
+ if (result) {
133
+ return { ...result, message: message ?? result.message };
134
+ }
135
+ return null;
136
+ }
137
+ // ── Public API ────────────────────────────────────────────────────────
138
+ /**
139
+ * Run a single validation rule against a value.
140
+ */
141
+ function runValidator(rule, value) {
142
+ const params = rule.params ?? {};
143
+ switch (rule.type) {
144
+ case "required":
145
+ return validateRequired(value, params, rule.message);
146
+ case "minLength":
147
+ return validateMinLength(value, params, rule.message);
148
+ case "maxLength":
149
+ return validateMaxLength(value, params, rule.message);
150
+ case "min":
151
+ return validateMin(value, params, rule.message);
152
+ case "max":
153
+ return validateMax(value, params, rule.message);
154
+ case "pattern":
155
+ return validatePattern(value, params, rule.message);
156
+ case "email":
157
+ return validateEmail(value, params, rule.message);
158
+ case "custom":
159
+ return validateCustom(value, params, rule.message);
160
+ default:
161
+ return null;
162
+ }
163
+ }
164
+ /**
165
+ * Validate a value against an array of rules and return a
166
+ * {@link ValidationResult}.
167
+ */
168
+ function validate(rules, value) {
169
+ const errors = [];
170
+ for (const rule of rules) {
171
+ const error = runValidator(rule, value);
172
+ if (error) {
173
+ errors.push(error);
174
+ }
175
+ }
176
+ return { valid: errors.length === 0, errors };
177
+ }
178
+
179
+ // ── Condition evaluator ───────────────────────────────────────────────
180
+ /**
181
+ * Evaluate a single {@link FieldCondition} against the current
182
+ * form values.
183
+ */
184
+ function evaluateFieldCondition(condition, values) {
185
+ const actual = values[condition.field];
186
+ const target = condition.value;
187
+ switch (condition.operator) {
188
+ case "equals":
189
+ return actual === target;
190
+ case "notEquals":
191
+ return actual !== target;
192
+ case "contains":
193
+ if (typeof actual === "string" && typeof target === "string") {
194
+ return actual.includes(target);
195
+ }
196
+ if (Array.isArray(actual)) {
197
+ return actual.includes(target);
198
+ }
199
+ return false;
200
+ case "notContains":
201
+ if (typeof actual === "string" && typeof target === "string") {
202
+ return !actual.includes(target);
203
+ }
204
+ if (Array.isArray(actual)) {
205
+ return !actual.includes(target);
206
+ }
207
+ return true;
208
+ case "empty":
209
+ return (actual === null ||
210
+ actual === undefined ||
211
+ actual === "" ||
212
+ (Array.isArray(actual) && actual.length === 0));
213
+ case "notEmpty":
214
+ return (actual !== null &&
215
+ actual !== undefined &&
216
+ actual !== "" &&
217
+ !(Array.isArray(actual) && actual.length === 0));
218
+ case "greaterThan":
219
+ return typeof actual === "number" && typeof target === "number"
220
+ ? actual > target
221
+ : false;
222
+ case "lessThan":
223
+ return typeof actual === "number" && typeof target === "number"
224
+ ? actual < target
225
+ : false;
226
+ case "greaterThanOrEqual":
227
+ return typeof actual === "number" && typeof target === "number"
228
+ ? actual >= target
229
+ : false;
230
+ case "lessThanOrEqual":
231
+ return typeof actual === "number" && typeof target === "number"
232
+ ? actual <= target
233
+ : false;
234
+ case "in":
235
+ return Array.isArray(target) ? target.includes(actual) : false;
236
+ case "notIn":
237
+ return Array.isArray(target) ? !target.includes(actual) : true;
238
+ default:
239
+ return true;
240
+ }
241
+ }
242
+ /**
243
+ * Evaluate a {@link Condition} (single or group) against the
244
+ * current form values.
245
+ *
246
+ * @returns `true` when the condition is satisfied.
247
+ */
248
+ function evaluateCondition(condition, values) {
249
+ if (isConditionGroup(condition)) {
250
+ const mode = condition.mode ?? "every";
251
+ return mode === "every"
252
+ ? condition.conditions.every((c) => evaluateFieldCondition(c, values))
253
+ : condition.conditions.some((c) => evaluateFieldCondition(c, values));
254
+ }
255
+ return evaluateFieldCondition(condition, values);
256
+ }
257
+
258
+ // ── Form engine ──────────────────────────────────────────────────────
259
+ // ── Form engine ──────────────────────────────────────────────────────
260
+ /**
261
+ * Signal-based form engine. Takes a {@link FormSchema}, creates
262
+ * reactive state for every field and group, evaluates conditions,
263
+ * runs validation, and produces a JSON output object.
264
+ *
265
+ * The engine is framework-agnostic (no Angular DI required) — it
266
+ * operates purely on signals and plain objects.
267
+ *
268
+ * @example
269
+ * ```ts
270
+ * const engine = new FormEngine(schema);
271
+ * engine.setValue("email", "test@example.com");
272
+ * console.log(engine.values()); // { email: "test@example.com", … }
273
+ * console.log(engine.valid()); // true / false
274
+ * ```
275
+ */
276
+ class FormEngine {
277
+ schema;
278
+ /** All field states indexed by field ID. */
279
+ fieldMap = new Map();
280
+ /** Ordered group states. */
281
+ groups;
282
+ /** Live snapshot of all field values (including hidden fields). */
283
+ values;
284
+ /** Whether every visible field passes validation. */
285
+ valid;
286
+ /** Whether any field has been interacted with. */
287
+ touched;
288
+ /** Whether any field value differs from its default. */
289
+ dirty;
290
+ constructor(schema) {
291
+ this.schema = schema;
292
+ // 1. Build field & group states (populates fieldMap)
293
+ this.groups = schema.groups.map((g) => this.buildGroupState(g));
294
+ // 2. Reactive values — computed from individual field signals
295
+ this.values = computed(() => this.readFieldValues(), ...(ngDevMode ? [{ debugName: "values" }] : []));
296
+ // 3. Top-level computed signals
297
+ this.valid = computed(() => this.groups.filter((g) => g.visible()).every((g) => g.valid()), ...(ngDevMode ? [{ debugName: "valid" }] : []));
298
+ this.touched = computed(() => [...this.fieldMap.values()].some((f) => f.touched()), ...(ngDevMode ? [{ debugName: "touched" }] : []));
299
+ this.dirty = computed(() => [...this.fieldMap.values()].some((f) => f.dirty()), ...(ngDevMode ? [{ debugName: "dirty" }] : []));
300
+ }
301
+ // ── Public API ────────────────────────────────────────────────────
302
+ /**
303
+ * Get the {@link FieldState} for a field by ID.
304
+ * Throws if the field is not found.
305
+ */
306
+ getField(id) {
307
+ const state = this.fieldMap.get(id);
308
+ if (!state) {
309
+ throw new Error(`FormEngine: unknown field "${id}".`);
310
+ }
311
+ return state;
312
+ }
313
+ /** Set the value of a single field by ID. */
314
+ setValue(id, value) {
315
+ this.getField(id).value.set(value);
316
+ }
317
+ /** Mark a field as touched. */
318
+ markTouched(id) {
319
+ this.getField(id).touched.set(true);
320
+ }
321
+ /** Mark all fields as touched (e.g. on submit attempt). */
322
+ markAllTouched() {
323
+ for (const field of this.fieldMap.values()) {
324
+ field.touched.set(true);
325
+ }
326
+ }
327
+ /**
328
+ * Reset the form to its initial default values and clear
329
+ * touched/dirty state.
330
+ */
331
+ reset() {
332
+ for (const field of this.fieldMap.values()) {
333
+ const def = field.definition.defaultValue ??
334
+ this.defaultForComponent(field.definition.component);
335
+ field.value.set(def);
336
+ field.touched.set(false);
337
+ }
338
+ }
339
+ /**
340
+ * Produce the JSON output — a plain object containing only
341
+ * **visible** field values.
342
+ */
343
+ output() {
344
+ return computed(() => {
345
+ const result = {};
346
+ for (const group of this.groups) {
347
+ if (!group.visible())
348
+ continue;
349
+ for (const field of group.fields) {
350
+ if (!field.visible())
351
+ continue;
352
+ if (isFlairComponent(field.definition.component))
353
+ continue;
354
+ result[field.definition.id] = field.value();
355
+ }
356
+ }
357
+ return result;
358
+ });
359
+ }
360
+ // ── Private helpers ───────────────────────────────────────────────
361
+ buildGroupState(def) {
362
+ const fields = def.fields.map((f) => this.buildFieldState(f));
363
+ const visible = def.visibleWhen
364
+ ? computed(() => evaluateCondition(def.visibleWhen, this.readFieldValues()))
365
+ : computed(() => true);
366
+ const enabled = def.enabledWhen
367
+ ? computed(() => evaluateCondition(def.enabledWhen, this.readFieldValues()))
368
+ : computed(() => true);
369
+ const valid = computed(() => fields.filter((f) => f.visible()).every((f) => f.validation().valid), ...(ngDevMode ? [{ debugName: "valid" }] : []));
370
+ return { definition: def, fields, visible, enabled, valid };
371
+ }
372
+ buildFieldState(def) {
373
+ const defaultVal = def.defaultValue ?? this.defaultForComponent(def.component);
374
+ const value = signal(defaultVal, ...(ngDevMode ? [{ debugName: "value" }] : []));
375
+ const visible = def.visibleWhen
376
+ ? computed(() => evaluateCondition(def.visibleWhen, this.readFieldValues()))
377
+ : computed(() => true);
378
+ const enabled = def.enabledWhen
379
+ ? computed(() => evaluateCondition(def.enabledWhen, this.readFieldValues()))
380
+ : computed(() => true);
381
+ const validation = computed(() => !isFlairComponent(def.component) &&
382
+ def.validation &&
383
+ def.validation.length > 0
384
+ ? validate(def.validation, value())
385
+ : { valid: true, errors: [] }, ...(ngDevMode ? [{ debugName: "validation" }] : []));
386
+ const touched = signal(false, ...(ngDevMode ? [{ debugName: "touched" }] : []));
387
+ const dirty = computed(() => value() !== defaultVal, ...(ngDevMode ? [{ debugName: "dirty" }] : []));
388
+ const state = {
389
+ definition: def,
390
+ value,
391
+ visible,
392
+ enabled,
393
+ validation,
394
+ touched,
395
+ dirty,
396
+ };
397
+ this.fieldMap.set(def.id, state);
398
+ return state;
399
+ }
400
+ /**
401
+ * Read all current field values reactively. When called inside a
402
+ * `computed()`, it tracks every field's value signal so the
403
+ * computed re-evaluates whenever any field changes.
404
+ */
405
+ readFieldValues() {
406
+ const v = {};
407
+ for (const [id, field] of this.fieldMap) {
408
+ if (isFlairComponent(field.definition.component))
409
+ continue;
410
+ v[id] = field.value();
411
+ }
412
+ return v;
413
+ }
414
+ /**
415
+ * Produce a sensible default value for a given component type so
416
+ * fields without explicit `defaultValue` still have a typed zero.
417
+ */
418
+ defaultForComponent(component) {
419
+ switch (component) {
420
+ case "checkbox":
421
+ case "toggle":
422
+ return false;
423
+ case "slider":
424
+ return 0;
425
+ case "date":
426
+ case "time":
427
+ case "datetime":
428
+ case "color":
429
+ return null;
430
+ case "file":
431
+ return [];
432
+ case "autocomplete":
433
+ return [];
434
+ case "flair:richtext":
435
+ case "flair:image":
436
+ case "flair:media":
437
+ return null;
438
+ default:
439
+ return "";
440
+ }
441
+ }
442
+ }
443
+
444
+ // ── Field registry ───────────────────────────────────────────────────
445
+ /**
446
+ * Multi-provider token that collects field registrations from
447
+ * across the application.
448
+ */
449
+ const FORM_FIELD_REGISTRATIONS = new InjectionToken("FORM_FIELD_REGISTRATIONS", {
450
+ factory: () => new Map(),
451
+ });
452
+ /**
453
+ * Register one or more field types for use in form schemas.
454
+ *
455
+ * @example
456
+ * ```ts
457
+ * // app.config.ts
458
+ * import { provideFormFields } from '@theredhead/lucid-forms';
459
+ * import { UIInput, UISelect, UICheckbox } from '@theredhead/lucid-kit';
460
+ *
461
+ * export const appConfig = {
462
+ * providers: [
463
+ * provideFormFields({
464
+ * text: { component: UIInput, modelProperty: 'value' },
465
+ * select: { component: UISelect, modelProperty: 'value' },
466
+ * checkbox: { component: UICheckbox, modelProperty: 'checked' },
467
+ * }),
468
+ * ],
469
+ * };
470
+ * ```
471
+ */
472
+ function provideFormFields(fields) {
473
+ const map = new Map(Object.entries(fields));
474
+ return makeEnvironmentProviders([
475
+ {
476
+ provide: FORM_FIELD_REGISTRATIONS,
477
+ useValue: map,
478
+ },
479
+ ]);
480
+ }
481
+ /**
482
+ * Injectable service that resolves a component key (e.g. `"text"`)
483
+ * to a {@link FormFieldRegistration}.
484
+ *
485
+ * It merges all maps provided via `FORM_FIELD_REGISTRATIONS`.
486
+ */
487
+ class FormFieldRegistry {
488
+ maps = inject(FORM_FIELD_REGISTRATIONS, {
489
+ optional: true,
490
+ });
491
+ merged = null;
492
+ getMerged() {
493
+ if (this.merged)
494
+ return this.merged;
495
+ this.merged = new Map();
496
+ if (this.maps) {
497
+ for (const [key, reg] of this.maps) {
498
+ this.merged.set(key, reg);
499
+ }
500
+ }
501
+ return this.merged;
502
+ }
503
+ /** Resolve a component key to its registration, or `null`. */
504
+ resolve(key) {
505
+ return this.getMerged().get(key) ?? null;
506
+ }
507
+ /** All registered keys. */
508
+ keys() {
509
+ return [...this.getMerged().keys()];
510
+ }
511
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: FormFieldRegistry, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
512
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: FormFieldRegistry, providedIn: "root" });
513
+ }
514
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: FormFieldRegistry, decorators: [{
515
+ type: Injectable,
516
+ args: [{ providedIn: "root" }]
517
+ }] });
518
+
519
+ // ── Text adapter resolver ────────────────────────────────────────────
520
+ /**
521
+ * Factory map from adapter key strings (as stored in form schema
522
+ * JSON) to {@link TextAdapter} constructors.
523
+ *
524
+ * @internal
525
+ */
526
+ const ADAPTER_FACTORIES = {
527
+ email: () => new EmailTextAdapter(),
528
+ url: () => new UrlTextAdapter(),
529
+ ip: () => new IPAddressTextAdapter(),
530
+ phone: () => new PhoneTextAdapter(),
531
+ creditCard: () => new CreditCardTextAdapter(),
532
+ money: () => new MoneyTextAdapter(),
533
+ integer: () => new IntegerTextAdapter(),
534
+ float: () => new FloatTextAdapter(),
535
+ decimal: () => new DecimalTextAdapter(),
536
+ hexadecimal: () => new HexadecimalTextAdapter(),
537
+ percentage: () => new PercentageTextAdapter(),
538
+ date: () => new DateTextAdapter(),
539
+ time: () => new TimeTextAdapter(),
540
+ color: () => new ColorTextAdapter(),
541
+ slug: () => new SlugTextAdapter(),
542
+ uuid: () => new UuidTextAdapter(),
543
+ cron: () => new CronTextAdapter(),
544
+ uppercase: () => new UppercaseTextAdapter(),
545
+ };
546
+ /**
547
+ * All available text adapter keys, in display order.
548
+ */
549
+ const TEXT_ADAPTER_KEYS = Object.keys(ADAPTER_FACTORIES);
550
+ /**
551
+ * Resolve a text adapter key to a {@link TextAdapter} instance.
552
+ *
553
+ * Returns `undefined` for empty/unknown keys.
554
+ *
555
+ * @param key Adapter key (e.g. `"email"`, `"phone"`, `"money"`).
556
+ */
557
+ function resolveTextAdapter(key) {
558
+ if (typeof key !== "string" || !key) {
559
+ return undefined;
560
+ }
561
+ return ADAPTER_FACTORIES[key]?.();
562
+ }
563
+
564
+ // ── Built-in field registrations for @theredhead/lucid-kit ──────────────
565
+ /**
566
+ * Built-in field registrations that map common component keys
567
+ * to `@theredhead/lucid-kit` components.
568
+ *
569
+ * | Key | Component | Model property |
570
+ * |-----------------|---------------------|----------------|
571
+ * | `"text"` | `UIInput` | `value` |
572
+ * | `"select"` | `UIDropdownList` | `value` |
573
+ * | `"checkbox"` | `UICheckbox` | `checked` |
574
+ * | `"toggle"` | `UIToggle` | `value` |
575
+ * | `"radio"` | `UIRadioGroup` | `value` |
576
+ * | `"autocomplete"`| `UIAutocomplete` | `value` |
577
+ * | `"date"` | `UIInput` + `DateInputAdapter` | `value` |
578
+ * | `"time"` | `UIInput` + `TimeTextAdapter` | `value` |
579
+ * | `"datetime"` | `UIInput` + `DateInputAdapter` | `value` |
580
+ * | `"color"` | `UIColorPicker` | `value` |
581
+ * | `"slider"` | `UISlider` | `value` |
582
+ * | `"richtext"` | `UIRichTextEditor` | `value` |
583
+ * | `"file"` | `UIFileUpload` | `files` |
584
+ * | `"flair:richtext"` | `UIRichTextView` | `content` |
585
+ * | `"flair:image"` | `UIImage` | `src` |
586
+ * | `"flair:media"` | `UIMediaPlayer` | `source` |
587
+ */
588
+ const BUILT_IN_FIELDS = {
589
+ text: {
590
+ component: UIInput,
591
+ modelProperty: "value",
592
+ configTransforms: {
593
+ textAdapter: {
594
+ inputKey: "adapter",
595
+ transform: resolveTextAdapter,
596
+ },
597
+ },
598
+ },
599
+ select: { component: UIDropdownList, modelProperty: "value" },
600
+ checkbox: { component: UICheckbox, modelProperty: "checked" },
601
+ toggle: { component: UIToggle, modelProperty: "value" },
602
+ radio: { component: UIRadioGroup, modelProperty: "value" },
603
+ autocomplete: { component: UIAutocomplete, modelProperty: "value" },
604
+ date: {
605
+ component: UIInput,
606
+ modelProperty: "value",
607
+ defaultConfig: { adapter: new DateInputAdapter() },
608
+ },
609
+ time: {
610
+ component: UIInput,
611
+ modelProperty: "value",
612
+ defaultConfig: { adapter: new TimeTextAdapter() },
613
+ },
614
+ datetime: {
615
+ component: UIInput,
616
+ modelProperty: "value",
617
+ defaultConfig: { adapter: new DateInputAdapter() },
618
+ },
619
+ color: { component: UIColorPicker, modelProperty: "value" },
620
+ slider: {
621
+ component: UISlider,
622
+ modelProperty: "value",
623
+ defaultConfig: { showValue: true, showMinMax: true },
624
+ },
625
+ richtext: { component: UIRichTextEditor, modelProperty: "value" },
626
+ file: { component: UIFileUpload, modelProperty: "files" },
627
+ "flair:richtext": {
628
+ component: UIRichTextView,
629
+ modelProperty: "content",
630
+ },
631
+ "flair:image": { component: UIImage, modelProperty: "src" },
632
+ "flair:media": {
633
+ component: UIMediaPlayer,
634
+ modelProperty: "source",
635
+ defaultConfig: { controls: true },
636
+ },
637
+ };
638
+ /**
639
+ * Convenience provider that registers all built-in
640
+ * `@theredhead/lucid-kit` field types.
641
+ *
642
+ * @example
643
+ * ```ts
644
+ * import { provideBuiltInFormFields } from '@theredhead/lucid-forms';
645
+ *
646
+ * export const appConfig = {
647
+ * providers: [provideBuiltInFormFields()],
648
+ * };
649
+ * ```
650
+ */
651
+ function provideBuiltInFormFields() {
652
+ return provideFormFields(BUILT_IN_FIELDS);
653
+ }
654
+
655
+ // ── Export strategy types ────────────────────────────────────────────
656
+ // ── String utilities ────────────────────────────────────────────────
657
+ /**
658
+ * Convert a human-readable title to kebab-case.
659
+ *
660
+ * Falls back to `"generated-form"` when the input is empty or
661
+ * contains no usable characters.
662
+ *
663
+ * @internal
664
+ */
665
+ function toKebabCase(text) {
666
+ return (text
667
+ .trim()
668
+ .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
669
+ .replace(/[\s_]+/g, "-")
670
+ .replace(/[^a-z0-9-]/gi, "")
671
+ .toLowerCase()
672
+ .replace(/-+/g, "-")
673
+ .replace(/^-|-$/g, "") || "generated-form");
674
+ }
675
+ /**
676
+ * Convert a human-readable title to PascalCase.
677
+ *
678
+ * Falls back to `"GeneratedForm"` when the input is empty.
679
+ *
680
+ * @internal
681
+ */
682
+ function toPascalCase(text) {
683
+ return toKebabCase(text)
684
+ .split("-")
685
+ .map((s) => s.charAt(0).toUpperCase() + s.slice(1))
686
+ .join("");
687
+ }
688
+
689
+ // ── JSON export strategy ────────────────────────────────────────────
690
+ /**
691
+ * Exports a {@link FormSchema} as a formatted JSON file.
692
+ *
693
+ * The output is a standard JSON document — identical to
694
+ * `JSON.stringify(schema, null, 2)`.
695
+ */
696
+ class JsonExportStrategy {
697
+ label = "JSON Schema";
698
+ description = "Export the form schema as a JSON file.";
699
+ export(schema) {
700
+ const title = schema.title?.trim() || "form";
701
+ const fileName = toKebabCase(title) + ".schema.json";
702
+ return {
703
+ mimeType: "application/json",
704
+ fileName,
705
+ content: JSON.stringify(schema, null, 2) + "\n",
706
+ };
707
+ }
708
+ }
709
+
710
+ // ── Angular component export strategy ───────────────────────────────
711
+ const FIELD_META = {
712
+ text: {
713
+ className: "UIInput",
714
+ selector: "ui-input",
715
+ modelProp: "value",
716
+ tsType: "string",
717
+ defaultValue: "''",
718
+ },
719
+ select: {
720
+ className: "UIDropdownList",
721
+ selector: "ui-dropdown-list",
722
+ modelProp: "value",
723
+ tsType: "string",
724
+ defaultValue: "''",
725
+ },
726
+ checkbox: {
727
+ className: "UICheckbox",
728
+ selector: "ui-checkbox",
729
+ modelProp: "checked",
730
+ tsType: "boolean",
731
+ defaultValue: "false",
732
+ },
733
+ toggle: {
734
+ className: "UIToggle",
735
+ selector: "ui-toggle",
736
+ modelProp: "value",
737
+ tsType: "boolean",
738
+ defaultValue: "false",
739
+ },
740
+ radio: {
741
+ className: "UIRadioGroup",
742
+ selector: "ui-radio-group",
743
+ modelProp: "value",
744
+ tsType: "string",
745
+ defaultValue: "''",
746
+ },
747
+ autocomplete: {
748
+ className: "UIAutocomplete",
749
+ selector: "ui-autocomplete",
750
+ modelProp: "value",
751
+ tsType: "string",
752
+ defaultValue: "''",
753
+ },
754
+ date: {
755
+ className: "UIInput",
756
+ selector: "ui-input",
757
+ modelProp: "value",
758
+ tsType: "string",
759
+ defaultValue: "''",
760
+ },
761
+ time: {
762
+ className: "UIInput",
763
+ selector: "ui-input",
764
+ modelProp: "value",
765
+ tsType: "string",
766
+ defaultValue: "''",
767
+ },
768
+ datetime: {
769
+ className: "UIInput",
770
+ selector: "ui-input",
771
+ modelProp: "value",
772
+ tsType: "string",
773
+ defaultValue: "''",
774
+ },
775
+ color: {
776
+ className: "UIColorPicker",
777
+ selector: "ui-color-picker",
778
+ modelProp: "value",
779
+ tsType: "string",
780
+ defaultValue: "''",
781
+ },
782
+ slider: {
783
+ className: "UISlider",
784
+ selector: "ui-slider",
785
+ modelProp: "value",
786
+ tsType: "number",
787
+ defaultValue: "0",
788
+ },
789
+ richtext: {
790
+ className: "UIRichTextEditor",
791
+ selector: "ui-rich-text-editor",
792
+ modelProp: "value",
793
+ tsType: "string",
794
+ defaultValue: "''",
795
+ },
796
+ file: {
797
+ className: "UIFileUpload",
798
+ selector: "ui-file-upload",
799
+ modelProp: "files",
800
+ tsType: "File[]",
801
+ defaultValue: "[]",
802
+ },
803
+ };
804
+ /** Fallback for unknown component keys. */
805
+ const UNKNOWN_META = {
806
+ className: "UIInput",
807
+ selector: "ui-input",
808
+ modelProp: "value",
809
+ tsType: "string",
810
+ defaultValue: "''",
811
+ };
812
+ // ── Helpers ─────────────────────────────────────────────────────────
813
+ /** @internal Convert a field id to a safe camelCase identifier. */
814
+ function toCamelCase(text) {
815
+ const pascal = toPascalCase(text);
816
+ return pascal.charAt(0).toLowerCase() + pascal.slice(1);
817
+ }
818
+ /** @internal Resolve FieldMeta for a component key. */
819
+ function metaFor(componentKey) {
820
+ return FIELD_META[componentKey] ?? UNKNOWN_META;
821
+ }
822
+ /**
823
+ * @internal Render config entries as template attribute bindings.
824
+ *
825
+ * String values → `key="value"`, others → `[key]="value"`.
826
+ */
827
+ function configAttrs(config) {
828
+ if (!config)
829
+ return "";
830
+ return Object.entries(config)
831
+ .map(([k, v]) => typeof v === "string" ? `${k}="${v}"` : `[${k}]="${String(v)}"`)
832
+ .join(" ");
833
+ }
834
+ /**
835
+ * @internal Build the options property name for a field
836
+ * (e.g. `emailOptions`).
837
+ */
838
+ function optionsPropName(fieldId) {
839
+ return toCamelCase(fieldId) + "Options";
840
+ }
841
+ // ── Strategy ────────────────────────────────────────────────────────
842
+ /**
843
+ * Exports a {@link FormSchema} as a standalone Angular component
844
+ * with a fully declarative HTML template.
845
+ *
846
+ * The generated `.component.ts` file contains:
847
+ *
848
+ * - A typed `<Title>FormValues` interface with one property per field
849
+ * - Individual `signal()` fields for two-way binding
850
+ * - A `computed()` `formValues` signal that assembles the full typed object
851
+ * - A template that directly uses `@theredhead/lucid-kit` components
852
+ * (`<ui-input>`, `<ui-select>`, …) — no `FormEngine` or `<ui-form>`
853
+ *
854
+ * The component is almost entirely declarative: the TypeScript class
855
+ * is just signals and a single computed.
856
+ */
857
+ class AngularComponentExportStrategy {
858
+ label = "Angular Component";
859
+ description = "Standalone Angular component with declarative template.";
860
+ export(schema) {
861
+ const title = schema.title?.trim() || "Generated Form";
862
+ const kebab = toKebabCase(title);
863
+ const pascal = toPascalCase(title);
864
+ const className = pascal + "Component";
865
+ const interfaceName = pascal + "FormValues";
866
+ const selector = "app-" + kebab;
867
+ const fileName = kebab + ".component.ts";
868
+ const allFields = schema.groups.flatMap((g) => g.fields);
869
+ const lines = [
870
+ "// ── Generated by @theredhead/lucid-forms Designer ──────────────────────",
871
+ "",
872
+ ...this.buildImports(allFields),
873
+ "",
874
+ ...this.buildInterface(interfaceName, allFields),
875
+ "",
876
+ ...this.buildComponent(selector, className, interfaceName, allFields, schema.groups),
877
+ "",
878
+ ];
879
+ return {
880
+ mimeType: "text/typescript",
881
+ fileName,
882
+ content: lines.join("\n"),
883
+ };
884
+ }
885
+ // ── Code generators ─────────────────────────────────────────────
886
+ /** @internal Build the import statements. */
887
+ buildImports(fields) {
888
+ const angularImports = [
889
+ "ChangeDetectionStrategy",
890
+ "Component",
891
+ "computed",
892
+ "signal",
893
+ ];
894
+ const hasOutput = true; // always include formValues output
895
+ if (hasOutput)
896
+ angularImports.push("output");
897
+ const uiKitComponents = new Set();
898
+ for (const f of fields) {
899
+ uiKitComponents.add(metaFor(f.component).className);
900
+ }
901
+ const sorted = [...uiKitComponents].sort();
902
+ return [
903
+ `import { ${angularImports.join(", ")} } from '@angular/core';`,
904
+ `import { ${sorted.join(", ")} } from '@theredhead/lucid-kit';`,
905
+ ];
906
+ }
907
+ /** @internal Build the values interface. */
908
+ buildInterface(name, fields) {
909
+ const lines = [`export interface ${name} {`];
910
+ for (const f of fields) {
911
+ const meta = metaFor(f.component);
912
+ const prop = toCamelCase(f.id);
913
+ if (f.title) {
914
+ lines.push(` /** ${f.title} */`);
915
+ }
916
+ lines.push(` readonly ${prop}: ${meta.tsType};`);
917
+ }
918
+ lines.push("}");
919
+ return lines;
920
+ }
921
+ /** @internal Build the full @Component class. */
922
+ buildComponent(selector, className, interfaceName, fields, groups) {
923
+ const uiKitComponents = new Set();
924
+ for (const f of fields) {
925
+ uiKitComponents.add(metaFor(f.component).className);
926
+ }
927
+ const sorted = [...uiKitComponents].sort();
928
+ const template = this.buildTemplate(groups);
929
+ const classBody = this.buildClassBody(interfaceName, fields);
930
+ return [
931
+ "@Component({",
932
+ ` selector: '${selector}',`,
933
+ " standalone: true,",
934
+ ` imports: [${sorted.join(", ")}],`,
935
+ " changeDetection: ChangeDetectionStrategy.OnPush,",
936
+ " template: `",
937
+ ...template.map((l) => " " + l),
938
+ " `,",
939
+ "})",
940
+ `export class ${className} {`,
941
+ ...classBody.map((l) => (l ? " " + l : "")),
942
+ "}",
943
+ ];
944
+ }
945
+ /** @internal Build the HTML template with fieldsets and components. */
946
+ buildTemplate(groups) {
947
+ const lines = [];
948
+ for (const group of groups) {
949
+ if (group.title) {
950
+ lines.push("<fieldset>");
951
+ lines.push(` <legend>${group.title}</legend>`);
952
+ lines.push("");
953
+ }
954
+ for (const field of group.fields) {
955
+ const meta = metaFor(field.component);
956
+ const prop = toCamelCase(field.id);
957
+ const attrs = [];
958
+ // Two-way binding
959
+ attrs.push(`[(${meta.modelProp})]="${prop}"`);
960
+ // aria-label
961
+ if (field.title) {
962
+ attrs.push(`ariaLabel="${field.title}"`);
963
+ }
964
+ // Options (select, radio, autocomplete)
965
+ if (field.options && field.options.length > 0) {
966
+ attrs.push(`[options]="${optionsPropName(field.id)}"`);
967
+ }
968
+ // Config attributes
969
+ const cfgStr = configAttrs(field.config);
970
+ if (cfgStr) {
971
+ attrs.push(cfgStr);
972
+ }
973
+ const label = field.title || field.id;
974
+ const indent = group.title ? " " : "";
975
+ lines.push(`${indent}<label>`);
976
+ lines.push(`${indent} ${label}`);
977
+ lines.push(`${indent} <${meta.selector} ${attrs.join(" ")} />`);
978
+ lines.push(`${indent}</label>`);
979
+ lines.push("");
980
+ }
981
+ if (group.title) {
982
+ lines.push("</fieldset>");
983
+ lines.push("");
984
+ }
985
+ }
986
+ return lines;
987
+ }
988
+ /** @internal Build the class body: signals, options, computed. */
989
+ buildClassBody(interfaceName, fields) {
990
+ const lines = [];
991
+ // Signal fields
992
+ lines.push("// ── Field signals ──");
993
+ for (const f of fields) {
994
+ const meta = metaFor(f.component);
995
+ const prop = toCamelCase(f.id);
996
+ const defaultVal = f.defaultValue !== undefined
997
+ ? JSON.stringify(f.defaultValue)
998
+ : meta.defaultValue;
999
+ lines.push(`public readonly ${prop} = signal<${meta.tsType}>(${defaultVal});`);
1000
+ }
1001
+ // Options properties
1002
+ const fieldsWithOptions = fields.filter((f) => f.options && f.options.length > 0);
1003
+ if (fieldsWithOptions.length > 0) {
1004
+ lines.push("");
1005
+ lines.push("// ── Options ──");
1006
+ for (const f of fieldsWithOptions) {
1007
+ const name = optionsPropName(f.id);
1008
+ const json = JSON.stringify(f.options.map((o) => ({ label: o.label, value: o.value })), null, 2);
1009
+ // Indent the JSON nicely
1010
+ const indented = json.replace(/\n/g, "\n ");
1011
+ lines.push(`protected readonly ${name} = ${indented} as const;`);
1012
+ }
1013
+ }
1014
+ // Computed form values
1015
+ lines.push("");
1016
+ lines.push("// ── Assembled form values ──");
1017
+ lines.push(`public readonly formValues = computed<${interfaceName}>(() => ({`);
1018
+ for (const f of fields) {
1019
+ const prop = toCamelCase(f.id);
1020
+ lines.push(` ${prop}: this.${prop}(),`);
1021
+ }
1022
+ lines.push("}));");
1023
+ return lines;
1024
+ }
1025
+ }
1026
+
1027
+ // ── Export barrel ────────────────────────────────────────────────────
1028
+
1029
+ // ── UIFormField ──────────────────────────────────────────────────────
1030
+ /**
1031
+ * Renders a single form field by dynamically creating the component
1032
+ * registered for the field's `component` key and wiring up two-way
1033
+ * value binding, config inputs, and validation display.
1034
+ *
1035
+ * This component is used internally by {@link UIFormGroup} and
1036
+ * {@link UIForm}. It can also be used standalone for custom layouts.
1037
+ *
1038
+ * @example
1039
+ * ```html
1040
+ * <ui-form-field [state]="fieldState" />
1041
+ * ```
1042
+ */
1043
+ class UIFormField {
1044
+ /** The field state managed by the {@link FormEngine}. */
1045
+ state = input.required(...(ngDevMode ? [{ debugName: "state" }] : []));
1046
+ registry = inject(FormFieldRegistry);
1047
+ log = inject(LoggerFactory).createLogger("UIFormField");
1048
+ outlet = viewChild("outlet", { ...(ngDevMode ? { debugName: "outlet" } : {}), read: ViewContainerRef });
1049
+ /** @internal Show errors only when the field has been touched. */
1050
+ showErrors = computed(() => this.state().touched() && !this.state().validation().valid, ...(ngDevMode ? [{ debugName: "showErrors" }] : []));
1051
+ /** @internal Whether this field is a flair (non-data) component. */
1052
+ isFlair = computed(() => isFlairComponent(this.state().definition.component), ...(ngDevMode ? [{ debugName: "isFlair" }] : []));
1053
+ /** @internal Whether the field has a `required` validation rule. */
1054
+ isRequired = computed(() => (this.state().definition.validation ?? []).some((r) => r.type === "required"), ...(ngDevMode ? [{ debugName: "isRequired" }] : []));
1055
+ constructor() {
1056
+ // Create the component once the view is ready
1057
+ effect(() => {
1058
+ const fieldState = this.state();
1059
+ const vcr = this.outlet();
1060
+ if (!vcr)
1061
+ return;
1062
+ vcr.clear();
1063
+ const reg = this.registry.resolve(fieldState.definition.component);
1064
+ if (!reg) {
1065
+ this.log.warn(`No component registered for "${fieldState.definition.component}"`, [fieldState.definition.id]);
1066
+ return;
1067
+ }
1068
+ const ref = vcr.createComponent(reg.component);
1069
+ // Wire the model property (two-way).
1070
+ // Use untracked() so the effect does NOT track fieldState.value();
1071
+ // otherwise every keystroke would re-run the effect and recreate
1072
+ // the component, destroying focus.
1073
+ ref.setInput(reg.modelProperty, untracked(() => fieldState.value()));
1074
+ // Subscribe to model changes.
1075
+ // Angular model() signals expose .subscribe() on the signal
1076
+ // itself (not on a separate *Change property).
1077
+ const modelProp = ref.instance[reg.modelProperty];
1078
+ if (modelProp &&
1079
+ typeof modelProp["subscribe"] ===
1080
+ "function") {
1081
+ // model() signal — subscribe directly
1082
+ modelProp.subscribe((v) => {
1083
+ fieldState.value.set(v);
1084
+ fieldState.touched.set(true);
1085
+ });
1086
+ }
1087
+ else {
1088
+ // Fallback: EventEmitter / output() — look for <prop>Change
1089
+ const changeOutput = ref.instance[reg.modelProperty + "Change"];
1090
+ if (changeOutput &&
1091
+ typeof changeOutput["subscribe"] ===
1092
+ "function") {
1093
+ changeOutput.subscribe((v) => {
1094
+ fieldState.value.set(v);
1095
+ fieldState.touched.set(true);
1096
+ });
1097
+ }
1098
+ }
1099
+ // Apply config inputs (defaultConfig merged with field config)
1100
+ const config = {
1101
+ ...(reg.defaultConfig ?? {}),
1102
+ ...(fieldState.definition.config ?? {}),
1103
+ };
1104
+ for (const [key, val] of Object.entries(config)) {
1105
+ const transform = reg.configTransforms?.[key];
1106
+ if (transform) {
1107
+ try {
1108
+ ref.setInput(transform.inputKey, transform.transform(val));
1109
+ }
1110
+ catch {
1111
+ this.log.debug(`Input "${transform.inputKey}" not found on component`, [fieldState.definition.component]);
1112
+ }
1113
+ }
1114
+ else {
1115
+ try {
1116
+ ref.setInput(key, val);
1117
+ }
1118
+ catch {
1119
+ this.log.debug(`Input "${key}" not found on component`, [
1120
+ fieldState.definition.component,
1121
+ ]);
1122
+ }
1123
+ }
1124
+ }
1125
+ // Forward options for select/radio/autocomplete
1126
+ if (fieldState.definition.options) {
1127
+ try {
1128
+ ref.setInput("options", fieldState.definition.options);
1129
+ }
1130
+ catch {
1131
+ // Component may not have an options input
1132
+ }
1133
+ }
1134
+ // Disabled state (untracked — don't recreate on toggle)
1135
+ if (!untracked(() => fieldState.enabled())) {
1136
+ try {
1137
+ ref.setInput("disabled", true);
1138
+ }
1139
+ catch {
1140
+ // Not all components have a disabled input
1141
+ }
1142
+ }
1143
+ // Set aria label from title
1144
+ try {
1145
+ ref.setInput("ariaLabel", fieldState.definition.title);
1146
+ }
1147
+ catch {
1148
+ // Not all components have ariaLabel
1149
+ }
1150
+ });
1151
+ }
1152
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: UIFormField, deps: [], target: i0.ɵɵFactoryTarget.Component });
1153
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.0", type: UIFormField, isStandalone: true, selector: "ui-form-field", inputs: { state: { classPropertyName: "state", publicName: "state", isSignal: true, isRequired: true, transformFunction: null } }, host: { properties: { "class.invalid": "showErrors()", "class.disabled": "!state().enabled()", "class.hidden": "!state().visible()" }, classAttribute: "ui-form-field" }, viewQueries: [{ propertyName: "outlet", first: true, predicate: ["outlet"], descendants: true, read: ViewContainerRef, isSignal: true }], hostDirectives: [{ directive: i1.UISurface, inputs: ["surfaceType", "surfaceType"] }], ngImport: i0, template: "@if (state().visible() && !isFlair()) {\n <label class=\"label\" [attr.for]=\"state().definition.id\">\n {{ state().definition.title }}\n @if (isRequired()) {\n <span class=\"required\" aria-hidden=\"true\">*</span>\n }\n </label>\n\n @if (state().definition.description) {\n <p class=\"description\">{{ state().definition.description }}</p>\n }\n}\n\n<div class=\"control\" [class.control--hidden]=\"!state().visible()\">\n <ng-container #outlet />\n</div>\n\n@if (state().visible() && !isFlair() && showErrors()) {\n <ul class=\"errors\" role=\"alert\">\n @for (error of state().validation().errors; track error.type) {\n <li class=\"error\">{{ error.message }}</li>\n }\n </ul>\n}\n", styles: [":host{display:block}:host(.hidden){display:none}.label{display:block;font-weight:600;font-size:.875rem;margin-bottom:4px}.required{margin-left:2px}.description{font-size:.8125rem;opacity:.65;margin:0 0 4px;line-height:1.4}.control{display:grid;width:100%;justify-items:stretch;margin-bottom:2px}.control--hidden{display:none}.errors{list-style:none;margin:2px 0 0;padding:0}.error{font-size:.8125rem;line-height:1.4}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1154
+ }
1155
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: UIFormField, decorators: [{
1156
+ type: Component,
1157
+ args: [{ selector: "ui-form-field", standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, hostDirectives: [{ directive: UISurface, inputs: ["surfaceType"] }], host: {
1158
+ class: "ui-form-field",
1159
+ "[class.invalid]": "showErrors()",
1160
+ "[class.disabled]": "!state().enabled()",
1161
+ "[class.hidden]": "!state().visible()",
1162
+ }, template: "@if (state().visible() && !isFlair()) {\n <label class=\"label\" [attr.for]=\"state().definition.id\">\n {{ state().definition.title }}\n @if (isRequired()) {\n <span class=\"required\" aria-hidden=\"true\">*</span>\n }\n </label>\n\n @if (state().definition.description) {\n <p class=\"description\">{{ state().definition.description }}</p>\n }\n}\n\n<div class=\"control\" [class.control--hidden]=\"!state().visible()\">\n <ng-container #outlet />\n</div>\n\n@if (state().visible() && !isFlair() && showErrors()) {\n <ul class=\"errors\" role=\"alert\">\n @for (error of state().validation().errors; track error.type) {\n <li class=\"error\">{{ error.message }}</li>\n }\n </ul>\n}\n", styles: [":host{display:block}:host(.hidden){display:none}.label{display:block;font-weight:600;font-size:.875rem;margin-bottom:4px}.required{margin-left:2px}.description{font-size:.8125rem;opacity:.65;margin:0 0 4px;line-height:1.4}.control{display:grid;width:100%;justify-items:stretch;margin-bottom:2px}.control--hidden{display:none}.errors{list-style:none;margin:2px 0 0;padding:0}.error{font-size:.8125rem;line-height:1.4}\n"] }]
1163
+ }], ctorParameters: () => [], propDecorators: { state: [{ type: i0.Input, args: [{ isSignal: true, alias: "state", required: true }] }], outlet: [{ type: i0.ViewChild, args: ["outlet", { ...{
1164
+ read: ViewContainerRef,
1165
+ }, isSignal: true }] }] } });
1166
+
1167
+ // ── UIFormGroup ──────────────────────────────────────────────────────
1168
+ /**
1169
+ * Renders a group of form fields as a visual section (fieldset).
1170
+ *
1171
+ * In sequential (non-wizard) mode every group is displayed
1172
+ * vertically. In wizard mode, {@link UIFormWizard} controls which
1173
+ * group is visible.
1174
+ *
1175
+ * @example
1176
+ * ```html
1177
+ * <ui-form-group [state]="groupState" />
1178
+ * ```
1179
+ */
1180
+ class UIFormGroup {
1181
+ /** The group state managed by the {@link FormEngine}. */
1182
+ state = input.required(...(ngDevMode ? [{ debugName: "state" }] : []));
1183
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: UIFormGroup, deps: [], target: i0.ɵɵFactoryTarget.Component });
1184
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.0", type: UIFormGroup, isStandalone: true, selector: "ui-form-group", inputs: { state: { classPropertyName: "state", publicName: "state", isSignal: true, isRequired: true, transformFunction: null } }, host: { properties: { "class.hidden": "!state().visible()" }, classAttribute: "ui-form-group" }, hostDirectives: [{ directive: i1.UISurface, inputs: ["surfaceType", "surfaceType"] }], ngImport: i0, template: "@if (state().visible()) {\n @if (state().definition.title) {\n <h3 class=\"title\">{{ state().definition.title }}</h3>\n }\n @if (state().definition.description) {\n <p class=\"description\">{{ state().definition.description }}</p>\n }\n <div class=\"fields\">\n @for (field of state().fields; track field.definition.id) {\n <ui-form-field [state]=\"field\" />\n }\n </div>\n}\n", styles: [":host{display:block}:host(.hidden){display:none}.title{font-size:1.125rem;font-weight:600;margin:0 0 4px}.description{font-size:.875rem;opacity:.65;margin:0 0 12px;line-height:1.4}.fields{display:flex;flex-direction:column;gap:16px}\n"], dependencies: [{ kind: "component", type: UIFormField, selector: "ui-form-field", inputs: ["state"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1185
+ }
1186
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: UIFormGroup, decorators: [{
1187
+ type: Component,
1188
+ args: [{ selector: "ui-form-group", standalone: true, imports: [UIFormField], changeDetection: ChangeDetectionStrategy.OnPush, hostDirectives: [{ directive: UISurface, inputs: ["surfaceType"] }], host: {
1189
+ class: "ui-form-group",
1190
+ "[class.hidden]": "!state().visible()",
1191
+ }, template: "@if (state().visible()) {\n @if (state().definition.title) {\n <h3 class=\"title\">{{ state().definition.title }}</h3>\n }\n @if (state().definition.description) {\n <p class=\"description\">{{ state().definition.description }}</p>\n }\n <div class=\"fields\">\n @for (field of state().fields; track field.definition.id) {\n <ui-form-field [state]=\"field\" />\n }\n </div>\n}\n", styles: [":host{display:block}:host(.hidden){display:none}.title{font-size:1.125rem;font-weight:600;margin:0 0 4px}.description{font-size:.875rem;opacity:.65;margin:0 0 12px;line-height:1.4}.fields{display:flex;flex-direction:column;gap:16px}\n"] }]
1192
+ }], propDecorators: { state: [{ type: i0.Input, args: [{ isSignal: true, alias: "state", required: true }] }] } });
1193
+
1194
+ // ── FormSettings ─────────────────────────────────────────────────────
1195
+ /**
1196
+ * DI token for {@link FormSettings}.
1197
+ *
1198
+ * Provided by `UIForm` and `UIFormWizard` so that descendant
1199
+ * components can look up form-level settings without requiring
1200
+ * explicit input threading.
1201
+ */
1202
+ const FORM_SETTINGS = new InjectionToken("FORM_SETTINGS");
1203
+
1204
+ // ── UIForm ───────────────────────────────────────────────────────────
1205
+ /**
1206
+ * Top-level form component that renders all groups sequentially.
1207
+ *
1208
+ * Takes a {@link FormEngine} instance and displays every group's
1209
+ * fields in order, with validation and conditional visibility.
1210
+ *
1211
+ * @example
1212
+ * ```html
1213
+ * <ui-form [engine]="engine" (formSubmit)="onSubmit($event)" />
1214
+ * ```
1215
+ */
1216
+ class UIForm {
1217
+ /** The form engine instance that drives this form. */
1218
+ engine = input.required(...(ngDevMode ? [{ debugName: "engine" }] : []));
1219
+ /** Label for the submit button. Defaults to `"Submit"`. */
1220
+ submitLabel = input("Submit", ...(ngDevMode ? [{ debugName: "submitLabel" }] : []));
1221
+ /** Whether to show the built-in submit button. Defaults to `true`. */
1222
+ showSubmit = input(true, ...(ngDevMode ? [{ debugName: "showSubmit" }] : []));
1223
+ /** Minimum width (in pixels) for form field controls. Defaults to `200`. */
1224
+ fieldMinWidth = input(450, ...(ngDevMode ? [{ debugName: "fieldMinWidth" }] : []));
1225
+ /** Emitted when the submit button is clicked and the form is valid. */
1226
+ formSubmit = output();
1227
+ /** @internal */
1228
+ isValid = computed(() => this.engine().valid(), ...(ngDevMode ? [{ debugName: "isValid" }] : []));
1229
+ /** @internal — collects all invalid visible fields for the summary. */
1230
+ validationSummary = computed(() => {
1231
+ const summary = [];
1232
+ for (const group of this.engine().groups) {
1233
+ if (!group.visible())
1234
+ continue;
1235
+ for (const field of group.fields) {
1236
+ if (!field.visible())
1237
+ continue;
1238
+ const v = field.validation();
1239
+ if (!v.valid) {
1240
+ summary.push({
1241
+ fieldId: field.definition.id,
1242
+ title: field.definition.title || field.definition.id,
1243
+ errors: v.errors.map((e) => e.message),
1244
+ });
1245
+ }
1246
+ }
1247
+ }
1248
+ return summary;
1249
+ }, ...(ngDevMode ? [{ debugName: "validationSummary" }] : []));
1250
+ /** @internal */
1251
+ onSubmit() {
1252
+ const eng = this.engine();
1253
+ eng.markAllTouched();
1254
+ if (eng.valid()) {
1255
+ this.formSubmit.emit(eng.output()());
1256
+ }
1257
+ }
1258
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: UIForm, deps: [], target: i0.ɵɵFactoryTarget.Component });
1259
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.0", type: UIForm, isStandalone: true, selector: "ui-form", inputs: { engine: { classPropertyName: "engine", publicName: "engine", isSignal: true, isRequired: true, transformFunction: null }, submitLabel: { classPropertyName: "submitLabel", publicName: "submitLabel", isSignal: true, isRequired: false, transformFunction: null }, showSubmit: { classPropertyName: "showSubmit", publicName: "showSubmit", isSignal: true, isRequired: false, transformFunction: null }, fieldMinWidth: { classPropertyName: "fieldMinWidth", publicName: "fieldMinWidth", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { formSubmit: "formSubmit" }, host: { classAttribute: "ui-form" }, providers: [
1260
+ {
1261
+ provide: FORM_SETTINGS,
1262
+ useExisting: forwardRef(() => UIForm),
1263
+ },
1264
+ ], hostDirectives: [{ directive: i1.UISurface, inputs: ["surfaceType", "surfaceType"] }], ngImport: i0, template: "@if (engine().schema.title) {\n <h2 class=\"title\">{{ engine().schema.title }}</h2>\n}\n@if (engine().schema.description) {\n <p class=\"description\">{{ engine().schema.description }}</p>\n}\n\n@for (group of engine().groups; track group.definition.id) {\n <ui-form-group [state]=\"group\" />\n}\n\n@if (showSubmit()) {\n @if (!isValid() && validationSummary().length) {\n <div class=\"validation-summary\" role=\"alert\">\n <strong>Please fix the following issues:</strong>\n <ul>\n @for (item of validationSummary(); track item.fieldId) {\n <li>\n <strong>{{ item.title }}:</strong>\n {{ item.errors[0] }}\n </li>\n }\n </ul>\n </div>\n }\n <div class=\"actions\">\n <button\n type=\"button\"\n class=\"submit\"\n [disabled]=\"!engine().valid()\"\n (click)=\"onSubmit()\"\n >\n {{ submitLabel() }}\n </button>\n </div>\n}\n", styles: [":host{display:block}.title{font-size:1.375rem;font-weight:700;margin:0 0 4px}.description{font-size:.9375rem;opacity:.65;margin:0 0 20px;line-height:1.4}ui-form-group{margin-bottom:24px}.actions{display:flex;justify-content:flex-end;padding-top:16px}.validation-summary{border-radius:8px;padding:12px 16px;margin-bottom:16px;font-size:.8125rem;line-height:1.5}.validation-summary strong{font-weight:600}.validation-summary ul{margin:6px 0 0;padding-left:20px}.validation-summary li{margin-bottom:2px}.submit{appearance:none;border:none;border-radius:8px;padding:10px 24px;font-size:.9375rem;font-weight:600;cursor:pointer;transition:opacity .15s ease}.submit:hover:not(:disabled){opacity:.88}.submit:disabled{opacity:.45;cursor:default}.submit:focus-visible{outline-offset:2px}\n"], dependencies: [{ kind: "component", type: UIFormGroup, selector: "ui-form-group", inputs: ["state"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1265
+ }
1266
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: UIForm, decorators: [{
1267
+ type: Component,
1268
+ args: [{ selector: "ui-form", standalone: true, imports: [UIFormGroup], changeDetection: ChangeDetectionStrategy.OnPush, hostDirectives: [{ directive: UISurface, inputs: ['surfaceType'] }], host: {
1269
+ class: "ui-form",
1270
+ }, providers: [
1271
+ {
1272
+ provide: FORM_SETTINGS,
1273
+ useExisting: forwardRef(() => UIForm),
1274
+ },
1275
+ ], template: "@if (engine().schema.title) {\n <h2 class=\"title\">{{ engine().schema.title }}</h2>\n}\n@if (engine().schema.description) {\n <p class=\"description\">{{ engine().schema.description }}</p>\n}\n\n@for (group of engine().groups; track group.definition.id) {\n <ui-form-group [state]=\"group\" />\n}\n\n@if (showSubmit()) {\n @if (!isValid() && validationSummary().length) {\n <div class=\"validation-summary\" role=\"alert\">\n <strong>Please fix the following issues:</strong>\n <ul>\n @for (item of validationSummary(); track item.fieldId) {\n <li>\n <strong>{{ item.title }}:</strong>\n {{ item.errors[0] }}\n </li>\n }\n </ul>\n </div>\n }\n <div class=\"actions\">\n <button\n type=\"button\"\n class=\"submit\"\n [disabled]=\"!engine().valid()\"\n (click)=\"onSubmit()\"\n >\n {{ submitLabel() }}\n </button>\n </div>\n}\n", styles: [":host{display:block}.title{font-size:1.375rem;font-weight:700;margin:0 0 4px}.description{font-size:.9375rem;opacity:.65;margin:0 0 20px;line-height:1.4}ui-form-group{margin-bottom:24px}.actions{display:flex;justify-content:flex-end;padding-top:16px}.validation-summary{border-radius:8px;padding:12px 16px;margin-bottom:16px;font-size:.8125rem;line-height:1.5}.validation-summary strong{font-weight:600}.validation-summary ul{margin:6px 0 0;padding-left:20px}.validation-summary li{margin-bottom:2px}.submit{appearance:none;border:none;border-radius:8px;padding:10px 24px;font-size:.9375rem;font-weight:600;cursor:pointer;transition:opacity .15s ease}.submit:hover:not(:disabled){opacity:.88}.submit:disabled{opacity:.45;cursor:default}.submit:focus-visible{outline-offset:2px}\n"] }]
1276
+ }], propDecorators: { engine: [{ type: i0.Input, args: [{ isSignal: true, alias: "engine", required: true }] }], submitLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "submitLabel", required: false }] }], showSubmit: [{ type: i0.Input, args: [{ isSignal: true, alias: "showSubmit", required: false }] }], fieldMinWidth: [{ type: i0.Input, args: [{ isSignal: true, alias: "fieldMinWidth", required: false }] }], formSubmit: [{ type: i0.Output, args: ["formSubmit"] }] } });
1277
+
1278
+ // ── UIFormWizard ─────────────────────────────────────────────────────
1279
+ /**
1280
+ * Renders a {@link FormEngine}'s groups as wizard steps — one group
1281
+ * at a time with previous / next / submit navigation.
1282
+ *
1283
+ * Only visible groups are included as steps. The wizard validates
1284
+ * the current step before allowing navigation to the next.
1285
+ *
1286
+ * @example
1287
+ * ```html
1288
+ * <ui-form-wizard
1289
+ * [engine]="engine"
1290
+ * (formSubmit)="onSubmit($event)"
1291
+ * />
1292
+ * ```
1293
+ */
1294
+ class UIFormWizard {
1295
+ /** The form engine instance that drives this wizard. */
1296
+ engine = input.required(...(ngDevMode ? [{ debugName: "engine" }] : []));
1297
+ /** Label for the "Next" button. */
1298
+ nextLabel = input("Next", ...(ngDevMode ? [{ debugName: "nextLabel" }] : []));
1299
+ /** Label for the "Previous" button. */
1300
+ prevLabel = input("Previous", ...(ngDevMode ? [{ debugName: "prevLabel" }] : []));
1301
+ /** Label for the "Submit" button (last step). */
1302
+ submitLabel = input("Submit", ...(ngDevMode ? [{ debugName: "submitLabel" }] : []));
1303
+ /** Minimum width (in pixels) for form field controls. Defaults to `200`. */
1304
+ fieldMinWidth = input(450, ...(ngDevMode ? [{ debugName: "fieldMinWidth" }] : []));
1305
+ /** Emitted when the form is submitted (last step, valid). */
1306
+ formSubmit = output();
1307
+ /** Current step index. */
1308
+ currentIndex = signal(0, ...(ngDevMode ? [{ debugName: "currentIndex" }] : []));
1309
+ /** Only visible groups are wizard steps. */
1310
+ visibleGroups = computed(() => this.engine().groups.filter((g) => g.visible()), ...(ngDevMode ? [{ debugName: "visibleGroups" }] : []));
1311
+ /** The currently displayed group state. */
1312
+ currentGroup = computed(() => {
1313
+ const groups = this.visibleGroups();
1314
+ return groups[this.currentIndex()] ?? null;
1315
+ }, ...(ngDevMode ? [{ debugName: "currentGroup" }] : []));
1316
+ /** Whether the current step is the last one. */
1317
+ isLastStep = computed(() => this.currentIndex() === this.visibleGroups().length - 1, ...(ngDevMode ? [{ debugName: "isLastStep" }] : []));
1318
+ /** Whether the current step's fields are all valid. */
1319
+ currentStepValid = computed(() => {
1320
+ const group = this.currentGroup();
1321
+ return group ? group.valid() : false;
1322
+ }, ...(ngDevMode ? [{ debugName: "currentStepValid" }] : []));
1323
+ /** Navigate to the next step (marks current fields touched). */
1324
+ next() {
1325
+ const group = this.currentGroup();
1326
+ if (group) {
1327
+ for (const field of group.fields) {
1328
+ field.touched.set(true);
1329
+ }
1330
+ }
1331
+ if (this.currentStepValid() && !this.isLastStep()) {
1332
+ this.currentIndex.update((i) => i + 1);
1333
+ }
1334
+ }
1335
+ /** Navigate to the previous step. */
1336
+ prev() {
1337
+ if (this.currentIndex() > 0) {
1338
+ this.currentIndex.update((i) => i - 1);
1339
+ }
1340
+ }
1341
+ /** Jump to a specific step. */
1342
+ goTo(index) {
1343
+ if (index >= 0 && index <= this.currentIndex()) {
1344
+ this.currentIndex.set(index);
1345
+ }
1346
+ }
1347
+ /** @internal Submit handler. */
1348
+ onSubmit() {
1349
+ const eng = this.engine();
1350
+ eng.markAllTouched();
1351
+ if (eng.valid()) {
1352
+ this.formSubmit.emit(eng.output()());
1353
+ }
1354
+ }
1355
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: UIFormWizard, deps: [], target: i0.ɵɵFactoryTarget.Component });
1356
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.0", type: UIFormWizard, isStandalone: true, selector: "ui-form-wizard", inputs: { engine: { classPropertyName: "engine", publicName: "engine", isSignal: true, isRequired: true, transformFunction: null }, nextLabel: { classPropertyName: "nextLabel", publicName: "nextLabel", isSignal: true, isRequired: false, transformFunction: null }, prevLabel: { classPropertyName: "prevLabel", publicName: "prevLabel", isSignal: true, isRequired: false, transformFunction: null }, submitLabel: { classPropertyName: "submitLabel", publicName: "submitLabel", isSignal: true, isRequired: false, transformFunction: null }, fieldMinWidth: { classPropertyName: "fieldMinWidth", publicName: "fieldMinWidth", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { formSubmit: "formSubmit" }, host: { classAttribute: "ui-form-wizard" }, providers: [
1357
+ {
1358
+ provide: FORM_SETTINGS,
1359
+ useExisting: forwardRef(() => UIFormWizard),
1360
+ },
1361
+ ], hostDirectives: [{ directive: i1.UISurface, inputs: ["surfaceType", "surfaceType"] }], ngImport: i0, template: "@if (engine().schema.title) {\n <h2 class=\"title\">{{ engine().schema.title }}</h2>\n}\n@if (engine().schema.description) {\n <p class=\"description\">{{ engine().schema.description }}</p>\n}\n\n<!-- Step indicator -->\n<nav class=\"steps\" aria-label=\"Form steps\">\n @for (step of visibleGroups(); track step.definition.id; let i = $index) {\n <button\n type=\"button\"\n class=\"step\"\n [class.step--active]=\"i === currentIndex()\"\n [class.step--completed]=\"i < currentIndex()\"\n [disabled]=\"i > currentIndex()\"\n (click)=\"goTo(i)\"\n [attr.aria-current]=\"i === currentIndex() ? 'step' : null\"\n >\n <span class=\"step-number\">{{ i + 1 }}</span>\n @if (step.definition.title) {\n <span class=\"step-label\">{{ step.definition.title }}</span>\n }\n </button>\n }\n</nav>\n\n<!-- Current group -->\n@if (currentGroup(); as group) {\n <ui-form-group [state]=\"group\" />\n}\n\n<!-- Navigation -->\n<div class=\"actions\">\n @if (currentIndex() > 0) {\n <button type=\"button\" class=\"btn btn--prev\" (click)=\"prev()\">\n {{ prevLabel() }}\n </button>\n }\n\n <div class=\"spacer\"></div>\n\n @if (isLastStep()) {\n <button\n type=\"button\"\n class=\"btn btn--submit\"\n [disabled]=\"!engine().valid()\"\n (click)=\"onSubmit()\"\n >\n {{ submitLabel() }}\n </button>\n } @else {\n <button\n type=\"button\"\n class=\"btn btn--next\"\n [disabled]=\"!currentStepValid()\"\n (click)=\"next()\"\n >\n {{ nextLabel() }}\n </button>\n }\n</div>\n", styles: [":host{display:block}.title{font-size:1.375rem;font-weight:700;margin:0 0 4px}.description{font-size:.9375rem;opacity:.65;margin:0 0 16px;line-height:1.4}.steps{display:flex;gap:4px;margin-bottom:24px;padding:0}.step{appearance:none;border:none;padding:8px 16px;border-radius:8px;font-size:.8125rem;font-weight:500;cursor:pointer;display:flex;align-items:center;gap:6px;opacity:.55;transition:opacity .15s ease,background .15s ease}.step:disabled{cursor:default}.step--active{opacity:1}.step--completed{opacity:.8}.step-number{display:inline-flex;align-items:center;justify-content:center;width:22px;height:22px;border-radius:50%;font-size:.75rem;font-weight:700}.step-label{white-space:nowrap}.actions{display:flex;align-items:center;padding-top:16px;margin-top:24px}.spacer{flex:1}.btn{appearance:none;border:none;border-radius:8px;padding:10px 24px;font-size:.9375rem;font-weight:600;cursor:pointer;transition:opacity .15s ease}.btn:disabled{opacity:.45;cursor:default}.btn:focus-visible{outline-offset:2px}.btn--next:hover:not(:disabled),.btn--submit:hover:not(:disabled){opacity:.88}\n"], dependencies: [{ kind: "component", type: UIFormGroup, selector: "ui-form-group", inputs: ["state"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1362
+ }
1363
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: UIFormWizard, decorators: [{
1364
+ type: Component,
1365
+ args: [{ selector: "ui-form-wizard", standalone: true, imports: [UIFormGroup], changeDetection: ChangeDetectionStrategy.OnPush, hostDirectives: [{ directive: UISurface, inputs: ['surfaceType'] }], host: {
1366
+ class: "ui-form-wizard",
1367
+ }, providers: [
1368
+ {
1369
+ provide: FORM_SETTINGS,
1370
+ useExisting: forwardRef(() => UIFormWizard),
1371
+ },
1372
+ ], template: "@if (engine().schema.title) {\n <h2 class=\"title\">{{ engine().schema.title }}</h2>\n}\n@if (engine().schema.description) {\n <p class=\"description\">{{ engine().schema.description }}</p>\n}\n\n<!-- Step indicator -->\n<nav class=\"steps\" aria-label=\"Form steps\">\n @for (step of visibleGroups(); track step.definition.id; let i = $index) {\n <button\n type=\"button\"\n class=\"step\"\n [class.step--active]=\"i === currentIndex()\"\n [class.step--completed]=\"i < currentIndex()\"\n [disabled]=\"i > currentIndex()\"\n (click)=\"goTo(i)\"\n [attr.aria-current]=\"i === currentIndex() ? 'step' : null\"\n >\n <span class=\"step-number\">{{ i + 1 }}</span>\n @if (step.definition.title) {\n <span class=\"step-label\">{{ step.definition.title }}</span>\n }\n </button>\n }\n</nav>\n\n<!-- Current group -->\n@if (currentGroup(); as group) {\n <ui-form-group [state]=\"group\" />\n}\n\n<!-- Navigation -->\n<div class=\"actions\">\n @if (currentIndex() > 0) {\n <button type=\"button\" class=\"btn btn--prev\" (click)=\"prev()\">\n {{ prevLabel() }}\n </button>\n }\n\n <div class=\"spacer\"></div>\n\n @if (isLastStep()) {\n <button\n type=\"button\"\n class=\"btn btn--submit\"\n [disabled]=\"!engine().valid()\"\n (click)=\"onSubmit()\"\n >\n {{ submitLabel() }}\n </button>\n } @else {\n <button\n type=\"button\"\n class=\"btn btn--next\"\n [disabled]=\"!currentStepValid()\"\n (click)=\"next()\"\n >\n {{ nextLabel() }}\n </button>\n }\n</div>\n", styles: [":host{display:block}.title{font-size:1.375rem;font-weight:700;margin:0 0 4px}.description{font-size:.9375rem;opacity:.65;margin:0 0 16px;line-height:1.4}.steps{display:flex;gap:4px;margin-bottom:24px;padding:0}.step{appearance:none;border:none;padding:8px 16px;border-radius:8px;font-size:.8125rem;font-weight:500;cursor:pointer;display:flex;align-items:center;gap:6px;opacity:.55;transition:opacity .15s ease,background .15s ease}.step:disabled{cursor:default}.step--active{opacity:1}.step--completed{opacity:.8}.step-number{display:inline-flex;align-items:center;justify-content:center;width:22px;height:22px;border-radius:50%;font-size:.75rem;font-weight:700}.step-label{white-space:nowrap}.actions{display:flex;align-items:center;padding-top:16px;margin-top:24px}.spacer{flex:1}.btn{appearance:none;border:none;border-radius:8px;padding:10px 24px;font-size:.9375rem;font-weight:600;cursor:pointer;transition:opacity .15s ease}.btn:disabled{opacity:.45;cursor:default}.btn:focus-visible{outline-offset:2px}.btn--next:hover:not(:disabled),.btn--submit:hover:not(:disabled){opacity:.88}\n"] }]
1373
+ }], propDecorators: { engine: [{ type: i0.Input, args: [{ isSignal: true, alias: "engine", required: true }] }], nextLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "nextLabel", required: false }] }], prevLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "prevLabel", required: false }] }], submitLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "submitLabel", required: false }] }], fieldMinWidth: [{ type: i0.Input, args: [{ isSignal: true, alias: "fieldMinWidth", required: false }] }], formSubmit: [{ type: i0.Output, args: ["formSubmit"] }] } });
1374
+
1375
+ // ── FormDesignerEngine ───────────────────────────────────────────────
1376
+ // ── Counters for generating unique IDs ──────────────────────────────
1377
+ let nextFieldId = 1;
1378
+ let nextGroupId = 1;
1379
+ /** @internal Reset counters (for testing). */
1380
+ function resetDesignerCounters() {
1381
+ nextFieldId = 1;
1382
+ nextGroupId = 1;
1383
+ }
1384
+ // ── Engine ──────────────────────────────────────────────────────────
1385
+ /**
1386
+ * Signal-based engine that maintains the mutable designer state
1387
+ * and produces a readonly {@link FormSchema} snapshot on demand.
1388
+ *
1389
+ * All mutations go through public methods so the UI stays in sync
1390
+ * via Angular's signal-based change detection.
1391
+ *
1392
+ * @example
1393
+ * ```ts
1394
+ * const engine = new FormDesignerEngine();
1395
+ * engine.addGroup();
1396
+ * engine.addField(engine.groups()[0].uid, 'text');
1397
+ * const schema = engine.schema();
1398
+ * ```
1399
+ */
1400
+ class FormDesignerEngine {
1401
+ // ── Form-level properties ─────────────────────────────────────────
1402
+ /** Form ID. */
1403
+ formId = signal("form-1", ...(ngDevMode ? [{ debugName: "formId" }] : []));
1404
+ /** Form title. */
1405
+ formTitle = signal("", ...(ngDevMode ? [{ debugName: "formTitle" }] : []));
1406
+ /** Form description. */
1407
+ formDescription = signal("", ...(ngDevMode ? [{ debugName: "formDescription" }] : []));
1408
+ // ── Groups ────────────────────────────────────────────────────────
1409
+ /** Ordered list of mutable group definitions. */
1410
+ groups = signal([], ...(ngDevMode ? [{ debugName: "groups" }] : []));
1411
+ // ── Selection ─────────────────────────────────────────────────────
1412
+ /** Currently selected item in the canvas. */
1413
+ selection = signal(null, ...(ngDevMode ? [{ debugName: "selection" }] : []));
1414
+ /** The currently selected mutable field (convenience). */
1415
+ selectedField = computed(() => {
1416
+ const sel = this.selection();
1417
+ if (!sel || sel.kind !== "field" || !sel.fieldUid)
1418
+ return null;
1419
+ for (const g of this.groups()) {
1420
+ for (const f of g.fields()) {
1421
+ if (f.uid === sel.fieldUid)
1422
+ return f;
1423
+ }
1424
+ }
1425
+ return null;
1426
+ }, ...(ngDevMode ? [{ debugName: "selectedField" }] : []));
1427
+ /** The currently selected mutable group (convenience). */
1428
+ selectedGroup = computed(() => {
1429
+ const sel = this.selection();
1430
+ if (!sel || !sel.groupUid)
1431
+ return null;
1432
+ return this.groups().find((g) => g.uid === sel.groupUid) ?? null;
1433
+ }, ...(ngDevMode ? [{ debugName: "selectedGroup" }] : []));
1434
+ // ── Computed schema output ────────────────────────────────────────
1435
+ /**
1436
+ * Produces a readonly {@link FormSchema} snapshot from the current
1437
+ * mutable state. Fully JSON-serializable.
1438
+ */
1439
+ schema = computed(() => this.buildSchema(), ...(ngDevMode ? [{ debugName: "schema" }] : []));
1440
+ // ── Group mutations ───────────────────────────────────────────────
1441
+ /** Add a new empty group at the end. Returns the new group's uid. */
1442
+ addGroup() {
1443
+ const uid = crypto.randomUUID();
1444
+ const group = this.createMutableGroup(uid, `group-${nextGroupId++}`, "");
1445
+ this.groups.update((gs) => [...gs, group]);
1446
+ this.selection.set({ kind: "group", groupUid: uid, fieldUid: null });
1447
+ return uid;
1448
+ }
1449
+ /** Remove a group by uid. Clears selection if it pointed at the group. */
1450
+ removeGroup(uid) {
1451
+ this.groups.update((gs) => gs.filter((g) => g.uid !== uid));
1452
+ const sel = this.selection();
1453
+ if (sel && sel.groupUid === uid) {
1454
+ this.selection.set(null);
1455
+ }
1456
+ }
1457
+ /** Move a group to a new index. */
1458
+ moveGroup(uid, newIndex) {
1459
+ this.groups.update((gs) => {
1460
+ const idx = gs.findIndex((g) => g.uid === uid);
1461
+ if (idx === -1 || idx === newIndex)
1462
+ return gs;
1463
+ const copy = [...gs];
1464
+ const [item] = copy.splice(idx, 1);
1465
+ copy.splice(newIndex, 0, item);
1466
+ return copy;
1467
+ });
1468
+ }
1469
+ // ── Field mutations ───────────────────────────────────────────────
1470
+ /**
1471
+ * Add a new field to a group. Returns the new field's uid.
1472
+ *
1473
+ * @param groupUid — Target group
1474
+ * @param component — Field component key (e.g. `"text"`, `"select"`)
1475
+ * @param atIndex — Optional insertion index (appends if omitted)
1476
+ */
1477
+ addField(groupUid, component, atIndex) {
1478
+ const group = this.groups().find((g) => g.uid === groupUid);
1479
+ if (!group) {
1480
+ throw new Error(`FormDesignerEngine: unknown group "${groupUid}".`);
1481
+ }
1482
+ const uid = crypto.randomUUID();
1483
+ const fieldId = `field_${nextFieldId++}`;
1484
+ const field = this.createMutableField(uid, fieldId, this.labelForComponent(component), component);
1485
+ group.fields.update((fs) => {
1486
+ const copy = [...fs];
1487
+ if (atIndex !== undefined && atIndex >= 0 && atIndex <= copy.length) {
1488
+ copy.splice(atIndex, 0, field);
1489
+ }
1490
+ else {
1491
+ copy.push(field);
1492
+ }
1493
+ return copy;
1494
+ });
1495
+ this.selection.set({ kind: "field", groupUid, fieldUid: uid });
1496
+ return uid;
1497
+ }
1498
+ /** Remove a field by uid from its containing group. */
1499
+ removeField(groupUid, fieldUid) {
1500
+ const group = this.groups().find((g) => g.uid === groupUid);
1501
+ if (!group)
1502
+ return;
1503
+ group.fields.update((fs) => fs.filter((f) => f.uid !== fieldUid));
1504
+ const sel = this.selection();
1505
+ if (sel && sel.fieldUid === fieldUid) {
1506
+ this.selection.set(null);
1507
+ }
1508
+ }
1509
+ /** Move a field to a new position within its group, or to another group. */
1510
+ moveField(sourceGroupUid, fieldUid, targetGroupUid, targetIndex) {
1511
+ const sourceGroup = this.groups().find((g) => g.uid === sourceGroupUid);
1512
+ const targetGroup = this.groups().find((g) => g.uid === targetGroupUid);
1513
+ if (!sourceGroup || !targetGroup)
1514
+ return;
1515
+ let movedField;
1516
+ sourceGroup.fields.update((fs) => {
1517
+ const idx = fs.findIndex((f) => f.uid === fieldUid);
1518
+ if (idx === -1)
1519
+ return fs;
1520
+ const copy = [...fs];
1521
+ [movedField] = copy.splice(idx, 1);
1522
+ return copy;
1523
+ });
1524
+ if (!movedField)
1525
+ return;
1526
+ const fieldToInsert = movedField;
1527
+ targetGroup.fields.update((fs) => {
1528
+ const copy = [...fs];
1529
+ copy.splice(Math.min(targetIndex, copy.length), 0, fieldToInsert);
1530
+ return copy;
1531
+ });
1532
+ }
1533
+ /** Duplicate a field within its group (inserted right after the original). */
1534
+ duplicateField(groupUid, fieldUid) {
1535
+ const group = this.groups().find((g) => g.uid === groupUid);
1536
+ if (!group)
1537
+ return null;
1538
+ const fields = group.fields();
1539
+ const original = fields.find((f) => f.uid === fieldUid);
1540
+ if (!original)
1541
+ return null;
1542
+ const newUid = crypto.randomUUID();
1543
+ const copy = this.createMutableField(newUid, original.id() + "_copy", original.title() + " (copy)", original.component());
1544
+ // Copy properties
1545
+ copy.description.set(original.description());
1546
+ copy.config.set({ ...original.config() });
1547
+ copy.options.set([...original.options()]);
1548
+ copy.validation.set([...original.validation()]);
1549
+ copy.visibleWhen.set(original.visibleWhen());
1550
+ copy.enabledWhen.set(original.enabledWhen());
1551
+ copy.defaultValue.set(original.defaultValue());
1552
+ const idx = fields.findIndex((f) => f.uid === fieldUid);
1553
+ group.fields.update((fs) => {
1554
+ const arr = [...fs];
1555
+ arr.splice(idx + 1, 0, copy);
1556
+ return arr;
1557
+ });
1558
+ this.selection.set({ kind: "field", groupUid, fieldUid: newUid });
1559
+ return newUid;
1560
+ }
1561
+ // ── Selection ─────────────────────────────────────────────────────
1562
+ /** Select a field for editing in the inspector. */
1563
+ selectField(groupUid, fieldUid) {
1564
+ this.selection.set({ kind: "field", groupUid, fieldUid });
1565
+ }
1566
+ /** Select a group for editing in the inspector. */
1567
+ selectGroup(groupUid) {
1568
+ this.selection.set({ kind: "group", groupUid, fieldUid: null });
1569
+ }
1570
+ /** Select the form-level properties for editing. */
1571
+ selectForm() {
1572
+ this.selection.set({ kind: "form", groupUid: null, fieldUid: null });
1573
+ }
1574
+ /** Clear the selection. */
1575
+ clearSelection() {
1576
+ this.selection.set(null);
1577
+ }
1578
+ // ── Import / Export ───────────────────────────────────────────────
1579
+ /**
1580
+ * Load an existing {@link FormSchema} into the designer,
1581
+ * replacing all current state.
1582
+ */
1583
+ loadSchema(schema) {
1584
+ this.formId.set(schema.id);
1585
+ this.formTitle.set(schema.title ?? "");
1586
+ this.formDescription.set(schema.description ?? "");
1587
+ const groups = schema.groups.map((gDef) => {
1588
+ const gUid = crypto.randomUUID();
1589
+ const fields = gDef.fields.map((fDef) => {
1590
+ const fUid = crypto.randomUUID();
1591
+ const mf = this.createMutableField(fUid, fDef.id, fDef.title, fDef.component);
1592
+ mf.description.set(fDef.description ?? "");
1593
+ mf.config.set({ ...(fDef.config ?? {}) });
1594
+ mf.options.set([...(fDef.options ?? [])]);
1595
+ mf.validation.set([...(fDef.validation ?? [])]);
1596
+ mf.visibleWhen.set(fDef.visibleWhen ?? null);
1597
+ mf.enabledWhen.set(fDef.enabledWhen ?? null);
1598
+ mf.defaultValue.set(fDef.defaultValue ?? null);
1599
+ return mf;
1600
+ });
1601
+ const mg = this.createMutableGroup(gUid, gDef.id, gDef.title ?? "");
1602
+ mg.description.set(gDef.description ?? "");
1603
+ mg.fields.set(fields);
1604
+ mg.visibleWhen.set(gDef.visibleWhen ?? null);
1605
+ mg.enabledWhen.set(gDef.enabledWhen ?? null);
1606
+ return mg;
1607
+ });
1608
+ this.groups.set(groups);
1609
+ this.selection.set(null);
1610
+ }
1611
+ /**
1612
+ * Produce a pretty-printed JSON string of the current schema.
1613
+ */
1614
+ toJSON() {
1615
+ return JSON.stringify(this.schema(), null, 2);
1616
+ }
1617
+ // ── Private helpers ───────────────────────────────────────────────
1618
+ createMutableField(uid, id, title, component) {
1619
+ return {
1620
+ uid,
1621
+ id: signal(id),
1622
+ title: signal(title),
1623
+ description: signal(""),
1624
+ component: signal(component),
1625
+ config: signal({}),
1626
+ options: signal([]),
1627
+ validation: signal([]),
1628
+ visibleWhen: signal(null),
1629
+ enabledWhen: signal(null),
1630
+ defaultValue: signal(null),
1631
+ };
1632
+ }
1633
+ createMutableGroup(uid, id, title) {
1634
+ return {
1635
+ uid,
1636
+ id: signal(id),
1637
+ title: signal(title),
1638
+ description: signal(""),
1639
+ fields: signal([]),
1640
+ visibleWhen: signal(null),
1641
+ enabledWhen: signal(null),
1642
+ };
1643
+ }
1644
+ buildSchema() {
1645
+ const groups = this.groups().map((g) => {
1646
+ const fields = g.fields().map((f) => {
1647
+ const def = {
1648
+ id: f.id(),
1649
+ title: f.title(),
1650
+ component: f.component(),
1651
+ };
1652
+ if (f.description())
1653
+ def["description"] = f.description();
1654
+ if (Object.keys(f.config()).length > 0)
1655
+ def["config"] = f.config();
1656
+ if (f.options().length > 0)
1657
+ def["options"] = f.options();
1658
+ if (f.validation().length > 0)
1659
+ def["validation"] = f.validation();
1660
+ if (f.visibleWhen())
1661
+ def["visibleWhen"] = f.visibleWhen();
1662
+ if (f.enabledWhen())
1663
+ def["enabledWhen"] = f.enabledWhen();
1664
+ if (f.defaultValue() !== null)
1665
+ def["defaultValue"] = f.defaultValue();
1666
+ return def;
1667
+ });
1668
+ const gDef = {
1669
+ id: g.id(),
1670
+ fields,
1671
+ };
1672
+ if (g.title())
1673
+ gDef["title"] = g.title();
1674
+ if (g.description())
1675
+ gDef["description"] = g.description();
1676
+ if (g.visibleWhen())
1677
+ gDef["visibleWhen"] = g.visibleWhen();
1678
+ if (g.enabledWhen())
1679
+ gDef["enabledWhen"] = g.enabledWhen();
1680
+ return gDef;
1681
+ });
1682
+ const schema = {
1683
+ id: this.formId(),
1684
+ groups,
1685
+ };
1686
+ if (this.formTitle())
1687
+ schema["title"] = this.formTitle();
1688
+ if (this.formDescription())
1689
+ schema["description"] = this.formDescription();
1690
+ return schema;
1691
+ }
1692
+ /**
1693
+ * Generate a sensible default label from a component key.
1694
+ */
1695
+ labelForComponent(component) {
1696
+ const labels = {
1697
+ text: "Text Field",
1698
+ select: "Select",
1699
+ checkbox: "Checkbox",
1700
+ toggle: "Toggle",
1701
+ radio: "Radio Group",
1702
+ autocomplete: "Autocomplete",
1703
+ date: "Date Picker",
1704
+ time: "Time Picker",
1705
+ datetime: "Date/Time",
1706
+ color: "Color Picker",
1707
+ slider: "Slider",
1708
+ richtext: "Rich Text",
1709
+ file: "File Upload",
1710
+ "flair:richtext": "Rich Text (flair)",
1711
+ "flair:image": "Image (flair)",
1712
+ "flair:media": "Media (flair)",
1713
+ };
1714
+ return labels[component] ?? component;
1715
+ }
1716
+ }
1717
+
1718
+ // ── UIFieldPalette ──────────────────────────────────────────────────
1719
+ /** All built-in field types available in the palette. */
1720
+ const PALETTE_FIELDS = [
1721
+ {
1722
+ key: "text",
1723
+ label: "Text",
1724
+ description: "Single-line text input",
1725
+ icon: UIIcons.Lucide.Text.TextCursorInput,
1726
+ },
1727
+ {
1728
+ key: "select",
1729
+ label: "Select",
1730
+ description: "Dropdown selection",
1731
+ icon: UIIcons.Lucide.Arrows.ChevronsUpDown,
1732
+ },
1733
+ {
1734
+ key: "checkbox",
1735
+ label: "Checkbox",
1736
+ description: "Boolean checkbox",
1737
+ icon: UIIcons.Lucide.Notifications.SquareCheck,
1738
+ },
1739
+ {
1740
+ key: "toggle",
1741
+ label: "Toggle",
1742
+ description: "On/off toggle switch",
1743
+ icon: UIIcons.Lucide.Account.ToggleLeft,
1744
+ },
1745
+ {
1746
+ key: "radio",
1747
+ label: "Radio",
1748
+ description: "Radio button group",
1749
+ icon: UIIcons.Lucide.Shapes.CircleDot,
1750
+ },
1751
+ {
1752
+ key: "autocomplete",
1753
+ label: "Autocomplete",
1754
+ description: "Search with suggestions",
1755
+ icon: UIIcons.Lucide.Social.Search,
1756
+ },
1757
+ {
1758
+ key: "date",
1759
+ label: "Date",
1760
+ description: "Date picker",
1761
+ icon: UIIcons.Lucide.Time.Calendar,
1762
+ },
1763
+ {
1764
+ key: "time",
1765
+ label: "Time",
1766
+ description: "Time picker",
1767
+ icon: UIIcons.Lucide.Time.Clock,
1768
+ },
1769
+ {
1770
+ key: "datetime",
1771
+ label: "Date/Time",
1772
+ description: "Date and time picker",
1773
+ icon: UIIcons.Lucide.Time.CalendarClock,
1774
+ },
1775
+ {
1776
+ key: "color",
1777
+ label: "Color",
1778
+ description: "Color picker",
1779
+ icon: UIIcons.Lucide.Design.Palette,
1780
+ },
1781
+ {
1782
+ key: "slider",
1783
+ label: "Slider",
1784
+ description: "Numeric range slider",
1785
+ icon: UIIcons.Lucide.Account.SlidersHorizontal,
1786
+ },
1787
+ {
1788
+ key: "richtext",
1789
+ label: "Rich Text",
1790
+ description: "Rich text editor",
1791
+ icon: UIIcons.Lucide.Text.Pilcrow,
1792
+ },
1793
+ {
1794
+ key: "file",
1795
+ label: "File",
1796
+ description: "File upload",
1797
+ icon: UIIcons.Lucide.Files.FileUp,
1798
+ },
1799
+ ];
1800
+ /** Flair (non-data) types available in the palette. */
1801
+ const PALETTE_FLAIR = [
1802
+ {
1803
+ key: "flair:richtext",
1804
+ label: "Rich Text",
1805
+ description: "Static rich text content",
1806
+ icon: UIIcons.Lucide.Text.PenLine,
1807
+ },
1808
+ {
1809
+ key: "flair:image",
1810
+ label: "Image",
1811
+ description: "Static image",
1812
+ icon: UIIcons.Lucide.Photography.Image,
1813
+ },
1814
+ {
1815
+ key: "flair:media",
1816
+ label: "Media",
1817
+ description: "Audio or video player",
1818
+ icon: UIIcons.Lucide.Communication.Video,
1819
+ },
1820
+ ];
1821
+ /**
1822
+ * Palette sidebar that lists available field types. Clicking or
1823
+ * dragging a type emits the component key so the parent can add
1824
+ * it to the canvas.
1825
+ *
1826
+ * @example
1827
+ * ```html
1828
+ * <ui-field-palette (fieldRequested)="onAddField($event)" />
1829
+ * ```
1830
+ */
1831
+ class UIFieldPalette {
1832
+ /** Emitted when the user clicks a field type to add it. */
1833
+ fieldRequested = output();
1834
+ /** @internal Available field types. */
1835
+ fieldTypes = PALETTE_FIELDS;
1836
+ /** @internal Available flair types. */
1837
+ flairTypes = PALETTE_FLAIR;
1838
+ /** @internal */
1839
+ onDragStart(event, key) {
1840
+ event.dataTransfer?.setData("text/plain", key);
1841
+ event.dataTransfer.effectAllowed = "copy";
1842
+ }
1843
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: UIFieldPalette, deps: [], target: i0.ɵɵFactoryTarget.Component });
1844
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.0", type: UIFieldPalette, isStandalone: true, selector: "ui-field-palette", outputs: { fieldRequested: "fieldRequested" }, host: { classAttribute: "ui-field-palette" }, hostDirectives: [{ directive: i1.UISurface, inputs: ["surfaceType", "surfaceType"] }], ngImport: i0, template: "<h3 class=\"heading\" id=\"heading-fields\">Fields</h3>\n<nav class=\"list\" aria-labelledby=\"heading-fields\">\n @for (ft of fieldTypes; track ft.key) {\n <button\n type=\"button\"\n class=\"item\"\n [attr.aria-label]=\"'Add ' + ft.label + ' field: ' + ft.description\"\n (click)=\"fieldRequested.emit(ft.key)\"\n draggable=\"true\"\n (dragstart)=\"onDragStart($event, ft.key)\"\n >\n <ui-icon\n class=\"icon\"\n [svg]=\"ft.icon\"\n [size]=\"16\"\n aria-hidden=\"true\"\n />\n <span class=\"label\">{{ ft.label }}</span>\n </button>\n }\n</nav>\n\n<h3 class=\"heading heading--flair\" id=\"heading-flair\">Flair</h3>\n<nav class=\"list\" aria-labelledby=\"heading-flair\">\n @for (ft of flairTypes; track ft.key) {\n <button\n type=\"button\"\n class=\"item\"\n [attr.aria-label]=\"'Add ' + ft.label + ' flair: ' + ft.description\"\n (click)=\"fieldRequested.emit(ft.key)\"\n draggable=\"true\"\n (dragstart)=\"onDragStart($event, ft.key)\"\n >\n <ui-icon\n class=\"icon\"\n [svg]=\"ft.icon\"\n [size]=\"16\"\n aria-hidden=\"true\"\n />\n <span class=\"label\">{{ ft.label }}</span>\n </button>\n }\n</nav>\n", styles: [":host{display:flex;flex-direction:column;overflow-y:auto;padding:12px;min-width:180px}.heading{font-size:.75rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;margin:0 0 10px;opacity:.6}.heading--flair{margin-top:16px}.list{display:flex;flex-direction:column;gap:4px}.item{appearance:none;border-radius:6px;padding:8px 10px;display:flex;align-items:center;gap:8px;cursor:grab;font-size:.8125rem;transition:background .12s ease,border-color .12s ease}.item:active{cursor:grabbing}.icon{width:16px;height:16px;flex-shrink:0;opacity:.7}.label{font-weight:500}\n"], dependencies: [{ kind: "component", type: UIIcon, selector: "ui-icon", inputs: ["svg", "size", "ariaLabel"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1845
+ }
1846
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: UIFieldPalette, decorators: [{
1847
+ type: Component,
1848
+ args: [{ selector: "ui-field-palette", standalone: true, imports: [UIIcon], changeDetection: ChangeDetectionStrategy.OnPush, hostDirectives: [{ directive: UISurface, inputs: ['surfaceType'] }], host: { class: "ui-field-palette" }, template: "<h3 class=\"heading\" id=\"heading-fields\">Fields</h3>\n<nav class=\"list\" aria-labelledby=\"heading-fields\">\n @for (ft of fieldTypes; track ft.key) {\n <button\n type=\"button\"\n class=\"item\"\n [attr.aria-label]=\"'Add ' + ft.label + ' field: ' + ft.description\"\n (click)=\"fieldRequested.emit(ft.key)\"\n draggable=\"true\"\n (dragstart)=\"onDragStart($event, ft.key)\"\n >\n <ui-icon\n class=\"icon\"\n [svg]=\"ft.icon\"\n [size]=\"16\"\n aria-hidden=\"true\"\n />\n <span class=\"label\">{{ ft.label }}</span>\n </button>\n }\n</nav>\n\n<h3 class=\"heading heading--flair\" id=\"heading-flair\">Flair</h3>\n<nav class=\"list\" aria-labelledby=\"heading-flair\">\n @for (ft of flairTypes; track ft.key) {\n <button\n type=\"button\"\n class=\"item\"\n [attr.aria-label]=\"'Add ' + ft.label + ' flair: ' + ft.description\"\n (click)=\"fieldRequested.emit(ft.key)\"\n draggable=\"true\"\n (dragstart)=\"onDragStart($event, ft.key)\"\n >\n <ui-icon\n class=\"icon\"\n [svg]=\"ft.icon\"\n [size]=\"16\"\n aria-hidden=\"true\"\n />\n <span class=\"label\">{{ ft.label }}</span>\n </button>\n }\n</nav>\n", styles: [":host{display:flex;flex-direction:column;overflow-y:auto;padding:12px;min-width:180px}.heading{font-size:.75rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;margin:0 0 10px;opacity:.6}.heading--flair{margin-top:16px}.list{display:flex;flex-direction:column;gap:4px}.item{appearance:none;border-radius:6px;padding:8px 10px;display:flex;align-items:center;gap:8px;cursor:grab;font-size:.8125rem;transition:background .12s ease,border-color .12s ease}.item:active{cursor:grabbing}.icon{width:16px;height:16px;flex-shrink:0;opacity:.7}.label{font-weight:500}\n"] }]
1849
+ }], propDecorators: { fieldRequested: [{ type: i0.Output, args: ["fieldRequested"] }] } });
1850
+
1851
+ // ── UIDesignerCanvas ────────────────────────────────────────────────
1852
+ /**
1853
+ * The central canvas of the form designer. Displays all groups
1854
+ * and their fields as interactive cards that can be selected,
1855
+ * reordered, and deleted.
1856
+ *
1857
+ * @example
1858
+ * ```html
1859
+ * <ui-designer-canvas [engine]="engine" />
1860
+ * ```
1861
+ */
1862
+ class UIDesignerCanvas {
1863
+ /** The designer engine driving this canvas. */
1864
+ engine = input.required(...(ngDevMode ? [{ debugName: "engine" }] : []));
1865
+ /** Emitted when "Add field" is clicked on a group (passes group uid). */
1866
+ addFieldRequest = output();
1867
+ /** @internal Icon references exposed to the template. */
1868
+ icons = {
1869
+ ChevronUp: UIIcons.Lucide.Arrows.ChevronUp,
1870
+ ChevronDown: UIIcons.Lucide.Arrows.ChevronDown,
1871
+ Copy: UIIcons.Lucide.Text.Copy,
1872
+ X: UIIcons.Lucide.Notifications.X,
1873
+ };
1874
+ /** @internal Whether a component key is a flair type. */
1875
+ isFlair = isFlairComponent;
1876
+ /** @internal */
1877
+ moveGroupUp(event, uid, index) {
1878
+ event.stopPropagation();
1879
+ this.engine().moveGroup(uid, index - 1);
1880
+ }
1881
+ /** @internal */
1882
+ moveGroupDown(event, uid, index) {
1883
+ event.stopPropagation();
1884
+ this.engine().moveGroup(uid, index + 1);
1885
+ }
1886
+ /** @internal */
1887
+ removeGroup(event, uid) {
1888
+ event.stopPropagation();
1889
+ this.engine().removeGroup(uid);
1890
+ }
1891
+ /** @internal */
1892
+ moveFieldUp(event, groupUid, fieldUid, index) {
1893
+ event.stopPropagation();
1894
+ this.engine().moveField(groupUid, fieldUid, groupUid, index - 1);
1895
+ }
1896
+ /** @internal */
1897
+ moveFieldDown(event, groupUid, fieldUid, index) {
1898
+ event.stopPropagation();
1899
+ this.engine().moveField(groupUid, fieldUid, groupUid, index + 1);
1900
+ }
1901
+ /** @internal */
1902
+ duplicateField(event, groupUid, fieldUid) {
1903
+ event.stopPropagation();
1904
+ this.engine().duplicateField(groupUid, fieldUid);
1905
+ }
1906
+ /** @internal */
1907
+ removeField(event, groupUid, fieldUid) {
1908
+ event.stopPropagation();
1909
+ this.engine().removeField(groupUid, fieldUid);
1910
+ }
1911
+ /** @internal */
1912
+ onDragOver(event) {
1913
+ event.preventDefault();
1914
+ event.dataTransfer.dropEffect = "copy";
1915
+ }
1916
+ /** @internal */
1917
+ onDropField(event, group) {
1918
+ event.preventDefault();
1919
+ const component = event.dataTransfer?.getData("text/plain");
1920
+ if (component) {
1921
+ this.engine().addField(group.uid, component);
1922
+ }
1923
+ }
1924
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: UIDesignerCanvas, deps: [], target: i0.ɵɵFactoryTarget.Component });
1925
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.0", type: UIDesignerCanvas, isStandalone: true, selector: "ui-designer-canvas", inputs: { engine: { classPropertyName: "engine", publicName: "engine", isSignal: true, isRequired: true, transformFunction: null } }, outputs: { addFieldRequest: "addFieldRequest" }, host: { classAttribute: "ui-designer-canvas" }, hostDirectives: [{ directive: i1.UISurface, inputs: ["surfaceType", "surfaceType"] }], ngImport: i0, template: "<!-- Form header (click to select form-level props) -->\n<button\n type=\"button\"\n class=\"form-header\"\n [class.form-header--selected]=\"engine().selection()?.kind === 'form'\"\n [attr.aria-pressed]=\"engine().selection()?.kind === 'form'\"\n aria-label=\"Select form properties\"\n (click)=\"engine().selectForm()\"\n>\n <span class=\"form-title\">\n {{ engine().formTitle() || \"Untitled Form\" }}\n </span>\n @if (engine().formDescription()) {\n <span class=\"form-desc\">{{ engine().formDescription() }}</span>\n }\n</button>\n\n<!-- Groups -->\n@for (group of engine().groups(); track group.uid; let gi = $index) {\n <section\n class=\"group\"\n role=\"region\"\n [attr.aria-label]=\"group.title() || 'Group ' + (gi + 1)\"\n [class.group--selected]=\"\n engine().selection()?.kind === 'group' &&\n engine().selection()?.groupUid === group.uid\n \"\n (drop)=\"onDropField($event, group)\"\n (dragover)=\"onDragOver($event)\"\n >\n <!-- Group header -->\n <div class=\"group-header\">\n <span\n class=\"group-title\"\n tabindex=\"0\"\n role=\"button\"\n [attr.aria-label]=\"\n 'Select group: ' + (group.title() || 'Group ' + (gi + 1))\n \"\n (click)=\"engine().selectGroup(group.uid)\"\n (keydown.enter)=\"engine().selectGroup(group.uid)\"\n >{{ group.title() || \"Group \" + (gi + 1) }}</span\n >\n <div class=\"group-actions\">\n <button\n type=\"button\"\n class=\"dc-icon-btn\"\n aria-label=\"Move group up\"\n [disabled]=\"gi === 0\"\n (click)=\"moveGroupUp($event, group.uid, gi)\"\n >\n <ui-icon [svg]=\"icons.ChevronUp\" [size]=\"14\" aria-hidden=\"true\" />\n </button>\n <button\n type=\"button\"\n class=\"dc-icon-btn\"\n aria-label=\"Move group down\"\n [disabled]=\"gi === engine().groups().length - 1\"\n (click)=\"moveGroupDown($event, group.uid, gi)\"\n >\n <ui-icon\n [svg]=\"icons.ChevronDown\"\n [size]=\"14\"\n aria-hidden=\"true\"\n />\n </button>\n <button\n type=\"button\"\n class=\"dc-icon-btn dc-icon-btn--danger\"\n aria-label=\"Remove group\"\n (click)=\"removeGroup($event, group.uid)\"\n >\n <ui-icon [svg]=\"icons.X\" [size]=\"14\" aria-hidden=\"true\" />\n </button>\n </div>\n </div>\n\n <!-- Fields -->\n <div class=\"fields\">\n @for (field of group.fields(); track field.uid; let fi = $index) {\n <div\n class=\"field\"\n [class.field--selected]=\"\n engine().selection()?.fieldUid === field.uid\n \"\n [class.field--flair]=\"isFlair(field.component())\"\n role=\"button\"\n tabindex=\"0\"\n [attr.aria-label]=\"'Select field: ' + field.title()\"\n (click)=\"engine().selectField(group.uid, field.uid)\"\n (keydown.enter)=\"engine().selectField(group.uid, field.uid)\"\n >\n <div\n class=\"field-info\"\n >\n <span class=\"field-component\" aria-hidden=\"true\">{{\n field.component()\n }}</span>\n <span class=\"field-title\">{{ field.title() }}</span>\n <span class=\"field-id\" aria-hidden=\"true\">{{\n field.id()\n }}</span>\n </div>\n <div\n class=\"field-actions\"\n role=\"toolbar\"\n [attr.aria-label]=\"'Actions for ' + field.title()\"\n >\n <button\n type=\"button\"\n class=\"dc-icon-btn\"\n aria-label=\"Move field up\"\n [disabled]=\"fi === 0\"\n (click)=\"moveFieldUp($event, group.uid, field.uid, fi)\"\n >\n <ui-icon\n [svg]=\"icons.ChevronUp\"\n [size]=\"14\"\n aria-hidden=\"true\"\n />\n </button>\n <button\n type=\"button\"\n class=\"dc-icon-btn\"\n aria-label=\"Move field down\"\n [disabled]=\"fi === group.fields().length - 1\"\n (click)=\"moveFieldDown($event, group.uid, field.uid, fi)\"\n >\n <ui-icon\n [svg]=\"icons.ChevronDown\"\n [size]=\"14\"\n aria-hidden=\"true\"\n />\n </button>\n <button\n type=\"button\"\n class=\"dc-icon-btn\"\n aria-label=\"Duplicate field\"\n (click)=\"duplicateField($event, group.uid, field.uid)\"\n >\n <ui-icon [svg]=\"icons.Copy\" [size]=\"14\" aria-hidden=\"true\" />\n </button>\n <button\n type=\"button\"\n class=\"dc-icon-btn dc-icon-btn--danger\"\n aria-label=\"Remove field\"\n (click)=\"removeField($event, group.uid, field.uid)\"\n >\n <ui-icon [svg]=\"icons.X\" [size]=\"14\" aria-hidden=\"true\" />\n </button>\n </div>\n </div>\n }\n\n @if (group.fields().length === 0) {\n <div class=\"dc-empty\">\n Drop or click a field type to add it here\n </div>\n }\n </div>\n\n <!-- Add field button -->\n <button\n type=\"button\"\n class=\"dc-add-field\"\n (click)=\"addFieldRequest.emit(group.uid)\"\n >\n + Add field\n </button>\n </section>\n}\n\n<!-- Add group button -->\n<button type=\"button\" class=\"dc-add-group\" (click)=\"engine().addGroup()\">\n + Add group\n</button>\n", styles: [":host{display:flex;flex-direction:column;gap:16px;flex:1;padding:16px;overflow-y:auto}.form-header{appearance:none;border-radius:8px;padding:12px 16px;cursor:pointer;text-align:left;flex-shrink:0;transition:border-color .15s ease}.form-header--selected{border-style:solid}.form-title{display:block;font-weight:700;font-size:1rem}.form-desc{display:block;font-size:.8125rem;margin-top:2px}.group{border-radius:8px;overflow:hidden;flex-shrink:0}.group-header{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;cursor:pointer}.group-title{font-weight:600;font-size:.875rem;cursor:pointer;border-radius:4px;padding:2px 4px;margin:-2px -4px}.group-title:focus-visible{outline-offset:2px}.group-actions{display:flex;gap:2px}.fields{padding:8px;display:flex;flex-direction:column;gap:4px;min-height:40px}.field{display:flex;align-items:center;justify-content:space-between;padding:8px 10px;border-radius:6px;cursor:pointer;transition:border-color .12s ease,background .12s ease}.field-info{display:flex;align-items:center;gap:8px;min-width:0;flex:1;cursor:pointer;border-radius:4px;padding:2px;margin:-2px}.field-info:focus-visible{outline-offset:2px}.field-component{font-size:.6875rem;font-weight:700;text-transform:uppercase;letter-spacing:.05em;padding:2px 6px;border-radius:4px;flex-shrink:0}.field-title{font-weight:500;font-size:.875rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.field-id{font-size:.75rem;font-family:monospace}.field-actions{display:flex;gap:2px;flex-shrink:0;opacity:0;transition:opacity .12s ease}.field:hover .field-actions,.field--selected .field-actions{opacity:1}.dc-empty{text-align:center;padding:16px;font-size:.8125rem;opacity:.5;font-style:italic}.dc-icon-btn{appearance:none;border:none;cursor:pointer;font-size:.8125rem;width:26px;height:26px;display:inline-flex;align-items:center;justify-content:center;border-radius:4px;transition:background .1s ease}.dc-icon-btn:disabled{opacity:.3;cursor:default}.dc-add-field{appearance:none;border-radius:6px;padding:8px;margin:4px 8px 8px;cursor:pointer;font-size:.8125rem;font-weight:500;transition:background .12s ease}.dc-add-group{appearance:none;border-radius:8px;padding:14px;cursor:pointer;font-size:.875rem;font-weight:600;transition:background .12s ease,border-color .12s ease}\n"], dependencies: [{ kind: "component", type: UIIcon, selector: "ui-icon", inputs: ["svg", "size", "ariaLabel"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1926
+ }
1927
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: UIDesignerCanvas, decorators: [{
1928
+ type: Component,
1929
+ args: [{ selector: "ui-designer-canvas", standalone: true, imports: [UIIcon], changeDetection: ChangeDetectionStrategy.OnPush, hostDirectives: [{ directive: UISurface, inputs: ['surfaceType'] }], host: { class: "ui-designer-canvas" }, template: "<!-- Form header (click to select form-level props) -->\n<button\n type=\"button\"\n class=\"form-header\"\n [class.form-header--selected]=\"engine().selection()?.kind === 'form'\"\n [attr.aria-pressed]=\"engine().selection()?.kind === 'form'\"\n aria-label=\"Select form properties\"\n (click)=\"engine().selectForm()\"\n>\n <span class=\"form-title\">\n {{ engine().formTitle() || \"Untitled Form\" }}\n </span>\n @if (engine().formDescription()) {\n <span class=\"form-desc\">{{ engine().formDescription() }}</span>\n }\n</button>\n\n<!-- Groups -->\n@for (group of engine().groups(); track group.uid; let gi = $index) {\n <section\n class=\"group\"\n role=\"region\"\n [attr.aria-label]=\"group.title() || 'Group ' + (gi + 1)\"\n [class.group--selected]=\"\n engine().selection()?.kind === 'group' &&\n engine().selection()?.groupUid === group.uid\n \"\n (drop)=\"onDropField($event, group)\"\n (dragover)=\"onDragOver($event)\"\n >\n <!-- Group header -->\n <div class=\"group-header\">\n <span\n class=\"group-title\"\n tabindex=\"0\"\n role=\"button\"\n [attr.aria-label]=\"\n 'Select group: ' + (group.title() || 'Group ' + (gi + 1))\n \"\n (click)=\"engine().selectGroup(group.uid)\"\n (keydown.enter)=\"engine().selectGroup(group.uid)\"\n >{{ group.title() || \"Group \" + (gi + 1) }}</span\n >\n <div class=\"group-actions\">\n <button\n type=\"button\"\n class=\"dc-icon-btn\"\n aria-label=\"Move group up\"\n [disabled]=\"gi === 0\"\n (click)=\"moveGroupUp($event, group.uid, gi)\"\n >\n <ui-icon [svg]=\"icons.ChevronUp\" [size]=\"14\" aria-hidden=\"true\" />\n </button>\n <button\n type=\"button\"\n class=\"dc-icon-btn\"\n aria-label=\"Move group down\"\n [disabled]=\"gi === engine().groups().length - 1\"\n (click)=\"moveGroupDown($event, group.uid, gi)\"\n >\n <ui-icon\n [svg]=\"icons.ChevronDown\"\n [size]=\"14\"\n aria-hidden=\"true\"\n />\n </button>\n <button\n type=\"button\"\n class=\"dc-icon-btn dc-icon-btn--danger\"\n aria-label=\"Remove group\"\n (click)=\"removeGroup($event, group.uid)\"\n >\n <ui-icon [svg]=\"icons.X\" [size]=\"14\" aria-hidden=\"true\" />\n </button>\n </div>\n </div>\n\n <!-- Fields -->\n <div class=\"fields\">\n @for (field of group.fields(); track field.uid; let fi = $index) {\n <div\n class=\"field\"\n [class.field--selected]=\"\n engine().selection()?.fieldUid === field.uid\n \"\n [class.field--flair]=\"isFlair(field.component())\"\n role=\"button\"\n tabindex=\"0\"\n [attr.aria-label]=\"'Select field: ' + field.title()\"\n (click)=\"engine().selectField(group.uid, field.uid)\"\n (keydown.enter)=\"engine().selectField(group.uid, field.uid)\"\n >\n <div\n class=\"field-info\"\n >\n <span class=\"field-component\" aria-hidden=\"true\">{{\n field.component()\n }}</span>\n <span class=\"field-title\">{{ field.title() }}</span>\n <span class=\"field-id\" aria-hidden=\"true\">{{\n field.id()\n }}</span>\n </div>\n <div\n class=\"field-actions\"\n role=\"toolbar\"\n [attr.aria-label]=\"'Actions for ' + field.title()\"\n >\n <button\n type=\"button\"\n class=\"dc-icon-btn\"\n aria-label=\"Move field up\"\n [disabled]=\"fi === 0\"\n (click)=\"moveFieldUp($event, group.uid, field.uid, fi)\"\n >\n <ui-icon\n [svg]=\"icons.ChevronUp\"\n [size]=\"14\"\n aria-hidden=\"true\"\n />\n </button>\n <button\n type=\"button\"\n class=\"dc-icon-btn\"\n aria-label=\"Move field down\"\n [disabled]=\"fi === group.fields().length - 1\"\n (click)=\"moveFieldDown($event, group.uid, field.uid, fi)\"\n >\n <ui-icon\n [svg]=\"icons.ChevronDown\"\n [size]=\"14\"\n aria-hidden=\"true\"\n />\n </button>\n <button\n type=\"button\"\n class=\"dc-icon-btn\"\n aria-label=\"Duplicate field\"\n (click)=\"duplicateField($event, group.uid, field.uid)\"\n >\n <ui-icon [svg]=\"icons.Copy\" [size]=\"14\" aria-hidden=\"true\" />\n </button>\n <button\n type=\"button\"\n class=\"dc-icon-btn dc-icon-btn--danger\"\n aria-label=\"Remove field\"\n (click)=\"removeField($event, group.uid, field.uid)\"\n >\n <ui-icon [svg]=\"icons.X\" [size]=\"14\" aria-hidden=\"true\" />\n </button>\n </div>\n </div>\n }\n\n @if (group.fields().length === 0) {\n <div class=\"dc-empty\">\n Drop or click a field type to add it here\n </div>\n }\n </div>\n\n <!-- Add field button -->\n <button\n type=\"button\"\n class=\"dc-add-field\"\n (click)=\"addFieldRequest.emit(group.uid)\"\n >\n + Add field\n </button>\n </section>\n}\n\n<!-- Add group button -->\n<button type=\"button\" class=\"dc-add-group\" (click)=\"engine().addGroup()\">\n + Add group\n</button>\n", styles: [":host{display:flex;flex-direction:column;gap:16px;flex:1;padding:16px;overflow-y:auto}.form-header{appearance:none;border-radius:8px;padding:12px 16px;cursor:pointer;text-align:left;flex-shrink:0;transition:border-color .15s ease}.form-header--selected{border-style:solid}.form-title{display:block;font-weight:700;font-size:1rem}.form-desc{display:block;font-size:.8125rem;margin-top:2px}.group{border-radius:8px;overflow:hidden;flex-shrink:0}.group-header{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;cursor:pointer}.group-title{font-weight:600;font-size:.875rem;cursor:pointer;border-radius:4px;padding:2px 4px;margin:-2px -4px}.group-title:focus-visible{outline-offset:2px}.group-actions{display:flex;gap:2px}.fields{padding:8px;display:flex;flex-direction:column;gap:4px;min-height:40px}.field{display:flex;align-items:center;justify-content:space-between;padding:8px 10px;border-radius:6px;cursor:pointer;transition:border-color .12s ease,background .12s ease}.field-info{display:flex;align-items:center;gap:8px;min-width:0;flex:1;cursor:pointer;border-radius:4px;padding:2px;margin:-2px}.field-info:focus-visible{outline-offset:2px}.field-component{font-size:.6875rem;font-weight:700;text-transform:uppercase;letter-spacing:.05em;padding:2px 6px;border-radius:4px;flex-shrink:0}.field-title{font-weight:500;font-size:.875rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.field-id{font-size:.75rem;font-family:monospace}.field-actions{display:flex;gap:2px;flex-shrink:0;opacity:0;transition:opacity .12s ease}.field:hover .field-actions,.field--selected .field-actions{opacity:1}.dc-empty{text-align:center;padding:16px;font-size:.8125rem;opacity:.5;font-style:italic}.dc-icon-btn{appearance:none;border:none;cursor:pointer;font-size:.8125rem;width:26px;height:26px;display:inline-flex;align-items:center;justify-content:center;border-radius:4px;transition:background .1s ease}.dc-icon-btn:disabled{opacity:.3;cursor:default}.dc-add-field{appearance:none;border-radius:6px;padding:8px;margin:4px 8px 8px;cursor:pointer;font-size:.8125rem;font-weight:500;transition:background .12s ease}.dc-add-group{appearance:none;border-radius:8px;padding:14px;cursor:pointer;font-size:.875rem;font-weight:600;transition:background .12s ease,border-color .12s ease}\n"] }]
1930
+ }], propDecorators: { engine: [{ type: i0.Input, args: [{ isSignal: true, alias: "engine", required: true }] }], addFieldRequest: [{ type: i0.Output, args: ["addFieldRequest"] }] } });
1931
+
1932
+ // ── Component config schemas ────────────────────────────────────────
1933
+ /**
1934
+ * Map from component key (e.g. `"text"`, `"slider"`) to an ordered
1935
+ * list of config property schemas.
1936
+ *
1937
+ * Properties that are not in this map for a given component are
1938
+ * still stored in `config` but are only editable via the raw
1939
+ * JSON fallback textarea.
1940
+ */
1941
+ const COMPONENT_CONFIG_SCHEMAS = {
1942
+ text: [
1943
+ {
1944
+ key: "type",
1945
+ label: "Input type",
1946
+ editor: "select",
1947
+ options: ["text", "email", "password", "url", "tel", "search"],
1948
+ defaultValue: "text",
1949
+ visibleWhen: (cfg) => !cfg["textAdapter"],
1950
+ },
1951
+ {
1952
+ key: "textAdapter",
1953
+ label: "Text adapter",
1954
+ editor: "select",
1955
+ options: [
1956
+ { label: "(none)", value: "" },
1957
+ { label: "Email", value: "email" },
1958
+ { label: "URL", value: "url" },
1959
+ { label: "IP Address", value: "ip" },
1960
+ { label: "Phone", value: "phone" },
1961
+ { label: "Credit Card", value: "creditCard" },
1962
+ { label: "Money", value: "money" },
1963
+ { label: "Integer", value: "integer" },
1964
+ { label: "Float", value: "float" },
1965
+ { label: "Decimal", value: "decimal" },
1966
+ { label: "Hexadecimal", value: "hexadecimal" },
1967
+ { label: "Percentage", value: "percentage" },
1968
+ { label: "Date", value: "date" },
1969
+ { label: "Time", value: "time" },
1970
+ { label: "Colour", value: "color" },
1971
+ { label: "Slug", value: "slug" },
1972
+ { label: "UUID", value: "uuid" },
1973
+ { label: "Cron", value: "cron" },
1974
+ ],
1975
+ defaultValue: "",
1976
+ },
1977
+ {
1978
+ key: "placeholder",
1979
+ label: "Placeholder",
1980
+ editor: "text",
1981
+ placeholder: "e.g. Enter your name…",
1982
+ },
1983
+ {
1984
+ key: "multiline",
1985
+ label: "Multiline (textarea)",
1986
+ editor: "boolean",
1987
+ defaultValue: false,
1988
+ },
1989
+ {
1990
+ key: "rows",
1991
+ label: "Visible rows",
1992
+ editor: "number",
1993
+ defaultValue: 3,
1994
+ placeholder: "3",
1995
+ },
1996
+ ],
1997
+ select: [
1998
+ {
1999
+ key: "placeholder",
2000
+ label: "Placeholder",
2001
+ editor: "text",
2002
+ placeholder: "e.g. Choose…",
2003
+ },
2004
+ ],
2005
+ checkbox: [
2006
+ {
2007
+ key: "variant",
2008
+ label: "Variant",
2009
+ editor: "select",
2010
+ options: ["checkbox", "switch"],
2011
+ defaultValue: "checkbox",
2012
+ },
2013
+ {
2014
+ key: "indeterminate",
2015
+ label: "Indeterminate",
2016
+ editor: "boolean",
2017
+ defaultValue: false,
2018
+ },
2019
+ ],
2020
+ toggle: [
2021
+ {
2022
+ key: "onLabel",
2023
+ label: "On label",
2024
+ editor: "text",
2025
+ placeholder: "e.g. Enabled",
2026
+ },
2027
+ {
2028
+ key: "offLabel",
2029
+ label: "Off label",
2030
+ editor: "text",
2031
+ placeholder: "e.g. Disabled",
2032
+ },
2033
+ {
2034
+ key: "size",
2035
+ label: "Size",
2036
+ editor: "select",
2037
+ options: ["sm", "md", "lg"],
2038
+ defaultValue: "md",
2039
+ },
2040
+ ],
2041
+ radio: [],
2042
+ autocomplete: [
2043
+ {
2044
+ key: "placeholder",
2045
+ label: "Placeholder",
2046
+ editor: "text",
2047
+ placeholder: "e.g. Search…",
2048
+ },
2049
+ {
2050
+ key: "minQueryLength",
2051
+ label: "Min query length",
2052
+ editor: "number",
2053
+ defaultValue: 1,
2054
+ },
2055
+ {
2056
+ key: "multi",
2057
+ label: "Multi-select",
2058
+ editor: "boolean",
2059
+ defaultValue: false,
2060
+ },
2061
+ ],
2062
+ date: [
2063
+ {
2064
+ key: "format",
2065
+ label: "Date format",
2066
+ editor: "select",
2067
+ options: [
2068
+ "yyyy-MM-dd",
2069
+ "dd/MM/yyyy",
2070
+ "MM/dd/yyyy",
2071
+ "dd.MM.yyyy",
2072
+ "dd-MM-yyyy",
2073
+ "yyyy/MM/dd",
2074
+ ],
2075
+ defaultValue: "yyyy-MM-dd",
2076
+ },
2077
+ {
2078
+ key: "placeholder",
2079
+ label: "Placeholder",
2080
+ editor: "text",
2081
+ placeholder: "e.g. Select date",
2082
+ },
2083
+ {
2084
+ key: "firstDayOfWeek",
2085
+ label: "First day of week",
2086
+ editor: "select",
2087
+ options: [
2088
+ { label: "Sunday", value: "0" },
2089
+ { label: "Monday", value: "1" },
2090
+ { label: "Tuesday", value: "2" },
2091
+ { label: "Wednesday", value: "3" },
2092
+ { label: "Thursday", value: "4" },
2093
+ { label: "Friday", value: "5" },
2094
+ { label: "Saturday", value: "6" },
2095
+ ],
2096
+ defaultValue: 1,
2097
+ },
2098
+ ],
2099
+ time: [
2100
+ {
2101
+ key: "clockMode",
2102
+ label: "Clock mode",
2103
+ editor: "select",
2104
+ options: [
2105
+ { label: "24-hour", value: "24" },
2106
+ { label: "12-hour", value: "12" },
2107
+ ],
2108
+ defaultValue: 24,
2109
+ },
2110
+ {
2111
+ key: "minuteStep",
2112
+ label: "Minute step",
2113
+ editor: "number",
2114
+ defaultValue: 1,
2115
+ },
2116
+ ],
2117
+ datetime: [
2118
+ {
2119
+ key: "dateFormat",
2120
+ label: "Date format",
2121
+ editor: "select",
2122
+ options: [
2123
+ "yyyy-MM-dd",
2124
+ "dd/MM/yyyy",
2125
+ "MM/dd/yyyy",
2126
+ "dd.MM.yyyy",
2127
+ "dd-MM-yyyy",
2128
+ "yyyy/MM/dd",
2129
+ ],
2130
+ defaultValue: "yyyy-MM-dd",
2131
+ },
2132
+ {
2133
+ key: "clockMode",
2134
+ label: "Clock mode",
2135
+ editor: "select",
2136
+ options: [
2137
+ { label: "24-hour", value: "24" },
2138
+ { label: "12-hour", value: "12" },
2139
+ ],
2140
+ defaultValue: 24,
2141
+ },
2142
+ {
2143
+ key: "minuteStep",
2144
+ label: "Minute step",
2145
+ editor: "number",
2146
+ defaultValue: 1,
2147
+ },
2148
+ ],
2149
+ color: [
2150
+ {
2151
+ key: "defaultMode",
2152
+ label: "Default mode",
2153
+ editor: "select",
2154
+ options: ["theme", "grid", "rgba", "hsla"],
2155
+ defaultValue: "theme",
2156
+ },
2157
+ ],
2158
+ slider: [
2159
+ {
2160
+ key: "mode",
2161
+ label: "Mode",
2162
+ editor: "select",
2163
+ options: ["single", "range"],
2164
+ defaultValue: "single",
2165
+ },
2166
+ {
2167
+ key: "min",
2168
+ label: "Min value",
2169
+ editor: "number",
2170
+ defaultValue: 0,
2171
+ },
2172
+ {
2173
+ key: "max",
2174
+ label: "Max value",
2175
+ editor: "number",
2176
+ defaultValue: 100,
2177
+ },
2178
+ {
2179
+ key: "step",
2180
+ label: "Step",
2181
+ editor: "number",
2182
+ defaultValue: 1,
2183
+ },
2184
+ {
2185
+ key: "showValue",
2186
+ label: "Show value label",
2187
+ editor: "boolean",
2188
+ defaultValue: false,
2189
+ },
2190
+ {
2191
+ key: "showTicks",
2192
+ label: "Show tick marks",
2193
+ editor: "boolean",
2194
+ defaultValue: false,
2195
+ },
2196
+ ],
2197
+ richtext: [
2198
+ {
2199
+ key: "mode",
2200
+ label: "Mode",
2201
+ editor: "select",
2202
+ options: ["html", "markdown"],
2203
+ defaultValue: "html",
2204
+ },
2205
+ {
2206
+ key: "placeholder",
2207
+ label: "Placeholder",
2208
+ editor: "text",
2209
+ placeholder: "e.g. Type here…",
2210
+ },
2211
+ {
2212
+ key: "sanitize",
2213
+ label: "Sanitize HTML",
2214
+ editor: "boolean",
2215
+ defaultValue: true,
2216
+ },
2217
+ {
2218
+ key: "maxLength",
2219
+ label: "Max length",
2220
+ editor: "number",
2221
+ placeholder: "0 = unlimited",
2222
+ },
2223
+ ],
2224
+ file: [
2225
+ {
2226
+ key: "accept",
2227
+ label: "Accepted types",
2228
+ editor: "text",
2229
+ placeholder: "e.g. image/*,.pdf",
2230
+ },
2231
+ {
2232
+ key: "multiple",
2233
+ label: "Multiple files",
2234
+ editor: "boolean",
2235
+ defaultValue: false,
2236
+ },
2237
+ {
2238
+ key: "maxFileSize",
2239
+ label: "Max file size (bytes)",
2240
+ editor: "number",
2241
+ placeholder: "e.g. 5242880",
2242
+ },
2243
+ ],
2244
+ // ── Flair components ────────────────────────────────────────────────
2245
+ "flair:richtext": [
2246
+ {
2247
+ key: "content",
2248
+ label: "HTML content",
2249
+ editor: "richtext",
2250
+ },
2251
+ ],
2252
+ "flair:image": [
2253
+ {
2254
+ key: "src",
2255
+ label: "Image URL",
2256
+ editor: "text",
2257
+ placeholder: "https://example.com/image.png",
2258
+ },
2259
+ {
2260
+ key: "alt",
2261
+ label: "Alt text",
2262
+ editor: "text",
2263
+ placeholder: "Describe the image",
2264
+ },
2265
+ {
2266
+ key: "width",
2267
+ label: "Width (px)",
2268
+ editor: "number",
2269
+ placeholder: "e.g. 400",
2270
+ },
2271
+ {
2272
+ key: "height",
2273
+ label: "Height (px)",
2274
+ editor: "number",
2275
+ placeholder: "e.g. 300",
2276
+ },
2277
+ ],
2278
+ "flair:media": [
2279
+ {
2280
+ key: "source",
2281
+ label: "Media URL",
2282
+ editor: "text",
2283
+ placeholder: "https://example.com/video.mp4",
2284
+ },
2285
+ {
2286
+ key: "type",
2287
+ label: "Media type",
2288
+ editor: "select",
2289
+ options: ["video", "audio"],
2290
+ defaultValue: "video",
2291
+ },
2292
+ {
2293
+ key: "controls",
2294
+ label: "Show controls",
2295
+ editor: "boolean",
2296
+ defaultValue: true,
2297
+ },
2298
+ {
2299
+ key: "poster",
2300
+ label: "Poster image URL",
2301
+ editor: "text",
2302
+ placeholder: "https://example.com/poster.jpg",
2303
+ },
2304
+ ],
2305
+ };
2306
+ /**
2307
+ * Returns the config property schemas for a given component key.
2308
+ * Returns an empty array for unknown components.
2309
+ */
2310
+ function getConfigSchema(component) {
2311
+ return COMPONENT_CONFIG_SCHEMAS[component] ?? [];
2312
+ }
2313
+
2314
+ // ── UIRichTextContentDialog ─────────────────────────────────────────
2315
+ /**
2316
+ * Modal dialog for editing the HTML content of a `flair:richtext`
2317
+ * field in the form designer.
2318
+ *
2319
+ * Opens a full {@link UIRichTextEditor} inside a native `<dialog>`
2320
+ * with Apply / Cancel actions.
2321
+ *
2322
+ * @internal Used exclusively by {@link UIPropertyInspector}.
2323
+ */
2324
+ class UIRichTextContentDialog {
2325
+ modalRef = inject((ModalRef));
2326
+ /** Pre-filled HTML content (set via input by ModalService). */
2327
+ initialContent = input("", ...(ngDevMode ? [{ debugName: "initialContent" }] : []));
2328
+ /** Mutable content value for the editor. @internal */
2329
+ content = signal("", ...(ngDevMode ? [{ debugName: "content" }] : []));
2330
+ constructor() {
2331
+ queueMicrotask(() => {
2332
+ this.content.set(this.initialContent());
2333
+ });
2334
+ }
2335
+ /** @internal Called when the editor emits a value change. */
2336
+ onValueChange(html) {
2337
+ this.content.set(html);
2338
+ }
2339
+ /** Apply and close the dialog with the edited content. */
2340
+ apply() {
2341
+ this.modalRef.close(this.content());
2342
+ }
2343
+ /** Cancel and dismiss the dialog without a result. */
2344
+ cancel() {
2345
+ this.modalRef.close(undefined);
2346
+ }
2347
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: UIRichTextContentDialog, deps: [], target: i0.ɵɵFactoryTarget.Component });
2348
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "21.1.0", type: UIRichTextContentDialog, isStandalone: true, selector: "ui-richtext-content-dialog", inputs: { initialContent: { classPropertyName: "initialContent", publicName: "initialContent", isSignal: true, isRequired: false, transformFunction: null } }, host: { classAttribute: "ui-richtext-content-dialog" }, hostDirectives: [{ directive: i1.UISurface, inputs: ["surfaceType", "surfaceType"] }], ngImport: i0, template: "<div class=\"editor-wrapper\">\n <ui-rich-text-editor\n [value]=\"content()\"\n (valueChange)=\"onValueChange($event)\"\n ariaLabel=\"Edit rich text content\"\n />\n</div>\n<div class=\"actions\">\n <button\n type=\"button\"\n class=\"btn btn--cancel\"\n (click)=\"cancel()\"\n >\n Cancel\n </button>\n <button\n type=\"button\"\n class=\"btn btn--apply\"\n (click)=\"apply()\"\n >\n Apply\n </button>\n</div>\n", styles: [":host{display:block;padding:.75rem;min-width:32rem;max-width:48rem;font-family:inherit;font-size:.875rem}.editor-wrapper{border-radius:.25rem;overflow:hidden;min-height:14rem}.actions{display:flex;gap:.35rem;justify-content:flex-end;margin-top:.5rem}.btn{appearance:none;border-radius:.25rem;padding:.35rem .75rem;font-family:inherit;font-size:.8125rem;font-weight:500;cursor:pointer;display:inline-flex;align-items:center;gap:.3rem;transition:background-color 80ms ease,border-color 80ms ease;white-space:nowrap}.btn--apply:hover:not(:disabled){opacity:.9}\n"], dependencies: [{ kind: "component", type: UIRichTextEditor, selector: "ui-rich-text-editor", inputs: ["mode", "disabled", "readonly", "ariaLabel", "placeholder", "toolbarActions", "placeholders", "sanitise", "maxLength", "imageHandler", "emojiCategories", "value"], outputs: ["valueChange", "placeholderInserted"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
2349
+ }
2350
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: UIRichTextContentDialog, decorators: [{
2351
+ type: Component,
2352
+ args: [{ selector: "ui-richtext-content-dialog", standalone: true, imports: [UIRichTextEditor], changeDetection: ChangeDetectionStrategy.OnPush, hostDirectives: [{ directive: UISurface, inputs: ['surfaceType'] }], host: { class: "ui-richtext-content-dialog" }, template: "<div class=\"editor-wrapper\">\n <ui-rich-text-editor\n [value]=\"content()\"\n (valueChange)=\"onValueChange($event)\"\n ariaLabel=\"Edit rich text content\"\n />\n</div>\n<div class=\"actions\">\n <button\n type=\"button\"\n class=\"btn btn--cancel\"\n (click)=\"cancel()\"\n >\n Cancel\n </button>\n <button\n type=\"button\"\n class=\"btn btn--apply\"\n (click)=\"apply()\"\n >\n Apply\n </button>\n</div>\n", styles: [":host{display:block;padding:.75rem;min-width:32rem;max-width:48rem;font-family:inherit;font-size:.875rem}.editor-wrapper{border-radius:.25rem;overflow:hidden;min-height:14rem}.actions{display:flex;gap:.35rem;justify-content:flex-end;margin-top:.5rem}.btn{appearance:none;border-radius:.25rem;padding:.35rem .75rem;font-family:inherit;font-size:.8125rem;font-weight:500;cursor:pointer;display:inline-flex;align-items:center;gap:.3rem;transition:background-color 80ms ease,border-color 80ms ease;white-space:nowrap}.btn--apply:hover:not(:disabled){opacity:.9}\n"] }]
2353
+ }], ctorParameters: () => [], propDecorators: { initialContent: [{ type: i0.Input, args: [{ isSignal: true, alias: "initialContent", required: false }] }] } });
2354
+
2355
+ // ── UIPropertyInspector ─────────────────────────────────────────────
2356
+ /** Available component keys for the component selector. */
2357
+ const COMPONENT_OPTIONS = [
2358
+ "text",
2359
+ "select",
2360
+ "checkbox",
2361
+ "toggle",
2362
+ "radio",
2363
+ "autocomplete",
2364
+ "date",
2365
+ "time",
2366
+ "datetime",
2367
+ "color",
2368
+ "slider",
2369
+ "richtext",
2370
+ "file",
2371
+ "flair:richtext",
2372
+ "flair:image",
2373
+ "flair:media",
2374
+ ].map((k) => ({ value: k, label: k }));
2375
+ /** Available validation rule types. */
2376
+ const VALIDATION_TYPE_OPTIONS = [
2377
+ "required",
2378
+ "minLength",
2379
+ "maxLength",
2380
+ "min",
2381
+ "max",
2382
+ "pattern",
2383
+ "email",
2384
+ ].map((k) => ({ value: k, label: k }));
2385
+ /**
2386
+ * Property inspector panel for editing the properties of the
2387
+ * currently selected field, group, or form.
2388
+ *
2389
+ * @example
2390
+ * ```html
2391
+ * <ui-property-inspector [engine]="engine" />
2392
+ * ```
2393
+ */
2394
+ class UIPropertyInspector {
2395
+ /** The designer engine driving this inspector. */
2396
+ engine = input.required(...(ngDevMode ? [{ debugName: "engine" }] : []));
2397
+ /** @internal */
2398
+ componentOptions = COMPONENT_OPTIONS;
2399
+ /** @internal */
2400
+ validationTypeOptions = VALIDATION_TYPE_OPTIONS;
2401
+ /** @internal */
2402
+ configError = signal("", ...(ngDevMode ? [{ debugName: "configError" }] : []));
2403
+ /** @internal X icon for remove buttons. */
2404
+ iconX = UIIcons.Lucide.Notifications.X;
2405
+ /** @internal Edit icon for the rich text button. */
2406
+ iconEdit = UIIcons.Lucide.Text.Type;
2407
+ modalService = inject(ModalService);
2408
+ // ── Helpers ───────────────────────────────────────────────────────
2409
+ /** @internal Extract input value from a DOM event. */
2410
+ inputValue(event) {
2411
+ return event.target.value;
2412
+ }
2413
+ /** @internal Whether the field type supports options. */
2414
+ showOptions(field) {
2415
+ const comp = field.component();
2416
+ return comp === "select" || comp === "radio" || comp === "autocomplete";
2417
+ }
2418
+ /** @internal Whether the field is a flair (non-data) component. */
2419
+ isFieldFlair(field) {
2420
+ return isFlairComponent(field.component());
2421
+ }
2422
+ // ── Options ───────────────────────────────────────────────────────
2423
+ /** @internal */
2424
+ addOption(field) {
2425
+ field.options.update((opts) => [...opts, { label: "", value: "" }]);
2426
+ }
2427
+ /** @internal */
2428
+ removeOption(field, index) {
2429
+ field.options.update((opts) => opts.filter((_, i) => i !== index));
2430
+ }
2431
+ /** @internal */
2432
+ updateOption(field, index, key, value) {
2433
+ field.options.update((opts) => {
2434
+ const copy = [...opts];
2435
+ copy[index] = { ...copy[index], [key]: value };
2436
+ return copy;
2437
+ });
2438
+ }
2439
+ // ── Validation ────────────────────────────────────────────────────
2440
+ /** @internal */
2441
+ addValidationRule(field) {
2442
+ field.validation.update((rules) => [
2443
+ ...rules,
2444
+ { type: "required", message: "" },
2445
+ ]);
2446
+ }
2447
+ /** @internal */
2448
+ removeValidationRule(field, index) {
2449
+ field.validation.update((rules) => rules.filter((_, i) => i !== index));
2450
+ }
2451
+ /** @internal */
2452
+ updateValidationRule(field, index, key, value) {
2453
+ field.validation.update((rules) => {
2454
+ const copy = [...rules];
2455
+ copy[index] = { ...copy[index], [key]: value };
2456
+ return copy;
2457
+ });
2458
+ }
2459
+ /** @internal */
2460
+ ruleHasParam(type) {
2461
+ return ["minLength", "maxLength", "min", "max", "pattern"].includes(type);
2462
+ }
2463
+ /** @internal */
2464
+ paramPlaceholder(type) {
2465
+ switch (type) {
2466
+ case "minLength":
2467
+ return "Min length";
2468
+ case "maxLength":
2469
+ return "Max length";
2470
+ case "min":
2471
+ return "Min value";
2472
+ case "max":
2473
+ return "Max value";
2474
+ case "pattern":
2475
+ return "Regex pattern";
2476
+ default:
2477
+ return "Param";
2478
+ }
2479
+ }
2480
+ /** @internal */
2481
+ ruleParamValue(rule) {
2482
+ if (!rule.params)
2483
+ return "";
2484
+ const p = rule.params;
2485
+ return String(p["min"] ?? p["max"] ?? p["pattern"] ?? "");
2486
+ }
2487
+ /** @internal */
2488
+ updateValidationParam(field, index, type, value) {
2489
+ field.validation.update((rules) => {
2490
+ const copy = [...rules];
2491
+ let params;
2492
+ switch (type) {
2493
+ case "minLength":
2494
+ params = { min: parseInt(value, 10) || 0 };
2495
+ break;
2496
+ case "maxLength":
2497
+ params = { max: parseInt(value, 10) || 0 };
2498
+ break;
2499
+ case "min":
2500
+ params = { min: parseFloat(value) || 0 };
2501
+ break;
2502
+ case "max":
2503
+ params = { max: parseFloat(value) || 0 };
2504
+ break;
2505
+ case "pattern":
2506
+ params = { pattern: value };
2507
+ break;
2508
+ default:
2509
+ params = {};
2510
+ }
2511
+ copy[index] = { ...copy[index], params };
2512
+ return copy;
2513
+ });
2514
+ }
2515
+ // ── Structured Config ─────────────────────────────────────────────
2516
+ /** @internal Returns the config property schemas for the field's component. */
2517
+ configSchemaFor(field) {
2518
+ return getConfigSchema(field.component());
2519
+ }
2520
+ /** @internal Read a single config value. */
2521
+ configValue(field, key) {
2522
+ return field.config()[key] ?? null;
2523
+ }
2524
+ /** @internal Set a string config value (removes key if empty). */
2525
+ setConfigValue(field, key, value) {
2526
+ field.config.update((cfg) => {
2527
+ const copy = { ...cfg };
2528
+ if (value === "") {
2529
+ delete copy[key];
2530
+ }
2531
+ else {
2532
+ copy[key] = value;
2533
+ }
2534
+ return copy;
2535
+ });
2536
+ }
2537
+ /** @internal Set a numeric config value (removes key if empty/NaN). */
2538
+ setConfigNumber(field, key, raw) {
2539
+ field.config.update((cfg) => {
2540
+ const copy = { ...cfg };
2541
+ const n = parseFloat(raw);
2542
+ if (raw === "" || isNaN(n)) {
2543
+ delete copy[key];
2544
+ }
2545
+ else {
2546
+ copy[key] = n;
2547
+ }
2548
+ return copy;
2549
+ });
2550
+ }
2551
+ /** @internal Set a boolean config value. */
2552
+ setConfigBoolean(field, key, checked) {
2553
+ field.config.update((cfg) => {
2554
+ const copy = { ...cfg };
2555
+ if (!checked) {
2556
+ delete copy[key];
2557
+ }
2558
+ else {
2559
+ copy[key] = true;
2560
+ }
2561
+ return copy;
2562
+ });
2563
+ }
2564
+ /** @internal Normalize select options to SelectOption[] with empty option. */
2565
+ configSelectOptions(prop) {
2566
+ const opts = (prop.options ?? []).map((o) => typeof o === "string" ? { label: o, value: o } : o);
2567
+ return [{ label: "\u2014", value: "" }, ...opts];
2568
+ }
2569
+ /** @internal Open a richtext editor dialog for the given config key. */
2570
+ openRichTextEditor(field, key) {
2571
+ const current = field.config()[key] ?? "";
2572
+ this.modalService
2573
+ .openModal({
2574
+ component: UIRichTextContentDialog,
2575
+ inputs: { initialContent: current },
2576
+ ariaLabel: "Edit rich text content",
2577
+ })
2578
+ .closed.subscribe((result) => {
2579
+ if (result !== undefined) {
2580
+ this.setConfigValue(field, key, result);
2581
+ }
2582
+ });
2583
+ }
2584
+ // ── Extra Config (raw JSON for unknown keys) ──────────────────────
2585
+ /** @internal Whether the field has config keys not covered by the schema. */
2586
+ hasExtraConfig(field) {
2587
+ const schemaKeys = new Set(getConfigSchema(field.component()).map((p) => p.key));
2588
+ return Object.keys(field.config()).some((k) => !schemaKeys.has(k));
2589
+ }
2590
+ /** @internal JSON string of config keys NOT in the structured schema. */
2591
+ extraConfigJSON(field) {
2592
+ const schemaKeys = new Set(getConfigSchema(field.component()).map((p) => p.key));
2593
+ const extra = {};
2594
+ for (const [k, v] of Object.entries(field.config())) {
2595
+ if (!schemaKeys.has(k))
2596
+ extra[k] = v;
2597
+ }
2598
+ return Object.keys(extra).length > 0 ? JSON.stringify(extra, null, 2) : "";
2599
+ }
2600
+ /** @internal Parse and merge extra config JSON back into the field. */
2601
+ onExtraConfigChange(field, event) {
2602
+ const raw = event.target.value.trim();
2603
+ const schemaKeys = new Set(getConfigSchema(field.component()).map((p) => p.key));
2604
+ // Keep all schema-managed keys, replace extra keys
2605
+ const managed = {};
2606
+ for (const [k, v] of Object.entries(field.config())) {
2607
+ if (schemaKeys.has(k))
2608
+ managed[k] = v;
2609
+ }
2610
+ if (!raw) {
2611
+ field.config.set(managed);
2612
+ this.configError.set("");
2613
+ return;
2614
+ }
2615
+ try {
2616
+ const parsed = JSON.parse(raw);
2617
+ if (typeof parsed === "object" &&
2618
+ parsed !== null &&
2619
+ !Array.isArray(parsed)) {
2620
+ field.config.set({ ...managed, ...parsed });
2621
+ this.configError.set("");
2622
+ }
2623
+ else {
2624
+ this.configError.set("Config must be a JSON object.");
2625
+ }
2626
+ }
2627
+ catch {
2628
+ this.configError.set("Invalid JSON.");
2629
+ }
2630
+ }
2631
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: UIPropertyInspector, deps: [], target: i0.ɵɵFactoryTarget.Component });
2632
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.0", type: UIPropertyInspector, isStandalone: true, selector: "ui-property-inspector", inputs: { engine: { classPropertyName: "engine", publicName: "engine", isSignal: true, isRequired: true, transformFunction: null } }, host: { classAttribute: "ui-property-inspector" }, hostDirectives: [{ directive: i1.UISurface, inputs: ["surfaceType", "surfaceType"] }], ngImport: i0, template: "@if (engine().selection(); as sel) {\n @switch (sel.kind) {\n @case (\"form\") {\n <h3 class=\"heading\">Form Properties</h3>\n <div class=\"section\">\n <div class=\"label\">\n ID\n <ui-input\n [value]=\"engine().formId()\"\n (valueChange)=\"engine().formId.set($event)\"\n ariaLabel=\"Form ID\"\n />\n </div>\n\n <div class=\"label\">\n Title\n <ui-input\n [value]=\"engine().formTitle()\"\n (valueChange)=\"engine().formTitle.set($event)\"\n ariaLabel=\"Form title\"\n />\n </div>\n\n <div class=\"label\">\n Description\n <ui-input\n [multiline]=\"true\"\n [rows]=\"2\"\n [value]=\"engine().formDescription()\"\n (valueChange)=\"engine().formDescription.set($event)\"\n ariaLabel=\"Form description\"\n />\n </div>\n </div>\n }\n\n @case (\"group\") {\n @if (engine().selectedGroup(); as group) {\n <h3 class=\"heading\">Group Properties</h3>\n <div class=\"section\">\n <div class=\"label\">\n ID\n <ui-input\n [value]=\"group.id()\"\n (valueChange)=\"group.id.set($event)\"\n ariaLabel=\"Group ID\"\n />\n </div>\n\n <div class=\"label\">\n Title\n <ui-input\n [value]=\"group.title()\"\n (valueChange)=\"group.title.set($event)\"\n ariaLabel=\"Group title\"\n />\n </div>\n\n <div class=\"label\">\n Description\n <ui-input\n [multiline]=\"true\"\n [rows]=\"2\"\n [value]=\"group.description()\"\n (valueChange)=\"group.description.set($event)\"\n ariaLabel=\"Group description\"\n />\n </div>\n </div>\n }\n }\n\n @case (\"field\") {\n @if (engine().selectedField(); as field) {\n <h3 class=\"heading\">Field Properties</h3>\n <div class=\"section\">\n <div class=\"label\">\n ID\n <ui-input\n [value]=\"field.id()\"\n (valueChange)=\"field.id.set($event)\"\n ariaLabel=\"Field ID\"\n />\n </div>\n\n @if (!isFieldFlair(field)) {\n <div class=\"label\">\n Title\n <ui-input\n [value]=\"field.title()\"\n (valueChange)=\"field.title.set($event)\"\n ariaLabel=\"Field title\"\n />\n </div>\n\n <div class=\"label\">\n Description\n <ui-input\n [multiline]=\"true\"\n [rows]=\"2\"\n [value]=\"field.description()\"\n (valueChange)=\"field.description.set($event)\"\n ariaLabel=\"Field description\"\n />\n </div>\n }\n\n <div class=\"label\">\n Component\n <ui-dropdown-list\n [options]=\"componentOptions\"\n [value]=\"field.component()\"\n (valueChange)=\"field.component.set($event)\"\n ariaLabel=\"Field component type\"\n />\n </div>\n\n @if (!isFieldFlair(field)) {\n <div class=\"label\">\n Default Value\n <ui-input\n [value]=\"'' + (field.defaultValue() ?? '')\"\n (valueChange)=\"field.defaultValue.set($event || null)\"\n ariaLabel=\"Default value\"\n />\n </div>\n }\n </div>\n\n <!-- Options (for select/radio/autocomplete) -->\n @if (!isFieldFlair(field) && showOptions(field)) {\n <h4 class=\"subheading\">Options</h4>\n <div class=\"section\">\n @for (opt of field.options(); track $index; let i = $index) {\n <div class=\"pi-option-row\">\n <ui-input\n [value]=\"opt.label\"\n (valueChange)=\"updateOption(field, i, 'label', $event)\"\n placeholder=\"Label\"\n ariaLabel=\"Option label\"\n />\n <ui-input\n [value]=\"'' + opt.value\"\n (valueChange)=\"updateOption(field, i, 'value', $event)\"\n placeholder=\"Value\"\n ariaLabel=\"Option value\"\n />\n <button\n type=\"button\"\n class=\"icon-btn icon-btn--danger\"\n aria-label=\"Remove option\"\n (click)=\"removeOption(field, i)\"\n >\n <ui-icon [svg]=\"iconX\" [size]=\"12\" aria-hidden=\"true\" />\n </button>\n </div>\n }\n <button\n type=\"button\"\n class=\"add-btn\"\n (click)=\"addOption(field)\"\n >\n + Add option\n </button>\n </div>\n }\n\n <!-- Validation -->\n @if (!isFieldFlair(field)) {\n <h4 class=\"subheading\">Validation</h4>\n <div class=\"section\">\n @for (\n rule of field.validation();\n track $index;\n let i = $index\n ) {\n <div class=\"validation-row\">\n <ui-dropdown-list\n [options]=\"validationTypeOptions\"\n [value]=\"rule.type\"\n (valueChange)=\"\n updateValidationRule(field, i, 'type', $event)\n \"\n ariaLabel=\"Validation rule type\"\n />\n\n @if (ruleHasParam(rule.type)) {\n <ui-input\n [value]=\"ruleParamValue(rule)\"\n (valueChange)=\"\n updateValidationParam(field, i, rule.type, $event)\n \"\n [placeholder]=\"paramPlaceholder(rule.type)\"\n ariaLabel=\"Validation parameter\"\n />\n }\n\n <ui-input\n [value]=\"rule.message ?? ''\"\n (valueChange)=\"\n updateValidationRule(field, i, 'message', $event)\n \"\n placeholder=\"Error message\"\n ariaLabel=\"Validation error message\"\n />\n\n <button\n type=\"button\"\n class=\"icon-btn icon-btn--danger\"\n aria-label=\"Remove validation rule\"\n (click)=\"removeValidationRule(field, i)\"\n >\n <ui-icon [svg]=\"iconX\" [size]=\"12\" aria-hidden=\"true\" />\n </button>\n </div>\n }\n <button\n type=\"button\"\n class=\"add-btn\"\n (click)=\"addValidationRule(field)\"\n >\n + Add rule\n </button>\n </div>\n }\n\n <!-- Config -->\n @if (configSchemaFor(field).length > 0) {\n <h4 class=\"subheading\">Config</h4>\n <div class=\"section\">\n @for (prop of configSchemaFor(field); track prop.key) {\n @if (!prop.visibleWhen || prop.visibleWhen(field.config())) {\n @switch (prop.editor) {\n @case (\"text\") {\n <div class=\"label\">\n {{ prop.label }}\n <ui-input\n [placeholder]=\"prop.placeholder ?? ''\"\n [value]=\"'' + (configValue(field, prop.key) ?? '')\"\n (valueChange)=\"\n setConfigValue(field, prop.key, $event)\n \"\n [ariaLabel]=\"prop.label\"\n />\n </div>\n }\n\n @case (\"number\") {\n <div class=\"label\">\n {{ prop.label }}\n <ui-input\n type=\"number\"\n [placeholder]=\"prop.placeholder ?? ''\"\n [value]=\"'' + (configValue(field, prop.key) ?? '')\"\n (valueChange)=\"\n setConfigNumber(field, prop.key, $event)\n \"\n [ariaLabel]=\"prop.label\"\n />\n </div>\n }\n\n @case (\"boolean\") {\n <ui-checkbox\n [checked]=\"!!configValue(field, prop.key)\"\n (checkedChange)=\"\n setConfigBoolean(field, prop.key, $event)\n \"\n [ariaLabel]=\"prop.label\"\n >\n {{ prop.label }}\n </ui-checkbox>\n }\n\n @case (\"select\") {\n <div class=\"label\">\n {{ prop.label }}\n <ui-dropdown-list\n [options]=\"configSelectOptions(prop)\"\n [value]=\"'' + (configValue(field, prop.key) ?? '')\"\n (valueChange)=\"\n setConfigValue(field, prop.key, $event)\n \"\n [ariaLabel]=\"prop.label\"\n />\n </div>\n }\n\n @case (\"richtext\") {\n <div class=\"label\">\n {{ prop.label }}\n <button\n type=\"button\"\n class=\"edit-content-btn\"\n (click)=\"openRichTextEditor(field, prop.key)\"\n >\n <ui-icon [svg]=\"iconEdit\" [size]=\"14\" aria-hidden=\"true\" />\n Edit Content\n </button>\n </div>\n }\n }\n }\n }\n </div>\n }\n\n <!-- Extra config (raw JSON, for properties not in the schema) -->\n @if (hasExtraConfig(field)) {\n <h4 class=\"subheading\">Extra Config (JSON)</h4>\n <div class=\"section\">\n <textarea\n class=\"textarea textarea--mono\"\n rows=\"3\"\n aria-label=\"Extra configuration JSON\"\n aria-describedby=\"pi-config-error\"\n [attr.aria-invalid]=\"configError() ? 'true' : null\"\n [value]=\"extraConfigJSON(field)\"\n (change)=\"onExtraConfigChange(field, $event)\"\n ></textarea>\n @if (configError()) {\n <p class=\"error\" id=\"pi-config-error\" role=\"alert\">\n {{ configError() }}\n </p>\n }\n </div>\n }\n }\n }\n }\n} @else {\n <div class=\"empty\">\n <p>Select a field, group, or form header to edit its properties.</p>\n </div>\n}\n", styles: [":host{display:flex;flex-direction:column;padding:12px;overflow-y:auto;min-width:260px;max-width:320px}.heading{font-size:.75rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;margin:0 0 12px;opacity:.6}.subheading{font-size:.75rem;font-weight:700;text-transform:uppercase;letter-spacing:.04em;margin:16px 0 8px;opacity:.5}.section{display:flex;flex-direction:column;gap:8px}.label{display:flex;flex-direction:column;gap:3px;font-size:.75rem;font-weight:600}.label--row{flex-direction:row;align-items:center;gap:6px;cursor:pointer}.input,.textarea,.select{appearance:none;border-radius:5px;padding:6px 8px;font-size:.8125rem;font-family:inherit}.input:focus,.textarea:focus,.select:focus{outline-offset:-1px}.input--sm,.select--sm{padding:4px 6px;font-size:.75rem}.textarea--mono{font-family:monospace;font-size:.75rem}.pi-option-row,.validation-row{display:flex;gap:4px;align-items:center}.pi-option-row .input{flex:1}.validation-row>*{flex:1;min-width:0}.validation-row .icon-btn{flex:0 0 auto}.icon-btn{appearance:none;border:none;cursor:pointer;font-size:.75rem;width:24px;height:24px;display:inline-flex;align-items:center;justify-content:center;border-radius:4px}.add-btn{appearance:none;border-radius:5px;padding:5px 8px;cursor:pointer;font-size:.75rem;font-weight:500}.edit-content-btn{appearance:none;display:inline-flex;align-items:center;gap:6px;border-radius:5px;padding:5px 10px;cursor:pointer;font-size:.75rem;font-weight:500;font-family:inherit;transition:background-color 80ms ease,color 80ms ease}.error{font-size:.75rem;margin:2px 0 0}.empty{flex:1;display:flex;align-items:center;justify-content:center;text-align:center;font-size:.8125rem;padding:24px}\n"], dependencies: [{ kind: "component", type: UIIcon, selector: "ui-icon", inputs: ["svg", "size", "ariaLabel"] }, { kind: "component", type: UIInput, selector: "ui-input", inputs: ["type", "text", "value", "adapter", "placeholder", "disabled", "multiline", "rows", "heightAdjustable", "ariaLabel"], outputs: ["textChange", "valueChange"] }, { kind: "component", type: UIDropdownList, selector: "ui-dropdown-list", inputs: ["options", "placeholder", "disabled", "ariaLabel", "value"], outputs: ["valueChange"] }, { kind: "component", type: UICheckbox, selector: "ui-checkbox", inputs: ["variant", "checked", "disabled", "indeterminate", "ariaLabel"], outputs: ["checkedChange"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
2633
+ }
2634
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: UIPropertyInspector, decorators: [{
2635
+ type: Component,
2636
+ args: [{ selector: "ui-property-inspector", standalone: true, imports: [UIIcon, UIInput, UIDropdownList, UICheckbox], changeDetection: ChangeDetectionStrategy.OnPush, hostDirectives: [{ directive: UISurface, inputs: ["surfaceType"] }], host: { class: "ui-property-inspector" }, template: "@if (engine().selection(); as sel) {\n @switch (sel.kind) {\n @case (\"form\") {\n <h3 class=\"heading\">Form Properties</h3>\n <div class=\"section\">\n <div class=\"label\">\n ID\n <ui-input\n [value]=\"engine().formId()\"\n (valueChange)=\"engine().formId.set($event)\"\n ariaLabel=\"Form ID\"\n />\n </div>\n\n <div class=\"label\">\n Title\n <ui-input\n [value]=\"engine().formTitle()\"\n (valueChange)=\"engine().formTitle.set($event)\"\n ariaLabel=\"Form title\"\n />\n </div>\n\n <div class=\"label\">\n Description\n <ui-input\n [multiline]=\"true\"\n [rows]=\"2\"\n [value]=\"engine().formDescription()\"\n (valueChange)=\"engine().formDescription.set($event)\"\n ariaLabel=\"Form description\"\n />\n </div>\n </div>\n }\n\n @case (\"group\") {\n @if (engine().selectedGroup(); as group) {\n <h3 class=\"heading\">Group Properties</h3>\n <div class=\"section\">\n <div class=\"label\">\n ID\n <ui-input\n [value]=\"group.id()\"\n (valueChange)=\"group.id.set($event)\"\n ariaLabel=\"Group ID\"\n />\n </div>\n\n <div class=\"label\">\n Title\n <ui-input\n [value]=\"group.title()\"\n (valueChange)=\"group.title.set($event)\"\n ariaLabel=\"Group title\"\n />\n </div>\n\n <div class=\"label\">\n Description\n <ui-input\n [multiline]=\"true\"\n [rows]=\"2\"\n [value]=\"group.description()\"\n (valueChange)=\"group.description.set($event)\"\n ariaLabel=\"Group description\"\n />\n </div>\n </div>\n }\n }\n\n @case (\"field\") {\n @if (engine().selectedField(); as field) {\n <h3 class=\"heading\">Field Properties</h3>\n <div class=\"section\">\n <div class=\"label\">\n ID\n <ui-input\n [value]=\"field.id()\"\n (valueChange)=\"field.id.set($event)\"\n ariaLabel=\"Field ID\"\n />\n </div>\n\n @if (!isFieldFlair(field)) {\n <div class=\"label\">\n Title\n <ui-input\n [value]=\"field.title()\"\n (valueChange)=\"field.title.set($event)\"\n ariaLabel=\"Field title\"\n />\n </div>\n\n <div class=\"label\">\n Description\n <ui-input\n [multiline]=\"true\"\n [rows]=\"2\"\n [value]=\"field.description()\"\n (valueChange)=\"field.description.set($event)\"\n ariaLabel=\"Field description\"\n />\n </div>\n }\n\n <div class=\"label\">\n Component\n <ui-dropdown-list\n [options]=\"componentOptions\"\n [value]=\"field.component()\"\n (valueChange)=\"field.component.set($event)\"\n ariaLabel=\"Field component type\"\n />\n </div>\n\n @if (!isFieldFlair(field)) {\n <div class=\"label\">\n Default Value\n <ui-input\n [value]=\"'' + (field.defaultValue() ?? '')\"\n (valueChange)=\"field.defaultValue.set($event || null)\"\n ariaLabel=\"Default value\"\n />\n </div>\n }\n </div>\n\n <!-- Options (for select/radio/autocomplete) -->\n @if (!isFieldFlair(field) && showOptions(field)) {\n <h4 class=\"subheading\">Options</h4>\n <div class=\"section\">\n @for (opt of field.options(); track $index; let i = $index) {\n <div class=\"pi-option-row\">\n <ui-input\n [value]=\"opt.label\"\n (valueChange)=\"updateOption(field, i, 'label', $event)\"\n placeholder=\"Label\"\n ariaLabel=\"Option label\"\n />\n <ui-input\n [value]=\"'' + opt.value\"\n (valueChange)=\"updateOption(field, i, 'value', $event)\"\n placeholder=\"Value\"\n ariaLabel=\"Option value\"\n />\n <button\n type=\"button\"\n class=\"icon-btn icon-btn--danger\"\n aria-label=\"Remove option\"\n (click)=\"removeOption(field, i)\"\n >\n <ui-icon [svg]=\"iconX\" [size]=\"12\" aria-hidden=\"true\" />\n </button>\n </div>\n }\n <button\n type=\"button\"\n class=\"add-btn\"\n (click)=\"addOption(field)\"\n >\n + Add option\n </button>\n </div>\n }\n\n <!-- Validation -->\n @if (!isFieldFlair(field)) {\n <h4 class=\"subheading\">Validation</h4>\n <div class=\"section\">\n @for (\n rule of field.validation();\n track $index;\n let i = $index\n ) {\n <div class=\"validation-row\">\n <ui-dropdown-list\n [options]=\"validationTypeOptions\"\n [value]=\"rule.type\"\n (valueChange)=\"\n updateValidationRule(field, i, 'type', $event)\n \"\n ariaLabel=\"Validation rule type\"\n />\n\n @if (ruleHasParam(rule.type)) {\n <ui-input\n [value]=\"ruleParamValue(rule)\"\n (valueChange)=\"\n updateValidationParam(field, i, rule.type, $event)\n \"\n [placeholder]=\"paramPlaceholder(rule.type)\"\n ariaLabel=\"Validation parameter\"\n />\n }\n\n <ui-input\n [value]=\"rule.message ?? ''\"\n (valueChange)=\"\n updateValidationRule(field, i, 'message', $event)\n \"\n placeholder=\"Error message\"\n ariaLabel=\"Validation error message\"\n />\n\n <button\n type=\"button\"\n class=\"icon-btn icon-btn--danger\"\n aria-label=\"Remove validation rule\"\n (click)=\"removeValidationRule(field, i)\"\n >\n <ui-icon [svg]=\"iconX\" [size]=\"12\" aria-hidden=\"true\" />\n </button>\n </div>\n }\n <button\n type=\"button\"\n class=\"add-btn\"\n (click)=\"addValidationRule(field)\"\n >\n + Add rule\n </button>\n </div>\n }\n\n <!-- Config -->\n @if (configSchemaFor(field).length > 0) {\n <h4 class=\"subheading\">Config</h4>\n <div class=\"section\">\n @for (prop of configSchemaFor(field); track prop.key) {\n @if (!prop.visibleWhen || prop.visibleWhen(field.config())) {\n @switch (prop.editor) {\n @case (\"text\") {\n <div class=\"label\">\n {{ prop.label }}\n <ui-input\n [placeholder]=\"prop.placeholder ?? ''\"\n [value]=\"'' + (configValue(field, prop.key) ?? '')\"\n (valueChange)=\"\n setConfigValue(field, prop.key, $event)\n \"\n [ariaLabel]=\"prop.label\"\n />\n </div>\n }\n\n @case (\"number\") {\n <div class=\"label\">\n {{ prop.label }}\n <ui-input\n type=\"number\"\n [placeholder]=\"prop.placeholder ?? ''\"\n [value]=\"'' + (configValue(field, prop.key) ?? '')\"\n (valueChange)=\"\n setConfigNumber(field, prop.key, $event)\n \"\n [ariaLabel]=\"prop.label\"\n />\n </div>\n }\n\n @case (\"boolean\") {\n <ui-checkbox\n [checked]=\"!!configValue(field, prop.key)\"\n (checkedChange)=\"\n setConfigBoolean(field, prop.key, $event)\n \"\n [ariaLabel]=\"prop.label\"\n >\n {{ prop.label }}\n </ui-checkbox>\n }\n\n @case (\"select\") {\n <div class=\"label\">\n {{ prop.label }}\n <ui-dropdown-list\n [options]=\"configSelectOptions(prop)\"\n [value]=\"'' + (configValue(field, prop.key) ?? '')\"\n (valueChange)=\"\n setConfigValue(field, prop.key, $event)\n \"\n [ariaLabel]=\"prop.label\"\n />\n </div>\n }\n\n @case (\"richtext\") {\n <div class=\"label\">\n {{ prop.label }}\n <button\n type=\"button\"\n class=\"edit-content-btn\"\n (click)=\"openRichTextEditor(field, prop.key)\"\n >\n <ui-icon [svg]=\"iconEdit\" [size]=\"14\" aria-hidden=\"true\" />\n Edit Content\n </button>\n </div>\n }\n }\n }\n }\n </div>\n }\n\n <!-- Extra config (raw JSON, for properties not in the schema) -->\n @if (hasExtraConfig(field)) {\n <h4 class=\"subheading\">Extra Config (JSON)</h4>\n <div class=\"section\">\n <textarea\n class=\"textarea textarea--mono\"\n rows=\"3\"\n aria-label=\"Extra configuration JSON\"\n aria-describedby=\"pi-config-error\"\n [attr.aria-invalid]=\"configError() ? 'true' : null\"\n [value]=\"extraConfigJSON(field)\"\n (change)=\"onExtraConfigChange(field, $event)\"\n ></textarea>\n @if (configError()) {\n <p class=\"error\" id=\"pi-config-error\" role=\"alert\">\n {{ configError() }}\n </p>\n }\n </div>\n }\n }\n }\n }\n} @else {\n <div class=\"empty\">\n <p>Select a field, group, or form header to edit its properties.</p>\n </div>\n}\n", styles: [":host{display:flex;flex-direction:column;padding:12px;overflow-y:auto;min-width:260px;max-width:320px}.heading{font-size:.75rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;margin:0 0 12px;opacity:.6}.subheading{font-size:.75rem;font-weight:700;text-transform:uppercase;letter-spacing:.04em;margin:16px 0 8px;opacity:.5}.section{display:flex;flex-direction:column;gap:8px}.label{display:flex;flex-direction:column;gap:3px;font-size:.75rem;font-weight:600}.label--row{flex-direction:row;align-items:center;gap:6px;cursor:pointer}.input,.textarea,.select{appearance:none;border-radius:5px;padding:6px 8px;font-size:.8125rem;font-family:inherit}.input:focus,.textarea:focus,.select:focus{outline-offset:-1px}.input--sm,.select--sm{padding:4px 6px;font-size:.75rem}.textarea--mono{font-family:monospace;font-size:.75rem}.pi-option-row,.validation-row{display:flex;gap:4px;align-items:center}.pi-option-row .input{flex:1}.validation-row>*{flex:1;min-width:0}.validation-row .icon-btn{flex:0 0 auto}.icon-btn{appearance:none;border:none;cursor:pointer;font-size:.75rem;width:24px;height:24px;display:inline-flex;align-items:center;justify-content:center;border-radius:4px}.add-btn{appearance:none;border-radius:5px;padding:5px 8px;cursor:pointer;font-size:.75rem;font-weight:500}.edit-content-btn{appearance:none;display:inline-flex;align-items:center;gap:6px;border-radius:5px;padding:5px 10px;cursor:pointer;font-size:.75rem;font-weight:500;font-family:inherit;transition:background-color 80ms ease,color 80ms ease}.error{font-size:.75rem;margin:2px 0 0}.empty{flex:1;display:flex;align-items:center;justify-content:center;text-align:center;font-size:.8125rem;padding:24px}\n"] }]
2637
+ }], propDecorators: { engine: [{ type: i0.Input, args: [{ isSignal: true, alias: "engine", required: true }] }] } });
2638
+
2639
+ // ── UIFormDesigner ──────────────────────────────────────────────────
2640
+ /**
2641
+ * Full-featured form designer that lets users visually build
2642
+ * a {@link FormSchema} via a palette + canvas + inspector layout.
2643
+ *
2644
+ * Includes a live preview panel that renders the designed form
2645
+ * using {@link UIForm} and {@link FormEngine}.
2646
+ *
2647
+ * @example
2648
+ * ```html
2649
+ * <ui-form-designer
2650
+ * [schema]="existingSchema"
2651
+ * (schemaChange)="onSave($event)"
2652
+ * />
2653
+ * ```
2654
+ */
2655
+ class UIFormDesigner {
2656
+ /**
2657
+ * Optional initial schema to load into the designer.
2658
+ * When set, the designer engine imports it on init.
2659
+ */
2660
+ schema = input(null, ...(ngDevMode ? [{ debugName: "schema" }] : []));
2661
+ /** Emitted when the user clicks "Export" — always emits the raw schema. */
2662
+ schemaChange = output();
2663
+ /** @internal Icon SVG for the copy button. */
2664
+ copyIcon = UIIcons.Lucide.Text.Copy;
2665
+ /** @internal Active tab: design, preview, or json. */
2666
+ activeTab = signal("design", ...(ngDevMode ? [{ debugName: "activeTab" }] : []));
2667
+ /** @internal The designer engine instance. */
2668
+ designerEngine = new FormDesignerEngine();
2669
+ /**
2670
+ * @internal Preview engine — rebuilt whenever the schema changes.
2671
+ * Returns null if the schema has no groups/fields.
2672
+ */
2673
+ previewEngine = computed(() => {
2674
+ const schema = this.designerEngine.schema();
2675
+ const hasFields = schema.groups.some((g) => g.fields.length > 0);
2676
+ if (!hasFields)
2677
+ return null;
2678
+ return new FormEngine(schema);
2679
+ }, ...(ngDevMode ? [{ debugName: "previewEngine" }] : []));
2680
+ /** @internal Live output from the preview engine. */
2681
+ previewOutput = computed(() => {
2682
+ const eng = this.previewEngine();
2683
+ return eng ? eng.output()() : null;
2684
+ }, ...(ngDevMode ? [{ debugName: "previewOutput" }] : []));
2685
+ /** @internal The group uid to add a field to when palette is clicked. */
2686
+ lastGroupUid = null;
2687
+ constructor() {
2688
+ // Load initial schema when the input is set
2689
+ effect(() => {
2690
+ const s = this.schema();
2691
+ if (s) {
2692
+ this.designerEngine.loadSchema(s);
2693
+ }
2694
+ });
2695
+ }
2696
+ /** @internal Handle palette field click — add to first or last-used group. */
2697
+ onFieldRequested(componentKey) {
2698
+ let groups = this.designerEngine.groups();
2699
+ // If there are no groups, create one first
2700
+ if (groups.length === 0) {
2701
+ this.lastGroupUid = this.designerEngine.addGroup();
2702
+ groups = this.designerEngine.groups();
2703
+ }
2704
+ // Find the target group
2705
+ const targetUid = this.lastGroupUid && groups.find((g) => g.uid === this.lastGroupUid)
2706
+ ? this.lastGroupUid
2707
+ : (groups[groups.length - 1]?.uid ?? this.designerEngine.addGroup());
2708
+ this.designerEngine.addField(targetUid, componentKey);
2709
+ this.lastGroupUid = targetUid;
2710
+ }
2711
+ /** @internal Handle "Add field" click within a specific group. */
2712
+ onAddFieldToGroup(groupUid) {
2713
+ // Default to text field — user can change in inspector
2714
+ this.designerEngine.addField(groupUid, "text");
2715
+ this.lastGroupUid = groupUid;
2716
+ }
2717
+ /** @internal Copy the current JSON schema to the clipboard. */
2718
+ onCopyJson() {
2719
+ const json = JSON.stringify(this.designerEngine.schema(), null, 2);
2720
+ navigator.clipboard.writeText(json);
2721
+ }
2722
+ /** @internal Show submitted values in a native dialog. */
2723
+ onPreviewSubmit(values) {
2724
+ const dialog = document.createElement("dialog");
2725
+ dialog.style.cssText =
2726
+ "border:none;border-radius:12px;padding:24px;max-width:min(90vw,560px);" +
2727
+ "max-height:85vh;display:flex;flex-direction:column;background:var(--ui-surface,#fff);" +
2728
+ "color:var(--ui-text,#1d232b);box-shadow:0 8px 32px rgba(0,0,0,0.25);font-family:inherit;";
2729
+ const heading = document.createElement("h2");
2730
+ heading.textContent = "Submitted Values";
2731
+ heading.style.cssText =
2732
+ "margin:0 0 16px;font-size:1.125rem;font-weight:600;";
2733
+ const pre = document.createElement("pre");
2734
+ pre.textContent = JSON.stringify(values, null, 2);
2735
+ pre.style.cssText =
2736
+ "margin:0;padding:12px;border-radius:8px;background:rgba(0,0,0,0.06);" +
2737
+ "font-size:0.8125rem;overflow:auto;flex:1;min-height:200px;white-space:pre-wrap;word-break:break-word;";
2738
+ const footer = document.createElement("div");
2739
+ footer.style.cssText =
2740
+ "display:flex;justify-content:flex-end;margin-top:16px;";
2741
+ const btn = document.createElement("button");
2742
+ btn.textContent = "Close";
2743
+ btn.style.cssText =
2744
+ "appearance:none;border:none;border-radius:8px;padding:8px 20px;" +
2745
+ "font-size:0.875rem;font-weight:600;cursor:pointer;" +
2746
+ "background:var(--theredhead-primary,#3584e4);color:var(--theredhead-on-primary,#fff);";
2747
+ btn.addEventListener("click", () => {
2748
+ dialog.close();
2749
+ dialog.remove();
2750
+ });
2751
+ footer.appendChild(btn);
2752
+ dialog.append(heading, pre, footer);
2753
+ dialog.addEventListener("click", (e) => {
2754
+ if (e.target === dialog) {
2755
+ dialog.close();
2756
+ dialog.remove();
2757
+ }
2758
+ });
2759
+ dialog.addEventListener("close", () => dialog.remove());
2760
+ document.body.appendChild(dialog);
2761
+ dialog.showModal();
2762
+ }
2763
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: UIFormDesigner, deps: [], target: i0.ɵɵFactoryTarget.Component });
2764
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.1.0", type: UIFormDesigner, isStandalone: true, selector: "ui-form-designer", inputs: { schema: { classPropertyName: "schema", publicName: "schema", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { schemaChange: "schemaChange" }, host: { classAttribute: "ui-form-designer" }, hostDirectives: [{ directive: i1.UISurface, inputs: ["surfaceType", "surfaceType"] }], ngImport: i0, template: "<!-- Toolbar -->\n<header class=\"toolbar\">\n <span class=\"toolbar-title\">Form Designer</span>\n\n <div\n class=\"toolbar-actions\"\n role=\"tablist\"\n aria-label=\"Designer views\"\n >\n <button\n type=\"button\"\n role=\"tab\"\n class=\"toolbar-btn\"\n [class.toolbar-btn--active]=\"activeTab() === 'design'\"\n [attr.aria-selected]=\"activeTab() === 'design'\"\n aria-controls=\"fd-tabpanel\"\n (click)=\"activeTab.set('design')\"\n >\n Design\n </button>\n <button\n type=\"button\"\n role=\"tab\"\n class=\"toolbar-btn\"\n [class.toolbar-btn--active]=\"activeTab() === 'preview'\"\n [attr.aria-selected]=\"activeTab() === 'preview'\"\n aria-controls=\"fd-tabpanel\"\n (click)=\"activeTab.set('preview')\"\n >\n Preview\n </button>\n <button\n type=\"button\"\n role=\"tab\"\n class=\"toolbar-btn\"\n [class.toolbar-btn--active]=\"activeTab() === 'json'\"\n [attr.aria-selected]=\"activeTab() === 'json'\"\n aria-controls=\"fd-tabpanel\"\n (click)=\"activeTab.set('json')\"\n >\n JSON\n </button>\n </div>\n</header>\n\n<!-- Content area -->\n<div\n class=\"body\"\n id=\"fd-tabpanel\"\n role=\"tabpanel\"\n [attr.aria-label]=\"activeTab() + ' view'\"\n>\n @switch (activeTab()) {\n @case (\"design\") {\n <ui-field-palette\n class=\"palette\"\n (fieldRequested)=\"onFieldRequested($event)\"\n />\n <ui-designer-canvas\n class=\"canvas\"\n [engine]=\"designerEngine\"\n (addFieldRequest)=\"onAddFieldToGroup($event)\"\n />\n <ui-property-inspector\n class=\"inspector\"\n [engine]=\"designerEngine\"\n />\n }\n\n @case (\"preview\") {\n <div class=\"preview\">\n @if (previewEngine()) {\n <ui-form\n [engine]=\"previewEngine()!\"\n submitLabel=\"Submit (Preview)\"\n (formSubmit)=\"onPreviewSubmit($event)\"\n />\n <div class=\"preview-live\">\n <h4 class=\"preview-live-heading\">\n Live output\n <span\n class=\"preview-live-badge\"\n [class.preview-live-badge--valid]=\"\n previewEngine()!.valid()\n \"\n >\n {{ previewEngine()!.valid() ? \"\u2713 valid\" : \"\u2717 invalid\" }}\n </span>\n </h4>\n <pre class=\"preview-live-json\">{{\n previewOutput() | json\n }}</pre>\n </div>\n } @else {\n <p class=\"preview-empty\">\n Add some fields to see the preview.\n </p>\n }\n </div>\n }\n\n @case (\"json\") {\n <div class=\"json\">\n <div class=\"json-header\">\n <button\n type=\"button\"\n class=\"copy-btn\"\n aria-label=\"Copy JSON to clipboard\"\n (click)=\"onCopyJson()\"\n >\n <ui-icon [svg]=\"copyIcon\" [size]=\"14\" />\n </button>\n </div>\n <pre class=\"json-pre\">{{ designerEngine.schema() | json }}</pre>\n </div>\n }\n }\n</div>\n", styles: ["@charset \"UTF-8\";:host{display:flex;flex-direction:column;height:100%;min-height:500px;border-radius:8px;overflow:hidden}.toolbar{display:flex;align-items:center;gap:8px;padding:8px 12px}.toolbar-title{font-weight:700;font-size:.9375rem;margin-right:auto}.toolbar-actions{display:flex;gap:2px;border-radius:6px;padding:2px}.toolbar-btn{appearance:none;border:none;padding:5px 12px;border-radius:4px;font-size:.8125rem;font-weight:500;cursor:pointer;transition:background .12s ease}.toolbar-btn--active:hover{opacity:.9}.body{display:flex;flex:1;overflow:hidden}.palette{flex:0 0 auto}.canvas{flex:1}.inspector{flex:0 0 auto}.preview{flex:1;padding:24px;overflow-y:auto;max-width:720px;margin:0 auto;width:100%;border-radius:12px}.preview-empty{text-align:center;font-size:.9375rem;padding:48px 16px;font-weight:500}.preview-live{margin-top:20px;padding-top:12px}.preview-live-heading{margin:0 0 4px;font-size:.8125rem;font-weight:600;text-transform:uppercase;letter-spacing:.05em;opacity:.6;display:flex;align-items:center;gap:8px}.preview-live-badge{font-size:.75rem;font-weight:500;text-transform:none;letter-spacing:0;padding:1px 8px;border-radius:10px}.preview-live-json{margin:0;padding:12px;border-radius:8px;font-size:.8125rem;overflow:auto;max-height:300px}.json{flex:1;padding:16px;overflow:auto}.json-header{display:flex;justify-content:flex-end;margin-bottom:8px}.copy-btn{appearance:none;padding:4px 8px;border-radius:4px;cursor:pointer;display:inline-flex;align-items:center;gap:4px;transition:background .12s ease}.json-pre{font-family:monospace;font-size:.8125rem;white-space:pre-wrap;word-break:break-word;margin:0}:host-context(html.dark-theme) .toolbar-btn--active:hover{opacity:.9}@media(prefers-color-scheme:dark){:host-context(html:not(.light-theme):not(.dark-theme)) .toolbar-btn--active:hover{opacity:.9}}\n"], dependencies: [{ kind: "component", type: UIIcon, selector: "ui-icon", inputs: ["svg", "size", "ariaLabel"] }, { kind: "component", type: UIFieldPalette, selector: "ui-field-palette", outputs: ["fieldRequested"] }, { kind: "component", type: UIDesignerCanvas, selector: "ui-designer-canvas", inputs: ["engine"], outputs: ["addFieldRequest"] }, { kind: "component", type: UIPropertyInspector, selector: "ui-property-inspector", inputs: ["engine"] }, { kind: "component", type: UIForm, selector: "ui-form", inputs: ["engine", "submitLabel", "showSubmit", "fieldMinWidth"], outputs: ["formSubmit"] }, { kind: "pipe", type: JsonPipe, name: "json" }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
2765
+ }
2766
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.1.0", ngImport: i0, type: UIFormDesigner, decorators: [{
2767
+ type: Component,
2768
+ args: [{ selector: "ui-form-designer", standalone: true, imports: [
2769
+ JsonPipe,
2770
+ UIIcon,
2771
+ UIFieldPalette,
2772
+ UIDesignerCanvas,
2773
+ UIPropertyInspector,
2774
+ UIForm,
2775
+ ], changeDetection: ChangeDetectionStrategy.OnPush, hostDirectives: [{ directive: UISurface, inputs: ['surfaceType'] }], host: { class: "ui-form-designer" }, template: "<!-- Toolbar -->\n<header class=\"toolbar\">\n <span class=\"toolbar-title\">Form Designer</span>\n\n <div\n class=\"toolbar-actions\"\n role=\"tablist\"\n aria-label=\"Designer views\"\n >\n <button\n type=\"button\"\n role=\"tab\"\n class=\"toolbar-btn\"\n [class.toolbar-btn--active]=\"activeTab() === 'design'\"\n [attr.aria-selected]=\"activeTab() === 'design'\"\n aria-controls=\"fd-tabpanel\"\n (click)=\"activeTab.set('design')\"\n >\n Design\n </button>\n <button\n type=\"button\"\n role=\"tab\"\n class=\"toolbar-btn\"\n [class.toolbar-btn--active]=\"activeTab() === 'preview'\"\n [attr.aria-selected]=\"activeTab() === 'preview'\"\n aria-controls=\"fd-tabpanel\"\n (click)=\"activeTab.set('preview')\"\n >\n Preview\n </button>\n <button\n type=\"button\"\n role=\"tab\"\n class=\"toolbar-btn\"\n [class.toolbar-btn--active]=\"activeTab() === 'json'\"\n [attr.aria-selected]=\"activeTab() === 'json'\"\n aria-controls=\"fd-tabpanel\"\n (click)=\"activeTab.set('json')\"\n >\n JSON\n </button>\n </div>\n</header>\n\n<!-- Content area -->\n<div\n class=\"body\"\n id=\"fd-tabpanel\"\n role=\"tabpanel\"\n [attr.aria-label]=\"activeTab() + ' view'\"\n>\n @switch (activeTab()) {\n @case (\"design\") {\n <ui-field-palette\n class=\"palette\"\n (fieldRequested)=\"onFieldRequested($event)\"\n />\n <ui-designer-canvas\n class=\"canvas\"\n [engine]=\"designerEngine\"\n (addFieldRequest)=\"onAddFieldToGroup($event)\"\n />\n <ui-property-inspector\n class=\"inspector\"\n [engine]=\"designerEngine\"\n />\n }\n\n @case (\"preview\") {\n <div class=\"preview\">\n @if (previewEngine()) {\n <ui-form\n [engine]=\"previewEngine()!\"\n submitLabel=\"Submit (Preview)\"\n (formSubmit)=\"onPreviewSubmit($event)\"\n />\n <div class=\"preview-live\">\n <h4 class=\"preview-live-heading\">\n Live output\n <span\n class=\"preview-live-badge\"\n [class.preview-live-badge--valid]=\"\n previewEngine()!.valid()\n \"\n >\n {{ previewEngine()!.valid() ? \"\u2713 valid\" : \"\u2717 invalid\" }}\n </span>\n </h4>\n <pre class=\"preview-live-json\">{{\n previewOutput() | json\n }}</pre>\n </div>\n } @else {\n <p class=\"preview-empty\">\n Add some fields to see the preview.\n </p>\n }\n </div>\n }\n\n @case (\"json\") {\n <div class=\"json\">\n <div class=\"json-header\">\n <button\n type=\"button\"\n class=\"copy-btn\"\n aria-label=\"Copy JSON to clipboard\"\n (click)=\"onCopyJson()\"\n >\n <ui-icon [svg]=\"copyIcon\" [size]=\"14\" />\n </button>\n </div>\n <pre class=\"json-pre\">{{ designerEngine.schema() | json }}</pre>\n </div>\n }\n }\n</div>\n", styles: ["@charset \"UTF-8\";:host{display:flex;flex-direction:column;height:100%;min-height:500px;border-radius:8px;overflow:hidden}.toolbar{display:flex;align-items:center;gap:8px;padding:8px 12px}.toolbar-title{font-weight:700;font-size:.9375rem;margin-right:auto}.toolbar-actions{display:flex;gap:2px;border-radius:6px;padding:2px}.toolbar-btn{appearance:none;border:none;padding:5px 12px;border-radius:4px;font-size:.8125rem;font-weight:500;cursor:pointer;transition:background .12s ease}.toolbar-btn--active:hover{opacity:.9}.body{display:flex;flex:1;overflow:hidden}.palette{flex:0 0 auto}.canvas{flex:1}.inspector{flex:0 0 auto}.preview{flex:1;padding:24px;overflow-y:auto;max-width:720px;margin:0 auto;width:100%;border-radius:12px}.preview-empty{text-align:center;font-size:.9375rem;padding:48px 16px;font-weight:500}.preview-live{margin-top:20px;padding-top:12px}.preview-live-heading{margin:0 0 4px;font-size:.8125rem;font-weight:600;text-transform:uppercase;letter-spacing:.05em;opacity:.6;display:flex;align-items:center;gap:8px}.preview-live-badge{font-size:.75rem;font-weight:500;text-transform:none;letter-spacing:0;padding:1px 8px;border-radius:10px}.preview-live-json{margin:0;padding:12px;border-radius:8px;font-size:.8125rem;overflow:auto;max-height:300px}.json{flex:1;padding:16px;overflow:auto}.json-header{display:flex;justify-content:flex-end;margin-bottom:8px}.copy-btn{appearance:none;padding:4px 8px;border-radius:4px;cursor:pointer;display:inline-flex;align-items:center;gap:4px;transition:background .12s ease}.json-pre{font-family:monospace;font-size:.8125rem;white-space:pre-wrap;word-break:break-word;margin:0}:host-context(html.dark-theme) .toolbar-btn--active:hover{opacity:.9}@media(prefers-color-scheme:dark){:host-context(html:not(.light-theme):not(.dark-theme)) .toolbar-btn--active:hover{opacity:.9}}\n"] }]
2776
+ }], ctorParameters: () => [], propDecorators: { schema: [{ type: i0.Input, args: [{ isSignal: true, alias: "schema", required: false }] }], schemaChange: [{ type: i0.Output, args: ["schemaChange"] }] } });
2777
+
2778
+ // ── Designer barrel ─────────────────────────────────────────────────
2779
+
2780
+ /*
2781
+ * Public API Surface of @theredhead/lucid-forms
2782
+ */
2783
+
2784
+ /**
2785
+ * Generated bundle index. Do not edit.
2786
+ */
2787
+
2788
+ export { AngularComponentExportStrategy, BUILT_IN_FIELDS, FLAIR_COMPONENTS, FORM_FIELD_REGISTRATIONS, FormDesignerEngine, FormEngine, FormFieldRegistry, JsonExportStrategy, TEXT_ADAPTER_KEYS, UIDesignerCanvas, UIFieldPalette, UIForm, UIFormDesigner, UIFormField, UIFormGroup, UIFormWizard, UIPropertyInspector, evaluateCondition, isConditionGroup, isFlairComponent, provideBuiltInFormFields, provideFormFields, registerCustomValidator, resolveTextAdapter, validate };
2789
+ //# sourceMappingURL=theredhead-lucid-forms.mjs.map