@pure-ds/core 0.4.18 → 0.4.19

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",
@@ -1045,10 +1353,30 @@ export class SchemaForm extends LitElement {
1045
1353
  const path = node.path;
1046
1354
  const id = this.#idFromPath(path);
1047
1355
  const label = node.title ?? this.#titleFromPath(path);
1048
- const value = this.#getByPath(this.#data, path);
1049
- const required = this.#isRequired(path);
1356
+ let value = this.#getByPath(this.#data, path);
1050
1357
  const ui = node.ui || this.#uiFor(path);
1051
1358
 
1359
+ // Evaluate calculated value if present
1360
+ if (ui?.["ui:calculate"]) {
1361
+ const calculatedValue = this.#evaluateCalculation(ui["ui:calculate"]);
1362
+ // Apply calculated value if field is empty or override not allowed
1363
+ if (value === undefined || value === null || !ui["ui:calculateOverride"]) {
1364
+ value = calculatedValue;
1365
+ // Also update internal data to keep in sync
1366
+ if (this.#getByPath(this.#data, path) !== calculatedValue) {
1367
+ this.#setByPath(this.#data, path, calculatedValue);
1368
+ }
1369
+ }
1370
+ }
1371
+
1372
+ // Evaluate conditional states
1373
+ const isDisabled = ui?.["ui:disabled"] ||
1374
+ (ui?.["ui:disabledWhen"] && this.#evaluateCondition(ui["ui:disabledWhen"]));
1375
+ const isRequired = this.#isRequired(path) ||
1376
+ (ui?.["ui:requiredWhen"] && this.#evaluateCondition(ui["ui:requiredWhen"]));
1377
+ const isReadonly = ui?.["ui:readonly"] ||
1378
+ (ui?.["ui:calculate"] && !ui["ui:calculateOverride"]);
1379
+
1052
1380
  // Override hook before default field render
1053
1381
  {
1054
1382
  const override = this.#emitCancelable("pw:before-render-field", {
@@ -1061,23 +1389,45 @@ export class SchemaForm extends LitElement {
1061
1389
  if (override?.render) return override.render();
1062
1390
  }
1063
1391
 
1392
+ // Build attributes including conditional states
1393
+ const attrs = {
1394
+ ...this.#nativeConstraints(path, node.schema),
1395
+ disabled: isDisabled,
1396
+ required: isRequired,
1397
+ readOnly: isReadonly,
1398
+ };
1399
+
1400
+ // Build base render context (shared by ui:render, renderers, and ui:wrapper)
1401
+ const baseContext = {
1402
+ id,
1403
+ path,
1404
+ label,
1405
+ value,
1406
+ required: isRequired,
1407
+ ui,
1408
+ schema: node.schema,
1409
+ get: (p) => this.#getByPath(this.#data, p ?? path),
1410
+ set: (val, p) => this.#assignValue(p ?? path, val),
1411
+ attrs,
1412
+ host: this,
1413
+ };
1414
+
1415
+ // Check for ui:render - completely custom inline renderer (same context as defineRenderer)
1416
+ if (ui?.["ui:render"] && typeof ui["ui:render"] === "function") {
1417
+ const customTpl = ui["ui:render"](baseContext);
1418
+ const before = this.#renderCustomContent(ui?.["ui:before"], baseContext);
1419
+ const after = this.#renderCustomContent(ui?.["ui:after"], baseContext);
1420
+ queueMicrotask(() =>
1421
+ this.#emit("pw:after-render-field", { path, schema: node.schema })
1422
+ );
1423
+ return html`${before}${customTpl}${after}`;
1424
+ }
1425
+
1064
1426
  // Default renderer lookup: returns ONLY the control markup
1065
1427
  const renderer =
1066
1428
  this.#renderers.get(node.widgetKey) || this.#renderers.get("*");
1067
1429
  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
- })
1430
+ ? renderer(baseContext)
1081
1431
  : nothing;
1082
1432
 
1083
1433
  // Post-creation tweak
@@ -1153,12 +1503,47 @@ export class SchemaForm extends LitElement {
1153
1503
  return html`<span data-label>${label}</span> ${controlTpl}`;
1154
1504
  };
1155
1505
 
1156
- return html`
1506
+ // Build render context for custom content
1507
+ const renderContext = {
1508
+ id,
1509
+ path,
1510
+ label,
1511
+ value,
1512
+ required: isRequired,
1513
+ ui,
1514
+ schema: node.schema,
1515
+ get: (p) => this.#getByPath(this.#data, p ?? path),
1516
+ set: (val, p) => this.#assignValue(p ?? path, val),
1517
+ attrs,
1518
+ host: this,
1519
+ // Additional context for ui:wrapper
1520
+ control: controlTpl,
1521
+ help: help ? html`<div data-help>${help}</div>` : nothing,
1522
+ };
1523
+
1524
+ // Check for ui:wrapper - custom wrapper replaces the entire label structure
1525
+ if (ui?.["ui:wrapper"] && typeof ui["ui:wrapper"] === "function") {
1526
+ const wrapped = ui["ui:wrapper"](renderContext);
1527
+ const before = this.#renderCustomContent(ui?.["ui:before"], renderContext);
1528
+ const after = this.#renderCustomContent(ui?.["ui:after"], renderContext);
1529
+ return html`${before}${wrapped}${after}`;
1530
+ }
1531
+
1532
+ // Standard label wrapper with ui:before/ui:after
1533
+ const before = this.#renderCustomContent(ui?.["ui:before"], renderContext);
1534
+ const after = this.#renderCustomContent(ui?.["ui:after"], renderContext);
1535
+
1536
+ const labelTpl = html`
1157
1537
  <label for=${id} ?data-toggle=${isToggle} class=${ifDefined(labelClass)}>
1158
1538
  ${renderControlAndLabel(isToggle)}
1159
1539
  ${help ? html`<div data-help>${help}</div>` : nothing}
1160
1540
  </label>
1161
1541
  `;
1542
+
1543
+ if (before || after) {
1544
+ return html`${before}${labelTpl}${after}`;
1545
+ }
1546
+ return labelTpl;
1162
1547
  }
1163
1548
 
1164
1549
  // ===== Default renderers: controls only (no spread arrays) =====
@@ -1177,6 +1562,7 @@ export class SchemaForm extends LitElement {
1177
1562
  maxlength=${ifDefined(attrs.maxLength)}
1178
1563
  pattern=${ifDefined(attrs.pattern)}
1179
1564
  ?readonly=${!!attrs.readOnly}
1565
+ ?disabled=${!!attrs.disabled}
1180
1566
  ?required=${!!attrs.required}
1181
1567
  autocomplete=${ifDefined(attrs.autocomplete)}
1182
1568
  @input=${(e) => set(e.target.value)}
@@ -1197,6 +1583,7 @@ export class SchemaForm extends LitElement {
1197
1583
  maxlength=${ifDefined(attrs.maxLength)}
1198
1584
  pattern=${ifDefined(attrs.pattern)}
1199
1585
  ?readonly=${!!attrs.readOnly}
1586
+ ?disabled=${!!attrs.disabled}
1200
1587
  ?required=${!!attrs.required}
1201
1588
  autocomplete=${ifDefined(attrs.autocomplete)}
1202
1589
  list=${ifDefined(ui?.["ui:datalist"] ? `${id}-datalist` : attrs.list)}
@@ -1226,6 +1613,7 @@ export class SchemaForm extends LitElement {
1226
1613
  minlength=${ifDefined(attrs.minLength)}
1227
1614
  maxlength=${ifDefined(attrs.maxLength)}
1228
1615
  ?readonly=${!!attrs.readOnly}
1616
+ ?disabled=${!!attrs.disabled}
1229
1617
  ?required=${!!attrs.required}
1230
1618
  @input=${(e) => set(e.target.value)}
1231
1619
  ></textarea>
@@ -1247,6 +1635,7 @@ export class SchemaForm extends LitElement {
1247
1635
  max=${ifDefined(attrs.max)}
1248
1636
  step=${ifDefined(step)}
1249
1637
  ?readonly=${!!attrs.readOnly}
1638
+ ?disabled=${!!attrs.disabled}
1250
1639
  ?required=${!!attrs.required}
1251
1640
  @input=${(e) => {
1252
1641
  const v = e.target.value;
@@ -1294,6 +1683,7 @@ export class SchemaForm extends LitElement {
1294
1683
  max=${max}
1295
1684
  step=${step}
1296
1685
  .value=${value ?? min}
1686
+ ?disabled=${!!attrs.disabled}
1297
1687
  @input=${(e) => set(Number(e.target.value))}
1298
1688
  />
1299
1689
  <div class="range-bubble" aria-hidden="true">${value ?? min}</div>
@@ -1312,6 +1702,7 @@ export class SchemaForm extends LitElement {
1312
1702
  type="email"
1313
1703
  .value=${value ?? ""}
1314
1704
  ?readonly=${!!attrs.readOnly}
1705
+ ?disabled=${!!attrs.disabled}
1315
1706
  ?required=${!!attrs.required}
1316
1707
  autocomplete=${ifDefined(attrs.autocomplete)}
1317
1708
  @input=${(e) => set(e.target.value)}
@@ -1336,6 +1727,7 @@ export class SchemaForm extends LitElement {
1336
1727
  minlength=${ifDefined(attrs.minLength)}
1337
1728
  maxlength=${ifDefined(attrs.maxLength)}
1338
1729
  ?readonly=${!!attrs.readOnly}
1730
+ ?disabled=${!!attrs.disabled}
1339
1731
  ?required=${!!attrs.required}
1340
1732
  autocomplete=${autocomplete}
1341
1733
  @input=${(e) => set(e.target.value)}
@@ -1354,6 +1746,7 @@ export class SchemaForm extends LitElement {
1354
1746
  type="url"
1355
1747
  .value=${value ?? ""}
1356
1748
  ?readonly=${!!attrs.readOnly}
1749
+ ?disabled=${!!attrs.disabled}
1357
1750
  ?required=${!!attrs.required}
1358
1751
  @input=${(e) => set(e.target.value)}
1359
1752
  />
@@ -1372,6 +1765,7 @@ export class SchemaForm extends LitElement {
1372
1765
  min=${ifDefined(attrs.min)}
1373
1766
  max=${ifDefined(attrs.max)}
1374
1767
  ?readonly=${!!attrs.readOnly}
1768
+ ?disabled=${!!attrs.disabled}
1375
1769
  ?required=${!!attrs.required}
1376
1770
  @input=${(e) => set(e.target.value)}
1377
1771
  />
@@ -1388,6 +1782,7 @@ export class SchemaForm extends LitElement {
1388
1782
  type="time"
1389
1783
  .value=${value ?? ""}
1390
1784
  ?readonly=${!!attrs.readOnly}
1785
+ ?disabled=${!!attrs.disabled}
1391
1786
  ?required=${!!attrs.required}
1392
1787
  @input=${(e) => set(e.target.value)}
1393
1788
  />
@@ -1404,6 +1799,7 @@ export class SchemaForm extends LitElement {
1404
1799
  type="color"
1405
1800
  .value=${value ?? ""}
1406
1801
  ?readonly=${!!attrs.readOnly}
1802
+ ?disabled=${!!attrs.disabled}
1407
1803
  ?required=${!!attrs.required}
1408
1804
  @input=${(e) => set(e.target.value)}
1409
1805
  />
@@ -1420,6 +1816,7 @@ export class SchemaForm extends LitElement {
1420
1816
  type="datetime-local"
1421
1817
  .value=${value ?? ""}
1422
1818
  ?readonly=${!!attrs.readOnly}
1819
+ ?disabled=${!!attrs.disabled}
1423
1820
  ?required=${!!attrs.required}
1424
1821
  @input=${(e) => set(e.target.value)}
1425
1822
  />
@@ -1434,6 +1831,7 @@ export class SchemaForm extends LitElement {
1434
1831
  name=${path}
1435
1832
  type="checkbox"
1436
1833
  .checked=${!!value}
1834
+ ?disabled=${!!attrs.disabled}
1437
1835
  ?required=${!!attrs.required}
1438
1836
  @change=${(e) => set(!!e.target.checked)}
1439
1837
  />
@@ -1449,6 +1847,7 @@ export class SchemaForm extends LitElement {
1449
1847
  name=${path}
1450
1848
  type="checkbox"
1451
1849
  .checked=${!!value}
1850
+ ?disabled=${!!attrs.disabled}
1452
1851
  ?required=${!!attrs.required}
1453
1852
  @change=${(e) => set(!!e.target.checked)}
1454
1853
  />
@@ -1461,13 +1860,13 @@ export class SchemaForm extends LitElement {
1461
1860
  const useDropdown =
1462
1861
  host.#getOption("widgets.selects", "standard") === "dropdown" ||
1463
1862
  ui?.["ui:dropdown"] === true;
1464
- const enumValues = schema.enum || [];
1465
- const enumLabels = schema.enumNames || enumValues;
1863
+ const { values: enumValues, labels: enumLabels } = host.#extractEnumOptions(schema);
1466
1864
  return html`
1467
1865
  <select
1468
1866
  id=${id}
1469
1867
  name=${path}
1470
1868
  .value=${value ?? ""}
1869
+ ?disabled=${!!attrs.disabled}
1471
1870
  ?required=${!!attrs.required}
1472
1871
  ?data-dropdown=${useDropdown}
1473
1872
  @change=${(e) => set(e.target.value)}
@@ -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;
@@ -1789,11 +2225,38 @@ export class SchemaForm extends LitElement {
1789
2225
 
1790
2226
  #assignValue(path, val) {
1791
2227
  this.#setByPath(this.#data, path, val);
2228
+
2229
+ // Apply calculated values for any dependent fields
2230
+ this.#applyCalculatedValues(path);
2231
+
1792
2232
  this.requestUpdate();
1793
2233
  const validity = { valid: true };
1794
2234
  this.#emit("pw:value-change", { name: path, value: val, validity });
1795
2235
  }
1796
2236
 
2237
+ #applyCalculatedValues(changedPath) {
2238
+ // Find fields that depend on the changed path
2239
+ const dependents = this.#dependencies.get(changedPath);
2240
+ if (!dependents) return;
2241
+
2242
+ for (const targetPath of dependents) {
2243
+ const ui = this.#uiFor(targetPath);
2244
+ if (ui?.["ui:calculate"]) {
2245
+ // Check if override is allowed and user has modified the field
2246
+ const allowOverride = ui["ui:calculateOverride"] === true;
2247
+ const currentValue = this.#getByPath(this.#data, targetPath);
2248
+ const calculatedValue = this.#evaluateCalculation(ui["ui:calculate"]);
2249
+
2250
+ // Only update if:
2251
+ // 1. Override is not allowed, OR
2252
+ // 2. Override is allowed but field hasn't been manually modified (still matches previous calc)
2253
+ if (!allowOverride || currentValue === undefined || currentValue === null) {
2254
+ this.#setByPath(this.#data, targetPath, calculatedValue);
2255
+ }
2256
+ }
2257
+ }
2258
+ }
2259
+
1797
2260
  #getByPath(obj, path) {
1798
2261
  if (!path || path === "") return obj;
1799
2262
  const parts = path.split("/").filter(Boolean);
@@ -1846,6 +2309,34 @@ export class SchemaForm extends LitElement {
1846
2309
  return key === "radio" || key === "checkbox-group";
1847
2310
  }
1848
2311
 
2312
+ /**
2313
+ * Render custom content from ui:before, ui:after values.
2314
+ * Supports:
2315
+ * - Function: (context) => html`...` - called with render context
2316
+ * - String starting with "slot:": looks up slotted element by name
2317
+ * - null/undefined: returns nothing
2318
+ * @param {Function|string|undefined} content - The content definition
2319
+ * @param {object} context - Render context with path, schema, value, etc.
2320
+ * @returns {TemplateResult|Element|typeof nothing}
2321
+ */
2322
+ #renderCustomContent(content, context) {
2323
+ if (!content) return nothing;
2324
+
2325
+ // Function: call with context
2326
+ if (typeof content === "function") {
2327
+ return content(context);
2328
+ }
2329
+
2330
+ // String: slot reference (e.g., "slot:myHeader")
2331
+ if (typeof content === "string" && content.startsWith("slot:")) {
2332
+ const slotName = content.slice(5); // Remove "slot:" prefix
2333
+ const slotEl = this.#slottedContent.get(slotName);
2334
+ return slotEl ? slotEl : nothing;
2335
+ }
2336
+
2337
+ return nothing;
2338
+ }
2339
+
1849
2340
  // ===== Event helpers =====
1850
2341
  #emit(name, detail) {
1851
2342
  this.dispatchEvent(
@@ -1871,4 +2362,4 @@ export class SchemaForm extends LitElement {
1871
2362
  }
1872
2363
  }
1873
2364
 
1874
- customElements.define("pds-jsonform", SchemaForm);
2365
+ customElements.define("pds-form", SchemaForm);