@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
|