@pure-ds/core 0.4.18 → 0.4.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { LitElement, html, nothing, ifDefined, ref } from "#pds/lit";
1
+ import { LitElement, html, nothing, ifDefined, ref, keyed } from "#pds/lit";
2
2
 
3
3
  function getStep(value) {
4
4
  if (typeof value === "number") {
@@ -8,7 +8,7 @@ function getStep(value) {
8
8
  return "1"; // Default step for integers
9
9
  }
10
10
 
11
- // Default options for pds-jsonform
11
+ // Default options for pds-form
12
12
  const DEFAULT_OPTIONS = {
13
13
  widgets: {
14
14
  booleans: "toggle", // 'toggle' | 'checkbox'
@@ -31,33 +31,33 @@ const DEFAULT_OPTIONS = {
31
31
  };
32
32
 
33
33
  /**
34
- * <pds-jsonform>
34
+ * <pds-form>
35
35
  *
36
36
  * Form Actions:
37
37
  * By default, the form includes Submit and Reset buttons inside the <form> element.
38
38
  *
39
39
  * Usage options:
40
40
  * 1. Default buttons:
41
- * <pds-jsonform .jsonSchema=${schema}></pds-jsonform>
41
+ * <pds-form .jsonSchema=${schema}></pds-form>
42
42
  *
43
43
  * 2. Customize labels:
44
- * <pds-jsonform .jsonSchema=${schema} submit-label="Save" reset-label="Clear"></pds-jsonform>
44
+ * <pds-form .jsonSchema=${schema} submit-label="Save" reset-label="Clear"></pds-form>
45
45
  *
46
46
  * 3. Hide reset button:
47
- * <pds-jsonform .jsonSchema=${schema} hide-reset></pds-jsonform>
47
+ * <pds-form .jsonSchema=${schema} hide-reset></pds-form>
48
48
  *
49
49
  * 4. Add extra buttons (slot):
50
- * <pds-jsonform .jsonSchema=${schema}>
50
+ * <pds-form .jsonSchema=${schema}>
51
51
  * <button type="button" slot="actions" @click=${...}>Cancel</button>
52
- * </pds-jsonform>
52
+ * </pds-form>
53
53
  *
54
54
  * 5. Completely custom actions (hides default buttons):
55
- * <pds-jsonform .jsonSchema=${schema} hide-actions>
55
+ * <pds-form .jsonSchema=${schema} hide-actions>
56
56
  * <div slot="actions" class="flex gap-md">
57
57
  * <button type="submit" class="btn btn-primary">Custom Submit</button>
58
58
  * <button type="button" class="btn">Custom Action</button>
59
59
  * </div>
60
- * </pds-jsonform>
60
+ * </pds-form>
61
61
  */
62
62
  export class SchemaForm extends LitElement {
63
63
  static properties = {
@@ -89,6 +89,9 @@ export class SchemaForm extends LitElement {
89
89
  #idBase = `sf-${Math.random().toString(36).slice(2)}`;
90
90
  #mergedOptions = null;
91
91
  #slottedActions = [];
92
+ #dependencies = new Map(); // targetPath → Set of sourcePaths that affect it
93
+ #resetKey = 0; // Incremented on reset to force Lit to recreate DOM elements
94
+ #slottedContent = new Map(); // slot name → element for ui:before/ui:after slot references
92
95
 
93
96
  constructor() {
94
97
  super();
@@ -156,12 +159,26 @@ export class SchemaForm extends LitElement {
156
159
  return this.#onSubmit(new Event("submit", { cancelable: true }));
157
160
  }
158
161
 
162
+ reset() {
163
+ const form = this.renderRoot?.querySelector("form");
164
+ if (form) form.reset(); // Triggers native reset which calls #onReset
165
+ else this.#onReset(new Event("reset")); // Manual fallback
166
+ }
167
+
159
168
  connectedCallback() {
160
169
  super.connectedCallback();
161
- // Capture slotted actions before first render (Light DOM doesn't support native slots)
170
+ // Capture slotted elements before first render (Light DOM doesn't support native slots)
162
171
  this.#slottedActions = Array.from(
163
172
  this.querySelectorAll('[slot="actions"]')
164
173
  );
174
+ // Capture all named slots for ui:before/ui:after references
175
+ this.#slottedContent = new Map();
176
+ this.querySelectorAll('[slot]').forEach((el) => {
177
+ const slotName = el.getAttribute('slot');
178
+ if (slotName && slotName !== 'actions') {
179
+ this.#slottedContent.set(slotName, el);
180
+ }
181
+ });
165
182
  }
166
183
 
167
184
  // ===== Lit lifecycle =====
@@ -234,6 +251,212 @@ export class SchemaForm extends LitElement {
234
251
  return current !== undefined ? current : defaultValue;
235
252
  }
236
253
 
254
+ // ===== Condition Evaluation =====
255
+ /**
256
+ * Evaluate a condition object against current form data.
257
+ * Supports:
258
+ * - Simple equality: { "/path": "value" }
259
+ * - Operators: { "/path": { "$eq": "value" } }
260
+ * - Multiple conditions (implicit AND): { "/a": "x", "/b": "y" }
261
+ * - Explicit logical: { "$and": [...] }, { "$or": [...] }, { "$not": {...} }
262
+ *
263
+ * Operators: $eq, $ne, $in, $nin, $gt, $gte, $lt, $lte, $exists, $regex
264
+ */
265
+ #evaluateCondition(condition) {
266
+ if (!condition || typeof condition !== "object") return true;
267
+
268
+ // Handle logical operators
269
+ if ("$and" in condition) {
270
+ return condition.$and.every((c) => this.#evaluateCondition(c));
271
+ }
272
+ if ("$or" in condition) {
273
+ return condition.$or.some((c) => this.#evaluateCondition(c));
274
+ }
275
+ if ("$not" in condition) {
276
+ return !this.#evaluateCondition(condition.$not);
277
+ }
278
+
279
+ // Multiple path conditions = implicit AND
280
+ for (const [key, expected] of Object.entries(condition)) {
281
+ if (key.startsWith("$")) continue; // Skip operators at root
282
+ const actualValue = this.#getByPath(this.#data, key);
283
+ if (!this.#matchValue(actualValue, expected)) return false;
284
+ }
285
+ return true;
286
+ }
287
+
288
+ #matchValue(actual, expected) {
289
+ // Simple equality shorthand
290
+ if (expected === null || typeof expected !== "object" || Array.isArray(expected)) {
291
+ return actual === expected;
292
+ }
293
+
294
+ // Operator object
295
+ for (const [op, operand] of Object.entries(expected)) {
296
+ switch (op) {
297
+ case "$eq":
298
+ if (actual !== operand) return false;
299
+ break;
300
+ case "$ne":
301
+ if (actual === operand) return false;
302
+ break;
303
+ case "$in":
304
+ if (!Array.isArray(operand) || !operand.includes(actual)) return false;
305
+ break;
306
+ case "$nin":
307
+ if (Array.isArray(operand) && operand.includes(actual)) return false;
308
+ break;
309
+ case "$gt":
310
+ if (!(actual > operand)) return false;
311
+ break;
312
+ case "$gte":
313
+ if (!(actual >= operand)) return false;
314
+ break;
315
+ case "$lt":
316
+ if (!(actual < operand)) return false;
317
+ break;
318
+ case "$lte":
319
+ if (!(actual <= operand)) return false;
320
+ break;
321
+ case "$exists":
322
+ if (operand && actual === undefined) return false;
323
+ if (!operand && actual !== undefined) return false;
324
+ break;
325
+ case "$regex": {
326
+ const regex = operand instanceof RegExp ? operand : new RegExp(operand);
327
+ if (!regex.test(String(actual ?? ""))) return false;
328
+ break;
329
+ }
330
+ default:
331
+ // Unknown operator, ignore
332
+ break;
333
+ }
334
+ }
335
+ return true;
336
+ }
337
+
338
+ /**
339
+ * Evaluate a calculate expression to compute a derived value.
340
+ * Supports:
341
+ * - Path reference: "/path" → returns value at path
342
+ * - Concatenation: { "$concat": ["/firstName", " ", "/lastName"] }
343
+ * - Math: { "$sum": ["/a", "/b"] }, { "$multiply": ["/a", 2] }
344
+ * - Conditional: { "$if": { "cond": {...}, "then": expr, "else": expr } }
345
+ */
346
+ #evaluateCalculation(expr) {
347
+ if (expr === null || expr === undefined) return undefined;
348
+
349
+ // String path reference
350
+ if (typeof expr === "string") {
351
+ if (expr.startsWith("/")) {
352
+ return this.#getByPath(this.#data, expr);
353
+ }
354
+ return expr; // Literal string
355
+ }
356
+
357
+ // Number or boolean literal
358
+ if (typeof expr !== "object") return expr;
359
+
360
+ // Array = literal array
361
+ if (Array.isArray(expr)) {
362
+ return expr.map((e) => this.#evaluateCalculation(e));
363
+ }
364
+
365
+ // Operators
366
+ if ("$concat" in expr) {
367
+ return expr.$concat
368
+ .map((e) => this.#evaluateCalculation(e) ?? "")
369
+ .join("");
370
+ }
371
+ if ("$sum" in expr) {
372
+ return expr.$sum.reduce(
373
+ (acc, e) => acc + (Number(this.#evaluateCalculation(e)) || 0),
374
+ 0
375
+ );
376
+ }
377
+ if ("$multiply" in expr) {
378
+ return expr.$multiply.reduce(
379
+ (acc, e) => acc * (Number(this.#evaluateCalculation(e)) || 0),
380
+ 1
381
+ );
382
+ }
383
+ if ("$subtract" in expr) {
384
+ const [a, b] = expr.$subtract;
385
+ return (Number(this.#evaluateCalculation(a)) || 0) - (Number(this.#evaluateCalculation(b)) || 0);
386
+ }
387
+ if ("$divide" in expr) {
388
+ const [a, b] = expr.$divide;
389
+ const divisor = Number(this.#evaluateCalculation(b)) || 1;
390
+ return (Number(this.#evaluateCalculation(a)) || 0) / divisor;
391
+ }
392
+ if ("$if" in expr) {
393
+ const { cond, then: thenExpr, else: elseExpr } = expr.$if;
394
+ return this.#evaluateCondition(cond)
395
+ ? this.#evaluateCalculation(thenExpr)
396
+ : this.#evaluateCalculation(elseExpr);
397
+ }
398
+ if ("$coalesce" in expr) {
399
+ for (const e of expr.$coalesce) {
400
+ const val = this.#evaluateCalculation(e);
401
+ if (val !== null && val !== undefined && val !== "") return val;
402
+ }
403
+ return null;
404
+ }
405
+
406
+ // Unknown object, return as-is
407
+ return expr;
408
+ }
409
+
410
+ /**
411
+ * Extract all path dependencies from a condition or calculation expression.
412
+ */
413
+ #extractDependencies(expr, deps = new Set()) {
414
+ if (!expr) return deps;
415
+
416
+ if (typeof expr === "string" && expr.startsWith("/")) {
417
+ deps.add(expr);
418
+ return deps;
419
+ }
420
+
421
+ if (Array.isArray(expr)) {
422
+ for (const e of expr) this.#extractDependencies(e, deps);
423
+ return deps;
424
+ }
425
+
426
+ if (typeof expr === "object") {
427
+ for (const [key, value] of Object.entries(expr)) {
428
+ if (key.startsWith("/")) {
429
+ deps.add(key);
430
+ }
431
+ this.#extractDependencies(value, deps);
432
+ }
433
+ }
434
+
435
+ return deps;
436
+ }
437
+
438
+ /**
439
+ * Register dependencies for a target path based on its UI conditions.
440
+ */
441
+ #registerDependencies(targetPath, ui) {
442
+ const deps = new Set();
443
+
444
+ // Extract from all condition properties
445
+ for (const prop of ["ui:visibleWhen", "ui:disabledWhen", "ui:requiredWhen", "ui:calculate"]) {
446
+ if (ui?.[prop]) {
447
+ this.#extractDependencies(ui[prop], deps);
448
+ }
449
+ }
450
+
451
+ // Register reverse mapping: when source changes, target needs update
452
+ for (const sourcePath of deps) {
453
+ if (!this.#dependencies.has(sourcePath)) {
454
+ this.#dependencies.set(sourcePath, new Set());
455
+ }
456
+ this.#dependencies.get(sourcePath).add(targetPath);
457
+ }
458
+ }
459
+
237
460
  // ===== Schema compilation =====
238
461
  #compile() {
239
462
  const root = this.jsonSchema;
@@ -241,6 +464,9 @@ export class SchemaForm extends LitElement {
241
464
  this.#compiled = null;
242
465
  return;
243
466
  }
467
+ // Clear dependency graph for fresh build
468
+ this.#dependencies.clear();
469
+
244
470
  const resolved =
245
471
  this.#emitCancelable("pw:schema-resolve", { schema: root })?.schema ||
246
472
  root;
@@ -252,6 +478,12 @@ export class SchemaForm extends LitElement {
252
478
  #compileNode(schema, path) {
253
479
  const title = schema.title ?? this.#titleFromPath(path);
254
480
  const ui = this.#uiFor(path);
481
+
482
+ // Register dependencies for conditional rendering
483
+ if (ui) {
484
+ this.#registerDependencies(path, ui);
485
+ }
486
+
255
487
  const custom = this.#emitCancelable("pw:compile-node", {
256
488
  path,
257
489
  schema,
@@ -259,7 +491,16 @@ export class SchemaForm extends LitElement {
259
491
  });
260
492
  if (custom?.node) return custom.node;
261
493
 
494
+ // Check if oneOf/anyOf is used for enum-like selections (all have const)
495
+ // If so, treat as a field with enum options, not as a complex choice
262
496
  if (schema.oneOf || schema.anyOf) {
497
+ const { values } = this.#extractEnumOptions(schema);
498
+ // If all options have const values, treat as enum field
499
+ if (values.length > 0) {
500
+ const widgetKey = this.#decideWidget(schema, ui, path);
501
+ return { kind: "field", path, title, schema, ui, widgetKey };
502
+ }
503
+ // Otherwise, treat as complex choice (for actual oneOf/anyOf schemas)
263
504
  const choices = (schema.oneOf || schema.anyOf).map((s, i) => ({
264
505
  kind: "choice-option",
265
506
  index: i,
@@ -288,8 +529,9 @@ export class SchemaForm extends LitElement {
288
529
  ? { type: "object", properties: {} }
289
530
  : schema.items || {};
290
531
 
291
- // Special case: array with enum items → checkbox-group
292
- if (itemSchema.enum && Array.isArray(itemSchema.enum)) {
532
+ // Special case: array with enum/oneOf/anyOf items → checkbox-group
533
+ const { values } = this.#extractEnumOptions(itemSchema);
534
+ if (values.length > 0) {
293
535
  return {
294
536
  kind: "field",
295
537
  path,
@@ -327,6 +569,49 @@ export class SchemaForm extends LitElement {
327
569
  return ordered;
328
570
  }
329
571
 
572
+ // Helper to extract enum values and labels from enum, oneOf, or anyOf
573
+ #extractEnumOptions(schema) {
574
+ // Support standard enum array
575
+ if (schema.enum && Array.isArray(schema.enum)) {
576
+ return {
577
+ values: schema.enum,
578
+ labels: schema.enum.map(String)
579
+ };
580
+ }
581
+
582
+ // Support oneOf pattern: [{ const: value, title: label }, ...]
583
+ if (schema.oneOf && Array.isArray(schema.oneOf)) {
584
+ const values = [];
585
+ const labels = [];
586
+ for (const option of schema.oneOf) {
587
+ if (option.const !== undefined) {
588
+ values.push(option.const);
589
+ labels.push(option.title ?? String(option.const));
590
+ }
591
+ }
592
+ if (values.length > 0) {
593
+ return { values, labels };
594
+ }
595
+ }
596
+
597
+ // Support anyOf pattern: [{ const: value, title: label }, ...]
598
+ if (schema.anyOf && Array.isArray(schema.anyOf)) {
599
+ const values = [];
600
+ const labels = [];
601
+ for (const option of schema.anyOf) {
602
+ if (option.const !== undefined) {
603
+ values.push(option.const);
604
+ labels.push(option.title ?? String(option.const));
605
+ }
606
+ }
607
+ if (values.length > 0) {
608
+ return { values, labels };
609
+ }
610
+ }
611
+
612
+ return { values: [], labels: [] };
613
+ }
614
+
330
615
  #decideWidget(schema, ui, path) {
331
616
  const picked = this.#emitCancelable("pw:choose-widget", {
332
617
  path,
@@ -337,7 +622,8 @@ export class SchemaForm extends LitElement {
337
622
  if (picked?.widget) return picked.widget;
338
623
  // Honor explicit uiSchema widget hints
339
624
  if (ui?.["ui:widget"]) return ui["ui:widget"];
340
- if (schema.enum) return schema.enum.length <= 5 ? "radio" : "select";
625
+ const { values } = this.#extractEnumOptions(schema);
626
+ if (values.length > 0) return values.length <= 5 ? "radio" : "select";
341
627
  if (schema.const !== undefined) return "const";
342
628
  if (schema.type === "string") {
343
629
  // Check for binary/upload content via JSON Schema contentMediaType
@@ -432,9 +718,12 @@ export class SchemaForm extends LitElement {
432
718
  method=${m}
433
719
  action=${this.action ?? nothing}
434
720
  @submit=${this.#onSubmit}
721
+ @reset=${this.#onReset}
435
722
  ?disabled=${this.disabled}
436
723
  >
437
- ${tree ? this.#renderNode(tree) : html`<slot></slot>`}
724
+ ${keyed(this.#resetKey, html`
725
+ ${tree ? this.#renderNode(tree) : html`<slot></slot>`}
726
+ `)}
438
727
  ${!this.hideActions
439
728
  ? html`
440
729
  <div class="form-actions">
@@ -457,6 +746,16 @@ export class SchemaForm extends LitElement {
457
746
  }
458
747
 
459
748
  #renderNode(node, context = {}) {
749
+ // Check visibility condition
750
+ const ui = node.ui || this.#uiFor(node.path);
751
+ if (ui?.["ui:visibleWhen"] && !this.#evaluateCondition(ui["ui:visibleWhen"])) {
752
+ return nothing;
753
+ }
754
+ // Also check static ui:hidden
755
+ if (ui?.["ui:hidden"]) {
756
+ return nothing;
757
+ }
758
+
460
759
  switch (node.kind) {
461
760
  case "fieldset":
462
761
  return this.#renderFieldset(node, context);
@@ -543,15 +842,24 @@ export class SchemaForm extends LitElement {
543
842
  `;
544
843
 
545
844
  // Wrap in surface if specified
845
+ let result = fieldsetContent;
546
846
  if (surface) {
547
847
  const surfaceClass =
548
848
  surface === "card" || surface === "elevated" || surface === "dialog"
549
849
  ? `surface ${surface}`
550
850
  : "surface";
551
- return html`<div class=${surfaceClass}>${fieldsetContent}</div>`;
851
+ result = html`<div class=${surfaceClass}>${fieldsetContent}</div>`;
552
852
  }
553
853
 
554
- return fieldsetContent;
854
+ // Apply ui:before and ui:after for fieldsets
855
+ const renderContext = { path: node.path, schema: node.schema, ui, node, host: this };
856
+ const before = this.#renderCustomContent(ui?.["ui:before"], renderContext);
857
+ const after = this.#renderCustomContent(ui?.["ui:after"], renderContext);
858
+
859
+ if (before || after) {
860
+ return html`${before}${result}${after}`;
861
+ }
862
+ return result;
555
863
  }
556
864
 
557
865
  #renderFieldsetTabs(node, legend, ui) {
@@ -635,14 +943,14 @@ export class SchemaForm extends LitElement {
635
943
  try {
636
944
  // Use PDS.ask to show dialog with form - it returns FormData when useForm: true
637
945
  const formData = await window.PDS.ask(
638
- html`<pds-jsonform
946
+ html`<pds-form
639
947
  .jsonSchema=${dialogSchema}
640
948
  .values=${currentValue}
641
949
  .uiSchema=${this.uiSchema}
642
950
  .options=${this.options}
643
951
  hide-actions
644
952
  hide-legend
645
- ></pds-jsonform>`,
953
+ ></pds-form>`,
646
954
  {
647
955
  title: dialogTitle,
648
956
  type: "custom",
@@ -808,6 +1116,7 @@ export class SchemaForm extends LitElement {
808
1116
  this.#deleteByPathPrefix(this.#data, path + "/");
809
1117
  this.requestUpdate();
810
1118
  this.#emit("pw:value-change", {
1119
+ path,
811
1120
  name: path,
812
1121
  value: i,
813
1122
  validity: { valid: true },
@@ -1045,10 +1354,30 @@ export class SchemaForm extends LitElement {
1045
1354
  const path = node.path;
1046
1355
  const id = this.#idFromPath(path);
1047
1356
  const label = node.title ?? this.#titleFromPath(path);
1048
- const value = this.#getByPath(this.#data, path);
1049
- const required = this.#isRequired(path);
1357
+ let value = this.#getByPath(this.#data, path);
1050
1358
  const ui = node.ui || this.#uiFor(path);
1051
1359
 
1360
+ // Evaluate calculated value if present
1361
+ if (ui?.["ui:calculate"]) {
1362
+ const calculatedValue = this.#evaluateCalculation(ui["ui:calculate"]);
1363
+ // Apply calculated value if field is empty or override not allowed
1364
+ if (value === undefined || value === null || !ui["ui:calculateOverride"]) {
1365
+ value = calculatedValue;
1366
+ // Also update internal data to keep in sync
1367
+ if (this.#getByPath(this.#data, path) !== calculatedValue) {
1368
+ this.#setByPath(this.#data, path, calculatedValue);
1369
+ }
1370
+ }
1371
+ }
1372
+
1373
+ // Evaluate conditional states
1374
+ const isDisabled = ui?.["ui:disabled"] ||
1375
+ (ui?.["ui:disabledWhen"] && this.#evaluateCondition(ui["ui:disabledWhen"]));
1376
+ const isRequired = this.#isRequired(path) ||
1377
+ (ui?.["ui:requiredWhen"] && this.#evaluateCondition(ui["ui:requiredWhen"]));
1378
+ const isReadonly = ui?.["ui:readonly"] ||
1379
+ (ui?.["ui:calculate"] && !ui["ui:calculateOverride"]);
1380
+
1052
1381
  // Override hook before default field render
1053
1382
  {
1054
1383
  const override = this.#emitCancelable("pw:before-render-field", {
@@ -1061,23 +1390,45 @@ export class SchemaForm extends LitElement {
1061
1390
  if (override?.render) return override.render();
1062
1391
  }
1063
1392
 
1393
+ // Build attributes including conditional states
1394
+ const attrs = {
1395
+ ...this.#nativeConstraints(path, node.schema),
1396
+ disabled: isDisabled,
1397
+ required: isRequired,
1398
+ readOnly: isReadonly,
1399
+ };
1400
+
1401
+ // Build base render context (shared by ui:render, renderers, and ui:wrapper)
1402
+ const baseContext = {
1403
+ id,
1404
+ path,
1405
+ label,
1406
+ value,
1407
+ required: isRequired,
1408
+ ui,
1409
+ schema: node.schema,
1410
+ get: (p) => this.#getByPath(this.#data, p ?? path),
1411
+ set: (val, p) => this.#assignValue(p ?? path, val),
1412
+ attrs,
1413
+ host: this,
1414
+ };
1415
+
1416
+ // Check for ui:render - completely custom inline renderer (same context as defineRenderer)
1417
+ if (ui?.["ui:render"] && typeof ui["ui:render"] === "function") {
1418
+ const customTpl = ui["ui:render"](baseContext);
1419
+ const before = this.#renderCustomContent(ui?.["ui:before"], baseContext);
1420
+ const after = this.#renderCustomContent(ui?.["ui:after"], baseContext);
1421
+ queueMicrotask(() =>
1422
+ this.#emit("pw:after-render-field", { path, schema: node.schema })
1423
+ );
1424
+ return html`${before}${customTpl}${after}`;
1425
+ }
1426
+
1064
1427
  // Default renderer lookup: returns ONLY the control markup
1065
1428
  const renderer =
1066
1429
  this.#renderers.get(node.widgetKey) || this.#renderers.get("*");
1067
1430
  let controlTpl = renderer
1068
- ? renderer({
1069
- id,
1070
- path,
1071
- label,
1072
- value,
1073
- required,
1074
- ui,
1075
- schema: node.schema,
1076
- get: (p) => this.#getByPath(this.#data, p ?? path),
1077
- set: (val, p) => this.#assignValue(p ?? path, val),
1078
- attrs: this.#nativeConstraints(path, node.schema),
1079
- host: this,
1080
- })
1431
+ ? renderer(baseContext)
1081
1432
  : nothing;
1082
1433
 
1083
1434
  // Post-creation tweak
@@ -1153,12 +1504,47 @@ export class SchemaForm extends LitElement {
1153
1504
  return html`<span data-label>${label}</span> ${controlTpl}`;
1154
1505
  };
1155
1506
 
1156
- return html`
1507
+ // Build render context for custom content
1508
+ const renderContext = {
1509
+ id,
1510
+ path,
1511
+ label,
1512
+ value,
1513
+ required: isRequired,
1514
+ ui,
1515
+ schema: node.schema,
1516
+ get: (p) => this.#getByPath(this.#data, p ?? path),
1517
+ set: (val, p) => this.#assignValue(p ?? path, val),
1518
+ attrs,
1519
+ host: this,
1520
+ // Additional context for ui:wrapper
1521
+ control: controlTpl,
1522
+ help: help ? html`<div data-help>${help}</div>` : nothing,
1523
+ };
1524
+
1525
+ // Check for ui:wrapper - custom wrapper replaces the entire label structure
1526
+ if (ui?.["ui:wrapper"] && typeof ui["ui:wrapper"] === "function") {
1527
+ const wrapped = ui["ui:wrapper"](renderContext);
1528
+ const before = this.#renderCustomContent(ui?.["ui:before"], renderContext);
1529
+ const after = this.#renderCustomContent(ui?.["ui:after"], renderContext);
1530
+ return html`${before}${wrapped}${after}`;
1531
+ }
1532
+
1533
+ // Standard label wrapper with ui:before/ui:after
1534
+ const before = this.#renderCustomContent(ui?.["ui:before"], renderContext);
1535
+ const after = this.#renderCustomContent(ui?.["ui:after"], renderContext);
1536
+
1537
+ const labelTpl = html`
1157
1538
  <label for=${id} ?data-toggle=${isToggle} class=${ifDefined(labelClass)}>
1158
1539
  ${renderControlAndLabel(isToggle)}
1159
1540
  ${help ? html`<div data-help>${help}</div>` : nothing}
1160
1541
  </label>
1161
1542
  `;
1543
+
1544
+ if (before || after) {
1545
+ return html`${before}${labelTpl}${after}`;
1546
+ }
1547
+ return labelTpl;
1162
1548
  }
1163
1549
 
1164
1550
  // ===== Default renderers: controls only (no spread arrays) =====
@@ -1177,6 +1563,7 @@ export class SchemaForm extends LitElement {
1177
1563
  maxlength=${ifDefined(attrs.maxLength)}
1178
1564
  pattern=${ifDefined(attrs.pattern)}
1179
1565
  ?readonly=${!!attrs.readOnly}
1566
+ ?disabled=${!!attrs.disabled}
1180
1567
  ?required=${!!attrs.required}
1181
1568
  autocomplete=${ifDefined(attrs.autocomplete)}
1182
1569
  @input=${(e) => set(e.target.value)}
@@ -1197,6 +1584,7 @@ export class SchemaForm extends LitElement {
1197
1584
  maxlength=${ifDefined(attrs.maxLength)}
1198
1585
  pattern=${ifDefined(attrs.pattern)}
1199
1586
  ?readonly=${!!attrs.readOnly}
1587
+ ?disabled=${!!attrs.disabled}
1200
1588
  ?required=${!!attrs.required}
1201
1589
  autocomplete=${ifDefined(attrs.autocomplete)}
1202
1590
  list=${ifDefined(ui?.["ui:datalist"] ? `${id}-datalist` : attrs.list)}
@@ -1226,6 +1614,7 @@ export class SchemaForm extends LitElement {
1226
1614
  minlength=${ifDefined(attrs.minLength)}
1227
1615
  maxlength=${ifDefined(attrs.maxLength)}
1228
1616
  ?readonly=${!!attrs.readOnly}
1617
+ ?disabled=${!!attrs.disabled}
1229
1618
  ?required=${!!attrs.required}
1230
1619
  @input=${(e) => set(e.target.value)}
1231
1620
  ></textarea>
@@ -1247,6 +1636,7 @@ export class SchemaForm extends LitElement {
1247
1636
  max=${ifDefined(attrs.max)}
1248
1637
  step=${ifDefined(step)}
1249
1638
  ?readonly=${!!attrs.readOnly}
1639
+ ?disabled=${!!attrs.disabled}
1250
1640
  ?required=${!!attrs.required}
1251
1641
  @input=${(e) => {
1252
1642
  const v = e.target.value;
@@ -1294,6 +1684,7 @@ export class SchemaForm extends LitElement {
1294
1684
  max=${max}
1295
1685
  step=${step}
1296
1686
  .value=${value ?? min}
1687
+ ?disabled=${!!attrs.disabled}
1297
1688
  @input=${(e) => set(Number(e.target.value))}
1298
1689
  />
1299
1690
  <div class="range-bubble" aria-hidden="true">${value ?? min}</div>
@@ -1312,6 +1703,7 @@ export class SchemaForm extends LitElement {
1312
1703
  type="email"
1313
1704
  .value=${value ?? ""}
1314
1705
  ?readonly=${!!attrs.readOnly}
1706
+ ?disabled=${!!attrs.disabled}
1315
1707
  ?required=${!!attrs.required}
1316
1708
  autocomplete=${ifDefined(attrs.autocomplete)}
1317
1709
  @input=${(e) => set(e.target.value)}
@@ -1336,6 +1728,7 @@ export class SchemaForm extends LitElement {
1336
1728
  minlength=${ifDefined(attrs.minLength)}
1337
1729
  maxlength=${ifDefined(attrs.maxLength)}
1338
1730
  ?readonly=${!!attrs.readOnly}
1731
+ ?disabled=${!!attrs.disabled}
1339
1732
  ?required=${!!attrs.required}
1340
1733
  autocomplete=${autocomplete}
1341
1734
  @input=${(e) => set(e.target.value)}
@@ -1354,6 +1747,7 @@ export class SchemaForm extends LitElement {
1354
1747
  type="url"
1355
1748
  .value=${value ?? ""}
1356
1749
  ?readonly=${!!attrs.readOnly}
1750
+ ?disabled=${!!attrs.disabled}
1357
1751
  ?required=${!!attrs.required}
1358
1752
  @input=${(e) => set(e.target.value)}
1359
1753
  />
@@ -1372,6 +1766,7 @@ export class SchemaForm extends LitElement {
1372
1766
  min=${ifDefined(attrs.min)}
1373
1767
  max=${ifDefined(attrs.max)}
1374
1768
  ?readonly=${!!attrs.readOnly}
1769
+ ?disabled=${!!attrs.disabled}
1375
1770
  ?required=${!!attrs.required}
1376
1771
  @input=${(e) => set(e.target.value)}
1377
1772
  />
@@ -1388,6 +1783,7 @@ export class SchemaForm extends LitElement {
1388
1783
  type="time"
1389
1784
  .value=${value ?? ""}
1390
1785
  ?readonly=${!!attrs.readOnly}
1786
+ ?disabled=${!!attrs.disabled}
1391
1787
  ?required=${!!attrs.required}
1392
1788
  @input=${(e) => set(e.target.value)}
1393
1789
  />
@@ -1404,6 +1800,7 @@ export class SchemaForm extends LitElement {
1404
1800
  type="color"
1405
1801
  .value=${value ?? ""}
1406
1802
  ?readonly=${!!attrs.readOnly}
1803
+ ?disabled=${!!attrs.disabled}
1407
1804
  ?required=${!!attrs.required}
1408
1805
  @input=${(e) => set(e.target.value)}
1409
1806
  />
@@ -1420,6 +1817,7 @@ export class SchemaForm extends LitElement {
1420
1817
  type="datetime-local"
1421
1818
  .value=${value ?? ""}
1422
1819
  ?readonly=${!!attrs.readOnly}
1820
+ ?disabled=${!!attrs.disabled}
1423
1821
  ?required=${!!attrs.required}
1424
1822
  @input=${(e) => set(e.target.value)}
1425
1823
  />
@@ -1434,6 +1832,7 @@ export class SchemaForm extends LitElement {
1434
1832
  name=${path}
1435
1833
  type="checkbox"
1436
1834
  .checked=${!!value}
1835
+ ?disabled=${!!attrs.disabled}
1437
1836
  ?required=${!!attrs.required}
1438
1837
  @change=${(e) => set(!!e.target.checked)}
1439
1838
  />
@@ -1449,6 +1848,7 @@ export class SchemaForm extends LitElement {
1449
1848
  name=${path}
1450
1849
  type="checkbox"
1451
1850
  .checked=${!!value}
1851
+ ?disabled=${!!attrs.disabled}
1452
1852
  ?required=${!!attrs.required}
1453
1853
  @change=${(e) => set(!!e.target.checked)}
1454
1854
  />
@@ -1461,13 +1861,12 @@ export class SchemaForm extends LitElement {
1461
1861
  const useDropdown =
1462
1862
  host.#getOption("widgets.selects", "standard") === "dropdown" ||
1463
1863
  ui?.["ui:dropdown"] === true;
1464
- const enumValues = schema.enum || [];
1465
- const enumLabels = schema.enumNames || enumValues;
1864
+ const { values: enumValues, labels: enumLabels } = host.#extractEnumOptions(schema);
1466
1865
  return html`
1467
1866
  <select
1468
1867
  id=${id}
1469
1868
  name=${path}
1470
- .value=${value ?? ""}
1869
+ ?disabled=${!!attrs.disabled}
1471
1870
  ?required=${!!attrs.required}
1472
1871
  ?data-dropdown=${useDropdown}
1473
1872
  @change=${(e) => set(e.target.value)}
@@ -1475,7 +1874,7 @@ export class SchemaForm extends LitElement {
1475
1874
  <option value="" ?selected=${value == null}>—</option>
1476
1875
  ${enumValues.map(
1477
1876
  (v, i) =>
1478
- html`<option value=${String(v)}>
1877
+ html`<option value=${String(v)} ?selected=${String(value) === String(v)}>
1479
1878
  ${String(enumLabels[i])}
1480
1879
  </option>`
1481
1880
  )}
@@ -1486,9 +1885,8 @@ export class SchemaForm extends LitElement {
1486
1885
 
1487
1886
  // Radio group: returns ONLY the labeled inputs
1488
1887
  // Matches PDS pattern: input hidden, label styled as button
1489
- this.defineRenderer("radio", ({ id, path, value, attrs, set, schema }) => {
1490
- const enumValues = schema.enum || [];
1491
- const enumLabels = schema.enumNames || enumValues;
1888
+ this.defineRenderer("radio", ({ id, path, value, attrs, set, schema, host }) => {
1889
+ const { values: enumValues, labels: enumLabels } = host.#extractEnumOptions(schema);
1492
1890
  return html`
1493
1891
  ${enumValues.map((v, i) => {
1494
1892
  const rid = `${id}-${i}`;
@@ -1516,11 +1914,11 @@ export class SchemaForm extends LitElement {
1516
1914
  // Shows actual checkboxes (not button-style like radios)
1517
1915
  this.defineRenderer(
1518
1916
  "checkbox-group",
1519
- ({ id, path, value, attrs, set, schema }) => {
1917
+ ({ id, path, value, attrs, set, schema, host }) => {
1520
1918
  const selected = Array.isArray(value) ? value : [];
1521
- const options = schema.items?.enum || schema.enum || [];
1522
- const optionLabels =
1523
- schema.items?.enumNames || schema.enumNames || options;
1919
+ // For array items, check items schema first, then fallback to root schema
1920
+ const itemSchema = schema.items || schema;
1921
+ const { values: options, labels: optionLabels } = host.#extractEnumOptions(itemSchema);
1524
1922
 
1525
1923
  return html`
1526
1924
  ${options.map((v, i) => {
@@ -1631,6 +2029,44 @@ export class SchemaForm extends LitElement {
1631
2029
  return final;
1632
2030
  }
1633
2031
 
2032
+ // ===== Form reset =====
2033
+ #onReset(e) {
2034
+ // Prevent native reset - we'll handle everything ourselves
2035
+ // Native reset clears DOM inputs but doesn't sync with Lit's reactive state
2036
+ e.preventDefault();
2037
+
2038
+ // Reset internal data to initial values (or empty if no initial values)
2039
+ if (this.values) {
2040
+ const v = this.values;
2041
+ const newData = {};
2042
+ for (const [key, value] of Object.entries(v)) {
2043
+ if (key.startsWith("/")) {
2044
+ this.#setByPath(newData, key, value);
2045
+ } else {
2046
+ newData[key] = value;
2047
+ }
2048
+ }
2049
+ this.#data = newData;
2050
+ } else {
2051
+ this.#data = {};
2052
+ }
2053
+
2054
+ // Recompile form - this rebuilds the tree, re-applies defaults,
2055
+ // and rebuilds dependency graph
2056
+ this.#compile();
2057
+
2058
+ // Re-apply calculated values after compile
2059
+ this.#applyCalculatedValues();
2060
+
2061
+ // Emit reset event
2062
+ this.#emit("pw:reset", { data: structuredClone(this.#data) });
2063
+
2064
+ // Increment reset key to force Lit to recreate all DOM elements
2065
+ // This ensures .value bindings properly reflect reset values
2066
+ this.#resetKey++;
2067
+ this.requestUpdate();
2068
+ }
2069
+
1634
2070
  // ===== Utilities =====
1635
2071
  #uiFor(path) {
1636
2072
  if (!this.uiSchema) return undefined;
@@ -1656,17 +2092,28 @@ export class SchemaForm extends LitElement {
1656
2092
  const withoutSlash = path.startsWith("/") ? path.substring(1) : path;
1657
2093
  if (this.uiSchema[withoutSlash]) return this.uiSchema[withoutSlash];
1658
2094
 
1659
- // Try nested navigation (e.g., userProfile/settings/preferences/theme)
2095
+ // Try nested navigation (e.g., /accountType/companyName)
1660
2096
  // Skip array indices (numeric parts and wildcard *) when navigating UI schema
1661
2097
  const parts = path.replace(/^\//, "").split("/");
1662
2098
  let current = this.uiSchema;
1663
- for (const part of parts) {
2099
+ for (let i = 0; i < parts.length; i++) {
2100
+ const part = parts[i];
1664
2101
  // Skip numeric array indices and wildcard in UI schema navigation
1665
2102
  if (/^\d+$/.test(part) || part === "*") continue;
1666
2103
 
2104
+ // Try both with and without leading slash for each segment
1667
2105
  if (current && typeof current === "object" && part in current) {
1668
2106
  current = current[part];
2107
+ } else if (current && typeof current === "object" && ("/" + part) in current) {
2108
+ current = current["/" + part];
1669
2109
  } else {
2110
+ // If this is the first segment, try looking up the full path from root
2111
+ if (i === 0) {
2112
+ const fullPath = "/" + parts.join("/");
2113
+ if (this.uiSchema[fullPath]) {
2114
+ return this.uiSchema[fullPath];
2115
+ }
2116
+ }
1670
2117
  return undefined;
1671
2118
  }
1672
2119
  }
@@ -1789,9 +2236,47 @@ export class SchemaForm extends LitElement {
1789
2236
 
1790
2237
  #assignValue(path, val) {
1791
2238
  this.#setByPath(this.#data, path, val);
1792
- this.requestUpdate();
2239
+
2240
+ // Apply calculated values for any dependent fields
2241
+ // This will call requestUpdate() if there are dependents
2242
+ const hadDependents = this.#applyCalculatedValues(path);
2243
+
2244
+ // Only request update here if there were no dependents
2245
+ // (to avoid double render)
2246
+ if (!hadDependents) {
2247
+ this.requestUpdate();
2248
+ }
2249
+
1793
2250
  const validity = { valid: true };
1794
- this.#emit("pw:value-change", { name: path, value: val, validity });
2251
+ this.#emit("pw:value-change", { path, name: path, value: val, validity });
2252
+ }
2253
+
2254
+ #applyCalculatedValues(changedPath) {
2255
+ // Find fields that depend on the changed path
2256
+ const dependents = this.#dependencies.get(changedPath);
2257
+ if (!dependents || dependents.size === 0) return false;
2258
+
2259
+ for (const targetPath of dependents) {
2260
+ const ui = this.#uiFor(targetPath);
2261
+ if (ui?.["ui:calculate"]) {
2262
+ // Check if override is allowed and user has modified the field
2263
+ const allowOverride = ui["ui:calculateOverride"] === true;
2264
+ const currentValue = this.#getByPath(this.#data, targetPath);
2265
+ const calculatedValue = this.#evaluateCalculation(ui["ui:calculate"]);
2266
+
2267
+ // Only update if:
2268
+ // 1. Override is not allowed, OR
2269
+ // 2. Override is allowed but field hasn't been manually modified (still matches previous calc)
2270
+ if (!allowOverride || currentValue === undefined || currentValue === null) {
2271
+ this.#setByPath(this.#data, targetPath, calculatedValue);
2272
+ }
2273
+ }
2274
+ }
2275
+
2276
+ // Force re-render since there were dependents
2277
+ // This ensures ui:visibleWhen, ui:requiredWhen, ui:disabledWhen get re-evaluated
2278
+ this.requestUpdate();
2279
+ return true; // Indicate that we triggered a re-render
1795
2280
  }
1796
2281
 
1797
2282
  #getByPath(obj, path) {
@@ -1846,6 +2331,34 @@ export class SchemaForm extends LitElement {
1846
2331
  return key === "radio" || key === "checkbox-group";
1847
2332
  }
1848
2333
 
2334
+ /**
2335
+ * Render custom content from ui:before, ui:after values.
2336
+ * Supports:
2337
+ * - Function: (context) => html`...` - called with render context
2338
+ * - String starting with "slot:": looks up slotted element by name
2339
+ * - null/undefined: returns nothing
2340
+ * @param {Function|string|undefined} content - The content definition
2341
+ * @param {object} context - Render context with path, schema, value, etc.
2342
+ * @returns {TemplateResult|Element|typeof nothing}
2343
+ */
2344
+ #renderCustomContent(content, context) {
2345
+ if (!content) return nothing;
2346
+
2347
+ // Function: call with context
2348
+ if (typeof content === "function") {
2349
+ return content(context);
2350
+ }
2351
+
2352
+ // String: slot reference (e.g., "slot:myHeader")
2353
+ if (typeof content === "string" && content.startsWith("slot:")) {
2354
+ const slotName = content.slice(5); // Remove "slot:" prefix
2355
+ const slotEl = this.#slottedContent.get(slotName);
2356
+ return slotEl ? slotEl : nothing;
2357
+ }
2358
+
2359
+ return nothing;
2360
+ }
2361
+
1849
2362
  // ===== Event helpers =====
1850
2363
  #emit(name, detail) {
1851
2364
  this.dispatchEvent(
@@ -1871,4 +2384,4 @@ export class SchemaForm extends LitElement {
1871
2384
  }
1872
2385
  }
1873
2386
 
1874
- customElements.define("pds-jsonform", SchemaForm);
2387
+ customElements.define("pds-form", SchemaForm);