@pure-ds/storybook 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.
Files changed (129) hide show
  1. package/.storybook/addons/description/preview.js +15 -0
  2. package/.storybook/addons/description/register.js +60 -0
  3. package/.storybook/addons/html-preview/Panel.jsx +327 -0
  4. package/.storybook/addons/html-preview/constants.js +6 -0
  5. package/.storybook/addons/html-preview/preview.js +178 -0
  6. package/.storybook/addons/html-preview/register.js +16 -0
  7. package/.storybook/addons/pds-configurator/SearchTool.js +44 -0
  8. package/.storybook/addons/pds-configurator/Tool.js +30 -0
  9. package/.storybook/addons/pds-configurator/constants.js +9 -0
  10. package/.storybook/addons/pds-configurator/preview.js +159 -0
  11. package/.storybook/addons/pds-configurator/register.js +24 -0
  12. package/.storybook/docs.css +35 -0
  13. package/.storybook/htmlPreview.css +103 -0
  14. package/.storybook/htmlPreview.js +271 -0
  15. package/.storybook/main.js +160 -0
  16. package/.storybook/preview-body.html +48 -0
  17. package/.storybook/preview-head.html +11 -0
  18. package/.storybook/preview.js +1563 -0
  19. package/README.md +266 -0
  20. package/bin/index.js +40 -0
  21. package/dist/pds-reference.json +2101 -0
  22. package/package.json +45 -0
  23. package/pds.config.js +6 -0
  24. package/public/assets/css/app.css +1216 -0
  25. package/public/assets/data/auto-design-advanced.json +704 -0
  26. package/public/assets/data/auto-design-simple.json +123 -0
  27. package/public/assets/img/icon-512x512.png +0 -0
  28. package/public/assets/img/logo-trans.png +0 -0
  29. package/public/assets/img/logo.png +0 -0
  30. package/public/assets/js/app.js +15088 -0
  31. package/public/assets/js/app.js.map +7 -0
  32. package/public/assets/js/lit.js +1176 -0
  33. package/public/assets/js/lit.js.map +7 -0
  34. package/public/assets/js/pds.js +9801 -0
  35. package/public/assets/js/pds.js.map +7 -0
  36. package/public/assets/pds/components/pds-calendar.js +837 -0
  37. package/public/assets/pds/components/pds-drawer.js +857 -0
  38. package/public/assets/pds/components/pds-icon.js +338 -0
  39. package/public/assets/pds/components/pds-jsonform.js +1775 -0
  40. package/public/assets/pds/components/pds-richtext.js +1035 -0
  41. package/public/assets/pds/components/pds-scrollrow.js +331 -0
  42. package/public/assets/pds/components/pds-splitpanel.js +401 -0
  43. package/public/assets/pds/components/pds-tabstrip.js +251 -0
  44. package/public/assets/pds/components/pds-toaster.js +446 -0
  45. package/public/assets/pds/components/pds-upload.js +657 -0
  46. package/public/assets/pds/custom-elements.json +2003 -0
  47. package/public/assets/pds/icons/pds-icons.svg +498 -0
  48. package/public/assets/pds/pds-css-complete.json +1861 -0
  49. package/public/assets/pds/pds-runtime-config.json +11 -0
  50. package/public/assets/pds/pds.css-data.json +2152 -0
  51. package/public/assets/pds/styles/pds-components.css +1944 -0
  52. package/public/assets/pds/styles/pds-components.css.js +3895 -0
  53. package/public/assets/pds/styles/pds-primitives.css +352 -0
  54. package/public/assets/pds/styles/pds-primitives.css.js +711 -0
  55. package/public/assets/pds/styles/pds-styles.css +3761 -0
  56. package/public/assets/pds/styles/pds-styles.css.js +7529 -0
  57. package/public/assets/pds/styles/pds-tokens.css +699 -0
  58. package/public/assets/pds/styles/pds-tokens.css.js +1405 -0
  59. package/public/assets/pds/styles/pds-utilities.css +763 -0
  60. package/public/assets/pds/styles/pds-utilities.css.js +1533 -0
  61. package/public/assets/pds/vscode-custom-data.json +824 -0
  62. package/scripts/build-pds-reference.mjs +807 -0
  63. package/scripts/generate-stories.js +542 -0
  64. package/scripts/package-build.js +86 -0
  65. package/src/js/app.js +17 -0
  66. package/src/js/common/ask.js +208 -0
  67. package/src/js/common/common.js +20 -0
  68. package/src/js/common/font-loader.js +200 -0
  69. package/src/js/common/msg.js +90 -0
  70. package/src/js/lit.js +40 -0
  71. package/src/js/pds-core/pds-config.js +1162 -0
  72. package/src/js/pds-core/pds-enhancer-metadata.js +75 -0
  73. package/src/js/pds-core/pds-enhancers.js +357 -0
  74. package/src/js/pds-core/pds-enums.js +86 -0
  75. package/src/js/pds-core/pds-generator.js +5317 -0
  76. package/src/js/pds-core/pds-ontology.js +256 -0
  77. package/src/js/pds-core/pds-paths.js +109 -0
  78. package/src/js/pds-core/pds-query.js +571 -0
  79. package/src/js/pds-core/pds-registry.js +129 -0
  80. package/src/js/pds-core/pds.d.ts +129 -0
  81. package/src/js/pds.d.ts +408 -0
  82. package/src/js/pds.js +1579 -0
  83. package/src/pds-core/pds-api.js +105 -0
  84. package/stories/GettingStarted.md +96 -0
  85. package/stories/GettingStarted.stories.js +144 -0
  86. package/stories/WhatIsPDS.md +194 -0
  87. package/stories/WhatIsPDS.stories.js +144 -0
  88. package/stories/components/PdsCalendar.stories.js +263 -0
  89. package/stories/components/PdsDrawer.stories.js +623 -0
  90. package/stories/components/PdsIcon.stories.js +78 -0
  91. package/stories/components/PdsJsonform.stories.js +1444 -0
  92. package/stories/components/PdsRichtext.stories.js +367 -0
  93. package/stories/components/PdsScrollrow.stories.js +140 -0
  94. package/stories/components/PdsSplitpanel.stories.js +502 -0
  95. package/stories/components/PdsTabstrip.stories.js +442 -0
  96. package/stories/components/PdsToaster.stories.js +186 -0
  97. package/stories/components/PdsUpload.stories.js +66 -0
  98. package/stories/enhancements/Dropdowns.stories.js +185 -0
  99. package/stories/enhancements/InteractiveStates.stories.js +625 -0
  100. package/stories/enhancements/MeshGradients.stories.js +320 -0
  101. package/stories/enhancements/OpenGroups.stories.js +227 -0
  102. package/stories/enhancements/RangeSliders.stories.js +232 -0
  103. package/stories/enhancements/RequiredFields.stories.js +189 -0
  104. package/stories/enhancements/Toggles.stories.js +167 -0
  105. package/stories/foundations/Colors.stories.js +283 -0
  106. package/stories/foundations/Icons.stories.js +305 -0
  107. package/stories/foundations/SmartSurfaces.stories.js +367 -0
  108. package/stories/foundations/Spacing.stories.js +175 -0
  109. package/stories/foundations/Typography.stories.js +960 -0
  110. package/stories/foundations/ZIndex.stories.js +325 -0
  111. package/stories/patterns/BorderEffects.stories.js +72 -0
  112. package/stories/patterns/Layout.stories.js +99 -0
  113. package/stories/patterns/Utilities.stories.js +107 -0
  114. package/stories/primitives/Accordion.stories.js +359 -0
  115. package/stories/primitives/Alerts.stories.js +64 -0
  116. package/stories/primitives/Badges.stories.js +183 -0
  117. package/stories/primitives/Buttons.stories.js +229 -0
  118. package/stories/primitives/Cards.stories.js +353 -0
  119. package/stories/primitives/FormGroups.stories.js +569 -0
  120. package/stories/primitives/Forms.stories.js +131 -0
  121. package/stories/primitives/Media.stories.js +203 -0
  122. package/stories/primitives/Tables.stories.js +232 -0
  123. package/stories/reference/ReferenceCatalog.stories.js +28 -0
  124. package/stories/reference/reference-catalog.js +413 -0
  125. package/stories/reference/reference-docs.js +302 -0
  126. package/stories/reference/reference-helpers.js +310 -0
  127. package/stories/utilities/GridSystem.stories.js +208 -0
  128. package/stories/utils/PdsAsk.stories.js +420 -0
  129. package/stories/utils/toast-utils.js +148 -0
@@ -0,0 +1,1775 @@
1
+ import { LitElement, html, nothing, ifDefined, ref } from "#pds/lit";
2
+
3
+ function getStep(value) {
4
+ if (typeof value === "number") {
5
+ const decimalPlaces = (value.toString().split(".")[1] || "").length;
6
+ return decimalPlaces > 0 ? `0.${"1".padStart(decimalPlaces, "0")}` : "1";
7
+ }
8
+ return "1"; // Default step for integers
9
+ }
10
+
11
+ // Default options for pds-jsonform
12
+ const DEFAULT_OPTIONS = {
13
+ widgets: {
14
+ booleans: "toggle", // 'toggle' | 'checkbox'
15
+ numbers: "input", // 'input' | 'range'
16
+ selects: "standard", // 'standard' | 'dropdown'
17
+ },
18
+ layouts: {
19
+ fieldsets: "default", // 'default' | 'flex' | 'grid' | 'accordion' | 'tabs' | 'card'
20
+ arrays: "default", // 'default' | 'open' | 'compact'
21
+ },
22
+ enhancements: {
23
+ icons: true, // Enable icon-enhanced inputs
24
+ datalists: true, // Enable datalist autocomplete
25
+ rangeOutput: true, // Use .range-output for ranges
26
+ },
27
+ validation: {
28
+ showErrors: true, // Show validation errors inline
29
+ validateOnChange: false, // Validate on every change vs on submit
30
+ },
31
+ };
32
+
33
+ /**
34
+ * <pds-jsonform>
35
+ *
36
+ * Form Actions:
37
+ * By default, the form includes Submit and Reset buttons inside the <form> element.
38
+ *
39
+ * Usage options:
40
+ * 1. Default buttons:
41
+ * <pds-jsonform .jsonSchema=${schema}></pds-jsonform>
42
+ *
43
+ * 2. Customize labels:
44
+ * <pds-jsonform .jsonSchema=${schema} submit-label="Save" reset-label="Clear"></pds-jsonform>
45
+ *
46
+ * 3. Hide reset button:
47
+ * <pds-jsonform .jsonSchema=${schema} hide-reset></pds-jsonform>
48
+ *
49
+ * 4. Add extra buttons (slot):
50
+ * <pds-jsonform .jsonSchema=${schema}>
51
+ * <button type="button" slot="actions" @click=${...}>Cancel</button>
52
+ * </pds-jsonform>
53
+ *
54
+ * 5. Completely custom actions (hides default buttons):
55
+ * <pds-jsonform .jsonSchema=${schema} hide-actions>
56
+ * <div slot="actions" style="display: flex; gap: 1rem;">
57
+ * <button type="submit" class="btn btn-primary">Custom Submit</button>
58
+ * <button type="button" class="btn">Custom Action</button>
59
+ * </div>
60
+ * </pds-jsonform>
61
+ */
62
+ export class SchemaForm extends LitElement {
63
+ static properties = {
64
+ jsonSchema: { type: Object, attribute: "json-schema" },
65
+ uiSchema: { type: Object, attribute: "ui-schema" },
66
+ options: { type: Object },
67
+ values: { type: Object }, // Make it reactive again
68
+ action: { type: String },
69
+ method: { type: String }, // 'get' | 'post' | 'dialog'
70
+ disabled: { type: Boolean, reflect: true },
71
+ hideActions: { type: Boolean, attribute: "hide-actions" },
72
+ submitLabel: { type: String, attribute: "submit-label" },
73
+ resetLabel: { type: String, attribute: "reset-label" },
74
+ hideReset: { type: Boolean, attribute: "hide-reset" },
75
+ hideSubmit: { type: Boolean, attribute: "hide-submit" },
76
+ hideLegend: { type: Boolean, attribute: "hide-legend" },
77
+ };
78
+
79
+ // Light DOM so page CSS can style generated markup
80
+ createRenderRoot() {
81
+ return this;
82
+ }
83
+
84
+ // ===== Private state =====
85
+ #renderers = new Map();
86
+ #validator = null;
87
+ #compiled = null;
88
+ #data = {};
89
+ #idBase = `sf-${Math.random().toString(36).slice(2)}`;
90
+ #mergedOptions = null;
91
+
92
+ constructor() {
93
+ super();
94
+ this.jsonSchema = undefined;
95
+ this.uiSchema = undefined;
96
+ this.options = undefined;
97
+ this.values = undefined;
98
+ this.method = "post";
99
+ this.hideActions = false;
100
+ this.submitLabel = "Submit";
101
+ this.resetLabel = "Reset";
102
+ this.hideReset = false;
103
+ this.hideLegend = false;
104
+ this.#installDefaultRenderers();
105
+
106
+ // Handle submit button clicks in slotted actions
107
+ this.addEventListener('click', (e) => {
108
+ const button = e.target.closest('button[type="submit"]');
109
+ if (button && this.contains(button)) {
110
+ const form = this.querySelector('form');
111
+ if (form) {
112
+ e.preventDefault();
113
+ form.requestSubmit();
114
+ }
115
+ }
116
+ });
117
+ }
118
+
119
+ // ===== Public API =====
120
+ defineRenderer(widgetKey, fn) {
121
+ this.#renderers.set(widgetKey, fn);
122
+ }
123
+ useValidator(fn) {
124
+ this.#validator = fn;
125
+ }
126
+
127
+ // Get values in flat JSON Pointer format
128
+ getValuesFlat() {
129
+ return this.#flattenToPointers(this.#data);
130
+ }
131
+
132
+ #flattenToPointers(obj, prefix = "") {
133
+ const flattened = {};
134
+ for (const [key, value] of Object.entries(obj)) {
135
+ const jsonPointerPath = prefix ? `${prefix}/${key}` : `/${key}`;
136
+ if (value && typeof value === "object" && !Array.isArray(value)) {
137
+ Object.assign(flattened, this.#flattenToPointers(value, jsonPointerPath));
138
+ } else {
139
+ flattened[jsonPointerPath] = value;
140
+ }
141
+ }
142
+ return flattened;
143
+ }
144
+
145
+ serialize() {
146
+ const form = this.renderRoot?.querySelector("form");
147
+ const fd = form ? new FormData(form) : new FormData();
148
+ return { json: structuredClone(this.#data), formData: fd };
149
+ }
150
+
151
+ async submit() {
152
+ return this.#onSubmit(new Event("submit", { cancelable: true }));
153
+ }
154
+
155
+ // ===== Lit lifecycle =====
156
+ willUpdate(changed) {
157
+ // Merge options when options or jsonSchema changes
158
+ if (changed.has("options") || changed.has("jsonSchema")) {
159
+ this.#mergeOptions();
160
+ }
161
+
162
+ if (changed.has("jsonSchema")) this.#compile();
163
+ if (changed.has("uiSchema")) this.requestUpdate();
164
+ if (changed.has("values")) {
165
+ // When values property changes, update internal data
166
+ const v = this.values;
167
+ if (!v) {
168
+ this.#data = {};
169
+ } else {
170
+ const newData = {};
171
+ for (const [key, value] of Object.entries(v)) {
172
+ if (key.startsWith('/')) {
173
+ this.#setByPath(newData, key, value);
174
+ } else {
175
+ newData[key] = value;
176
+ }
177
+ }
178
+ this.#data = newData;
179
+ }
180
+ }
181
+ }
182
+
183
+ #mergeOptions() {
184
+ // Start with default options
185
+ let merged = { ...DEFAULT_OPTIONS };
186
+
187
+ // Try to get preset options from window.PDS if available
188
+ if (typeof window !== "undefined" && window.PDS?.config?.form?.options) {
189
+ merged = window.PDS.common.deepMerge(merged, window.PDS.config.form.options);
190
+ }
191
+
192
+ // Merge instance options
193
+ if (this.options) {
194
+ merged = window.PDS.common.deepMerge(merged, this.options);
195
+ }
196
+
197
+ this.#mergedOptions = merged;
198
+ }
199
+
200
+ #getOption(path, defaultValue) {
201
+ if (!this.#mergedOptions) this.#mergeOptions();
202
+
203
+ // Support path-based options like '/address/zip': { ... }
204
+ if (path.startsWith('/')) {
205
+ const pathOptions = this.#mergedOptions[path];
206
+ if (pathOptions !== undefined) return pathOptions;
207
+ }
208
+
209
+ // Support nested option paths like 'widgets.booleans'
210
+ const parts = path.split('.');
211
+ let current = this.#mergedOptions;
212
+ for (const part of parts) {
213
+ if (current && typeof current === 'object' && part in current) {
214
+ current = current[part];
215
+ } else {
216
+ return defaultValue;
217
+ }
218
+ }
219
+ return current !== undefined ? current : defaultValue;
220
+ }
221
+
222
+ // ===== Schema compilation =====
223
+ #compile() {
224
+ const root = this.jsonSchema;
225
+ if (!root || typeof root !== "object") {
226
+ this.#compiled = null;
227
+ return;
228
+ }
229
+ const resolved =
230
+ this.#emitCancelable("pw:schema-resolve", { schema: root })?.schema ||
231
+ root;
232
+ const node = this.#compileNode(resolved, "");
233
+ this.#compiled = node;
234
+ this.#applyDefaults(resolved, this.#data, "");
235
+ }
236
+
237
+ #compileNode(schema, path) {
238
+ const title = schema.title ?? this.#titleFromPath(path);
239
+ const ui = this.#uiFor(path);
240
+ const custom = this.#emitCancelable("pw:compile-node", {
241
+ path,
242
+ schema,
243
+ ui,
244
+ });
245
+ if (custom?.node) return custom.node;
246
+
247
+ if (schema.oneOf || schema.anyOf) {
248
+ const choices = (schema.oneOf || schema.anyOf).map((s, i) => ({
249
+ kind: "choice-option",
250
+ index: i,
251
+ schema: s,
252
+ title: s.title ?? `Option ${i + 1}`,
253
+ }));
254
+ return { kind: "choice", path, title, schema, options: choices };
255
+ }
256
+
257
+ switch (schema.type) {
258
+ case "object": {
259
+ // Check if this should be a dialog
260
+ if (ui?.["ui:dialog"]) {
261
+ return { kind: "dialog", path, title, schema, ui };
262
+ }
263
+
264
+ const order = this.#propertyOrder(schema, ui);
265
+ const children = order.map((key) => {
266
+ const childPath = path + "/" + this.#escapeJsonPointer(key);
267
+ return this.#compileNode(schema.properties[key], childPath);
268
+ });
269
+ return { kind: "fieldset", path, title, schema, children };
270
+ }
271
+ case "array": {
272
+ const itemSchema = Array.isArray(schema.items)
273
+ ? { type: "object", properties: {} }
274
+ : schema.items || {};
275
+
276
+ // Special case: array with enum items → checkbox-group
277
+ if (itemSchema.enum && Array.isArray(itemSchema.enum)) {
278
+ return {
279
+ kind: "field",
280
+ path,
281
+ title,
282
+ schema,
283
+ ui,
284
+ widgetKey: "checkbox-group",
285
+ };
286
+ }
287
+
288
+ // Standard array with add/remove
289
+ const itemNode = this.#compileNode(itemSchema, path + "/*");
290
+ return { kind: "array", path, title, schema, item: itemNode };
291
+ }
292
+ default: {
293
+ const widgetKey = this.#decideWidget(schema, ui, path);
294
+ return { kind: "field", path, title, schema, ui, widgetKey };
295
+ }
296
+ }
297
+ }
298
+
299
+ #propertyOrder(schema, ui) {
300
+ const props = schema.properties ? Object.keys(schema.properties) : [];
301
+ const specified = ui?.["ui:order"] || this.uiSchema?.["ui:order"];
302
+ if (!specified) return props;
303
+ const byPtr = new Map(
304
+ props.map((k) => ["/" + this.#escapeJsonPointer(k), k])
305
+ );
306
+ const ordered = [];
307
+ for (const p of specified) {
308
+ const key = p.startsWith("/") ? byPtr.get(p) : p;
309
+ if (key && props.includes(key)) ordered.push(key);
310
+ }
311
+ for (const k of props) if (!ordered.includes(k)) ordered.push(k);
312
+ return ordered;
313
+ }
314
+
315
+ #decideWidget(schema, ui, path) {
316
+ const picked = this.#emitCancelable("pw:choose-widget", {
317
+ path,
318
+ schema,
319
+ ui,
320
+ widget: null,
321
+ });
322
+ if (picked?.widget) return picked.widget;
323
+ // Honor explicit uiSchema widget hints
324
+ if (ui?.["ui:widget"]) return ui["ui:widget"];
325
+ if (schema.enum) return schema.enum.length <= 5 ? "radio" : "select";
326
+ if (schema.const !== undefined) return "const";
327
+ if (schema.type === "string") {
328
+ // Check for binary/upload content via JSON Schema contentMediaType
329
+ if (schema.contentMediaType || schema.contentEncoding === 'base64') {
330
+ return "upload";
331
+ }
332
+ switch (schema.format) {
333
+ case "data-url":
334
+ return "upload";
335
+ case "upload":
336
+ return "upload";
337
+ case "richtext":
338
+ return "richtext";
339
+ case "email":
340
+ return "input-email";
341
+ case "password":
342
+ return "input-password";
343
+ case "uri":
344
+ case "url":
345
+ return "input-url";
346
+ case "date":
347
+ return "input-date";
348
+ case "time":
349
+ return "input-time";
350
+ case "datetime-local":
351
+ return "input-datetime";
352
+ case "color":
353
+ return "input-color";
354
+ case "date-time":
355
+ return "input-datetime";
356
+ default:
357
+ if (
358
+ (schema.maxLength ?? 0) > 160 ||
359
+ ui?.["ui:widget"] === "textarea"
360
+ )
361
+ return "textarea";
362
+ return "input-text";
363
+ }
364
+ }
365
+ if (schema.type === "number" || schema.type === "integer") {
366
+ // Check if range widget should be used
367
+ const useRange = this.#getOption('widgets.numbers', 'input') === 'range' ||
368
+ ui?.["ui:widget"] === "range" ||
369
+ ui?.["ui:widget"] === "input-range";
370
+ return useRange ? "input-range" : "input-number";
371
+ }
372
+ if (schema.type === "boolean") {
373
+ // Check if toggle should be used
374
+ const useToggle = this.#getOption('widgets.booleans', 'toggle') === 'toggle';
375
+ return useToggle ? "toggle" : "checkbox";
376
+ }
377
+ return "input-text";
378
+ }
379
+
380
+ #applyDefaults(schema, target, path) {
381
+ if (
382
+ schema.default !== undefined &&
383
+ this.#getByPath(target, path) === undefined
384
+ ) {
385
+ this.#setByPath(target, path, structuredClone(schema.default));
386
+ }
387
+ if (schema.type === "object" && schema.properties) {
388
+ for (const [k, s] of Object.entries(schema.properties)) {
389
+ this.#applyDefaults(s, target, path + "/" + this.#escapeJsonPointer(k));
390
+ }
391
+ }
392
+ if (
393
+ schema.type === "array" &&
394
+ schema.items &&
395
+ Array.isArray(schema.default)
396
+ ) {
397
+ this.#setByPath(target, path, structuredClone(schema.default));
398
+ }
399
+ }
400
+
401
+ // ===== Rendering =====
402
+ render() {
403
+ const tree = this.#compiled;
404
+ if (!tree)
405
+ return html`<div
406
+ class="pds-jsonform-error"
407
+ style="color: red; padding: 1rem; border: 1px solid red; background: #fee;"
408
+ >
409
+ <p>Failed to generate form schema.</p>
410
+ <pre>${JSON.stringify(this.#data, null, 2)}</pre>
411
+ </div>`;
412
+ const m =
413
+ this.method === "get" ||
414
+ this.method === "post" ||
415
+ this.method === "dialog"
416
+ ? this.method
417
+ : "post";
418
+ return html`
419
+ <form
420
+ method=${m}
421
+ action=${this.action ?? nothing}
422
+ @submit=${this.#onSubmit}
423
+ ?disabled=${this.disabled}
424
+ >
425
+ ${tree ? this.#renderNode(tree) : html`<slot></slot>`}
426
+ ${!this.hideActions
427
+ ? html`
428
+ <div
429
+ class="form-actions"
430
+ style="margin-top: var(--spacing-6, 1.5rem); display: flex; gap: var(--spacing-3, 0.75rem);"
431
+ >
432
+ ${!this.hideSubmit ? html`
433
+ <button type="submit" class="btn btn-primary">
434
+ ${this.submitLabel}
435
+ </button>` : nothing}
436
+
437
+ ${!this.hideReset
438
+ ? html`<button type="reset" class="btn">
439
+ ${this.resetLabel}
440
+ </button>`
441
+ : nothing}
442
+ <slot name="actions"></slot>
443
+ </div>
444
+ `
445
+ : html`<slot name="actions"></slot>`}
446
+ </form>
447
+ `;
448
+ }
449
+
450
+ #renderNode(node, context = {}) {
451
+ switch (node.kind) {
452
+ case "fieldset":
453
+ return this.#renderFieldset(node, context);
454
+ case "field":
455
+ return this.#renderField(node);
456
+ case "array":
457
+ return this.#renderArray(node);
458
+ case "choice":
459
+ return this.#renderChoice(node);
460
+ case "dialog":
461
+ return this.#renderDialog(node);
462
+ default:
463
+ return nothing;
464
+ }
465
+ }
466
+
467
+ #renderFieldset(node, context = {}) {
468
+ const legend = node.title ?? "Section";
469
+ const ui = node.ui || this.#uiFor(node.path);
470
+
471
+ // Check for path-specific options
472
+ const pathOptions = this.#getOption(node.path, {});
473
+
474
+ // Determine layout mode
475
+ const layout = ui?.["ui:layout"] || pathOptions.layout ||
476
+ this.#getOption('layouts.fieldsets', 'default');
477
+
478
+ // Check for tabs layout
479
+ if (layout === "tabs" || ui?.["ui:tabs"]) {
480
+ return this.#renderFieldsetTabs(node, legend, ui);
481
+ }
482
+
483
+ // Check for accordion layout
484
+ if (layout === "accordion" || ui?.["ui:accordion"]) {
485
+ return this.#renderFieldsetAccordion(node, legend, ui);
486
+ }
487
+
488
+ // Check for surface wrapping
489
+ const surface = ui?.["ui:surface"] || pathOptions.surface;
490
+
491
+ // Build layout classes and inline styles
492
+ const layoutClasses = [];
493
+ let layoutStyle = "";
494
+ const layoutOptions = ui?.["ui:layoutOptions"] || {};
495
+
496
+ if (layout === "flex") {
497
+ layoutClasses.push("flex");
498
+ if (layoutOptions.wrap) layoutClasses.push("flex-wrap");
499
+ if (layoutOptions.direction === "column") layoutClasses.push("flex-col");
500
+ if (layoutOptions.gap) {
501
+ // Check if gap is a CSS class name (e.g., 'md', 'lg') or a CSS value
502
+ if (layoutOptions.gap.startsWith('var(') || layoutOptions.gap.includes('px') || layoutOptions.gap.includes('rem')) {
503
+ layoutStyle += `gap: ${layoutOptions.gap};`;
504
+ } else {
505
+ layoutClasses.push(`gap-${layoutOptions.gap}`);
506
+ }
507
+ }
508
+ } else if (layout === "grid") {
509
+ layoutClasses.push("grid");
510
+ const cols = layoutOptions.columns || 2;
511
+ if (cols === "auto") {
512
+ const autoSize = layoutOptions.autoSize || "md";
513
+ layoutClasses.push(`grid-auto-${autoSize}`);
514
+ } else {
515
+ layoutClasses.push(`grid-cols-${cols}`);
516
+ }
517
+ if (layoutOptions.gap) {
518
+ // Check if gap is a CSS class name (e.g., 'md', 'lg') or a CSS value
519
+ if (layoutOptions.gap.startsWith('var(') || layoutOptions.gap.includes('px') || layoutOptions.gap.includes('rem')) {
520
+ layoutStyle += `gap: ${layoutOptions.gap};`;
521
+ } else {
522
+ layoutClasses.push(`gap-${layoutOptions.gap}`);
523
+ }
524
+ }
525
+ }
526
+
527
+ const fieldsetClass = layoutClasses.length > 0 ? layoutClasses.join(" ") : undefined;
528
+
529
+ // Render basic fieldset
530
+ const fieldsetContent = html`
531
+ <fieldset data-path=${node.path} class=${ifDefined(fieldsetClass)} style=${ifDefined(layoutStyle || undefined)}>
532
+ ${!this.hideLegend && !context.hideLegend ? html`<legend>${legend}</legend>` : nothing}
533
+ ${node.children.map((child) => this.#renderNode(child, context))}
534
+ </fieldset>
535
+ `;
536
+
537
+ // Wrap in surface if specified
538
+ if (surface) {
539
+ const surfaceClass = surface === "card" || surface === "elevated" || surface === "dialog"
540
+ ? `surface ${surface}`
541
+ : "surface";
542
+ return html`<div class=${surfaceClass}>${fieldsetContent}</div>`;
543
+ }
544
+
545
+ return fieldsetContent;
546
+ }
547
+
548
+ #renderFieldsetTabs(node, legend, ui) {
549
+ const children = node.children || [];
550
+ if (children.length === 0) return nothing;
551
+
552
+ // Create tab panels from child fields
553
+ return html`
554
+ <pds-tabstrip label=${legend} data-path=${node.path}>
555
+ ${children.map((child, idx) => {
556
+ const childTitle = child.title ?? `Tab ${idx + 1}`;
557
+ const childId = `${node.path}-tab-${idx}`.replace(/[^a-zA-Z0-9_-]/g, '-');
558
+ return html`
559
+ <pds-tabpanel id=${childId} label=${childTitle}>
560
+ ${this.#renderNode(child)}
561
+ </pds-tabpanel>
562
+ `;
563
+ })}
564
+ </pds-tabstrip>
565
+ `;
566
+ }
567
+
568
+ #renderFieldsetAccordion(node, legend, ui) {
569
+ const children = node.children || [];
570
+ if (children.length === 0) return nothing;
571
+ const layoutOptions = ui?.["ui:layoutOptions"] || {};
572
+ const openFirst = layoutOptions.openFirst ?? true;
573
+
574
+ return html`
575
+ <section class="accordion" data-path=${node.path}>
576
+ ${children.map((child, idx) => {
577
+ const childTitle = child.title ?? `Section ${idx + 1}`;
578
+ const childId = `${node.path}-acc-${idx}`.replace(/[^a-zA-Z0-9_-]/g, '-');
579
+ const isOpen = ui?.["ui:defaultOpen"]?.includes(idx) ?? (openFirst && idx === 0);
580
+ return html`
581
+ <details ?open=${isOpen}>
582
+ <summary id=${childId}>${childTitle}</summary>
583
+ <div role="region" aria-labelledby=${childId}>
584
+ ${this.#renderNode(child, { hideLegend: true })}
585
+ </div>
586
+ </details>
587
+ `;
588
+ })}
589
+ </section>
590
+ `;
591
+ }
592
+
593
+ #renderDialog(node) {
594
+ const path = node.path;
595
+ const title = node.title ?? "Edit";
596
+ const ui = node.ui || this.#uiFor(path);
597
+ const dialogOpts = ui?.["ui:dialogOptions"] || {};
598
+ const buttonLabel = dialogOpts.buttonLabel || ui?.["ui:dialogButton"] || `Edit ${title}`;
599
+ const dialogTitle = dialogOpts.dialogTitle || title;
600
+
601
+ const openDialog = async () => {
602
+ // Read current value from this.#data on each open (not captured at render time)
603
+ const currentValue = this.#getByPath(this.#data, path) || {};
604
+
605
+ console.log('Opening dialog for path:', path);
606
+ console.log('Current this.#data:', this.#data);
607
+ console.log('Current value at path:', currentValue);
608
+
609
+ this.#emit("pw:dialog-open", { path, schema: node.schema, value: currentValue });
610
+
611
+ // Create a nested form schema for the dialog
612
+ const dialogSchema = { ...node.schema, title: dialogTitle };
613
+
614
+ try {
615
+ // Use PDS.ask to show dialog with form - it returns FormData when useForm: true
616
+ const formData = await window.PDS.ask(
617
+ html`<pds-jsonform
618
+ .jsonSchema=${dialogSchema}
619
+ .values=${currentValue}
620
+ .uiSchema=${this.uiSchema}
621
+ .options=${this.options}
622
+ hide-actions
623
+ hide-legend
624
+ ></pds-jsonform>`,
625
+ {
626
+ title: dialogTitle,
627
+ type: "custom",
628
+ useForm: true,
629
+ size: dialogOpts.size || "lg",
630
+ buttons: {
631
+ ok: { name: dialogOpts.submitLabel || "Save", primary: true },
632
+ cancel: { name: dialogOpts.cancelLabel || "Cancel", cancel: true }
633
+ }
634
+ }
635
+ );
636
+
637
+ // formData is a FormData object if user clicked OK, null/false if cancelled
638
+ if (formData && formData instanceof FormData) {
639
+ // Convert FormData to nested object structure
640
+ // Note: The nested form generates paths from its own root (e.g., /name, /email)
641
+ // so we don't need to strip a basePath prefix
642
+ const updatedValue = this.#formDataToObject(formData, "", dialogSchema);
643
+
644
+ console.log('Updating path:', path, 'with value:', updatedValue);
645
+ console.log('Before update - this.#data:', structuredClone(this.#data));
646
+
647
+ // Update the data at the dialog's path
648
+ this.#setByPath(this.#data, path, updatedValue);
649
+
650
+ console.log('After update - this.#data:', structuredClone(this.#data));
651
+ console.log('Verify read back:', this.#getByPath(this.#data, path));
652
+
653
+ this.requestUpdate();
654
+ this.#emit("pw:dialog-submit", { path, value: updatedValue });
655
+ }
656
+ } catch (err) {
657
+ console.error("Dialog error:", err);
658
+ }
659
+ };
660
+
661
+ const buttonIcon = dialogOpts.icon;
662
+
663
+ return html`
664
+ <div class="dialog-field" data-path=${path}>
665
+ <button type="button" class="btn" @click=${openDialog}>
666
+ ${buttonIcon ? html`<pds-icon icon=${buttonIcon}></pds-icon>` : nothing}
667
+ ${buttonLabel}
668
+ </button>
669
+ <input type="hidden" name=${path} .value=${JSON.stringify(this.#getByPath(this.#data, path) || {})} />
670
+ </div>
671
+ `;
672
+ }
673
+
674
+ // Convert FormData to nested object, handling JSON pointer paths
675
+ #formDataToObject(formData, basePath = "", schema = null) {
676
+ const result = {};
677
+ const arrays = new Map(); // Track array values from checkbox groups
678
+
679
+ for (const [key, value] of formData.entries()) {
680
+ // Remove basePath prefix if present to get relative path
681
+ let relativePath = key;
682
+ if (basePath && key.startsWith(basePath)) {
683
+ relativePath = key.substring(basePath.length);
684
+ }
685
+
686
+ // Skip empty paths
687
+ if (!relativePath || relativePath === "/") continue;
688
+
689
+ // Handle array notation for checkbox groups (path[])
690
+ if (relativePath.endsWith('[]')) {
691
+ const arrayPath = relativePath.slice(0, -2);
692
+ if (!arrays.has(arrayPath)) {
693
+ arrays.set(arrayPath, []);
694
+ }
695
+ arrays.get(arrayPath).push(value);
696
+ continue;
697
+ }
698
+
699
+ // Convert value based on schema type if available
700
+ let convertedValue = value;
701
+ const fieldSchema = schema ? this.#schemaAtPath(schema, relativePath) : this.#schemaAt(relativePath);
702
+
703
+ if (fieldSchema) {
704
+ if (fieldSchema.type === 'number') {
705
+ convertedValue = parseFloat(value);
706
+ } else if (fieldSchema.type === 'integer') {
707
+ convertedValue = parseInt(value, 10);
708
+ } else if (fieldSchema.type === 'boolean') {
709
+ // Checkbox inputs: if present in FormData, they're checked (true)
710
+ // If not present, they're unchecked (false) - handled below
711
+ convertedValue = value === 'on' || value === 'true' || value === true;
712
+ }
713
+ }
714
+
715
+ // Set value using JSON pointer path
716
+ this.#setByPath(result, relativePath, convertedValue);
717
+ }
718
+
719
+ // Add array values from checkbox groups
720
+ for (const [arrayPath, values] of arrays) {
721
+ this.#setByPath(result, arrayPath, values);
722
+ }
723
+
724
+ // Handle unchecked checkboxes - they won't be in FormData
725
+ // We need to set them to false based on schema
726
+ this.#ensureCheckboxDefaults(result, basePath, schema);
727
+
728
+ return result;
729
+ }
730
+
731
+ // Ensure boolean fields that weren't in FormData are set to false
732
+ #ensureCheckboxDefaults(obj, basePath = "", schemaRoot = null) {
733
+ const schema = schemaRoot ? this.#schemaAtPath(schemaRoot, basePath) : this.#schemaAt(basePath);
734
+ if (!schema) return;
735
+
736
+ if (schema.type === 'object' && schema.properties) {
737
+ for (const [key, propSchema] of Object.entries(schema.properties)) {
738
+ const propPath = basePath + "/" + this.#escapeJsonPointer(key);
739
+ const relativePath = propPath.startsWith("/") ? propPath.substring(1) : propPath;
740
+
741
+ if (propSchema.type === 'boolean' && this.#getByPath(obj, propPath) === undefined) {
742
+ this.#setByPath(obj, propPath, false);
743
+ } else if (propSchema.type === 'object') {
744
+ this.#ensureCheckboxDefaults(obj, propPath, schemaRoot);
745
+ }
746
+ }
747
+ }
748
+ }
749
+
750
+ #renderChoice(node) {
751
+ const path = node.path;
752
+ const value = this.#getByPath(this.#data, path + "/__choice");
753
+ const index = Number.isInteger(value) ? value : 0;
754
+ const active = node.options[index] ?? node.options[0];
755
+
756
+ const onChange = (e) => {
757
+ const i = Number(e.target.value);
758
+ this.#setByPath(this.#data, path + "/__choice", i);
759
+ this.#deleteByPathPrefix(this.#data, path + "/");
760
+ this.requestUpdate();
761
+ this.#emit("pw:value-change", {
762
+ name: path,
763
+ value: i,
764
+ validity: { valid: true },
765
+ });
766
+ };
767
+
768
+ return html`
769
+ <fieldset data-path=${path}>
770
+ <legend>${node.title ?? "Choose one"}</legend>
771
+ <label>
772
+ <span data-label>Variant</span>
773
+ <select @change=${onChange} .value=${String(index)}>
774
+ ${node.options.map(
775
+ (opt, i) => html`<option value=${String(i)}>${opt.title}</option>`
776
+ )}
777
+ </select>
778
+ </label>
779
+ <div>${this.#renderNode(this.#compileNode(active.schema, path))}</div>
780
+ </fieldset>
781
+ `;
782
+ }
783
+
784
+ #renderArray(node) {
785
+ const path = node.path;
786
+ const arr = this.#ensureArrayAtPath(path);
787
+ const ui = node.ui || this.#uiFor(path);
788
+ const itemSchema = node.item?.schema;
789
+
790
+ // Check if this is a simple string array that should use the open group enhancement
791
+ const isSimpleStringArray = itemSchema?.type === "string" &&
792
+ !itemSchema.format &&
793
+ !itemSchema.enum &&
794
+ (!itemSchema.maxLength || itemSchema.maxLength <= 100);
795
+
796
+ // Check layout preference: use 'open' for simple string arrays by default, or if explicitly set
797
+ let arrayLayout = ui?.["ui:arrayLayout"];
798
+ if (!arrayLayout) {
799
+ // If not explicitly set, use global option with smart default based on array type
800
+ const globalDefault = this.#getOption('layouts.arrays', 'default');
801
+ arrayLayout = globalDefault === 'default' && isSimpleStringArray ? 'open' : globalDefault;
802
+ }
803
+
804
+ const useOpenGroup = arrayLayout === 'open' && isSimpleStringArray;
805
+
806
+ // Check if this is a single-selection array (maxItems: 1) for radio group
807
+ const isSingleSelection = node.schema?.maxItems === 1;
808
+ const inputType = isSingleSelection ? "radio" : "checkbox";
809
+
810
+ if (useOpenGroup) {
811
+ // Render fieldset with data-open to let the enhancement handle UI
812
+ // We sync state after the enhancement runs via MutationObserver
813
+
814
+ const syncFromDOM = (fieldset) => {
815
+ // Read current state from DOM (after enhancement has modified it)
816
+ const inputs = Array.from(fieldset.querySelectorAll('input[type="radio"], input[type="checkbox"]'));
817
+ const values = inputs
818
+ .map(input => input.value)
819
+ .filter(v => v && v.trim());
820
+
821
+ if (isSingleSelection) {
822
+ // For radio groups, find the checked one
823
+ const checkedInput = inputs.find(input => input.checked);
824
+ this.#setByPath(this.#data, path, checkedInput && checkedInput.value ? [checkedInput.value] : []);
825
+ } else {
826
+ // For checkbox groups, all items in DOM are in the array
827
+ this.#setByPath(this.#data, path, values);
828
+ }
829
+ this.#emit("pw:array-change", { path, values: this.#getByPath(this.#data, path) });
830
+ };
831
+
832
+ const handleChange = (e) => {
833
+ const fieldset = e.currentTarget;
834
+ if (isSingleSelection) {
835
+ // For radio groups, update to selected value immediately
836
+ const checkedInput = fieldset.querySelector('input[type="radio"]:checked');
837
+ this.#setByPath(this.#data, path, checkedInput && checkedInput.value ? [checkedInput.value] : []);
838
+ this.#emit("pw:array-change", { path, values: this.#getByPath(this.#data, path) });
839
+ }
840
+ };
841
+
842
+ const afterRender = (fieldset) => {
843
+ // Observe DOM changes made by the data-open enhancement
844
+ const observer = new MutationObserver(() => {
845
+ syncFromDOM(fieldset);
846
+ });
847
+ observer.observe(fieldset, {
848
+ childList: true,
849
+ subtree: true
850
+ });
851
+
852
+ // Store observer for cleanup
853
+ if (!fieldset._arrayObserver) {
854
+ fieldset._arrayObserver = observer;
855
+ }
856
+ };
857
+
858
+ const selectedValue = isSingleSelection && arr.length > 0 ? arr[0] : null;
859
+
860
+ return html`
861
+ <fieldset
862
+ role="group"
863
+ data-open
864
+ data-path=${path}
865
+ data-name=${path}
866
+ @change=${handleChange}
867
+ ${ref((el) => { if (el) afterRender(el); })}
868
+ >
869
+ <legend>${node.title ?? "List"}</legend>
870
+ ${arr.map((value, i) => {
871
+ const id = `${path}-${i}`;
872
+ const isChecked = isSingleSelection ? value === selectedValue : false;
873
+ return html`
874
+ <label for=${id}>
875
+ <span data-label>${value}</span>
876
+ <input
877
+ id=${id}
878
+ type=${inputType}
879
+ name=${path}
880
+ value=${value}
881
+ ?checked=${isChecked}
882
+ />
883
+ </label>
884
+ `;
885
+ })}
886
+ </fieldset>
887
+ `;
888
+ }
889
+
890
+ // Standard array with add/remove controls for complex items
891
+ const add = () => {
892
+ arr.push(this.#defaultFor(node.item.schema));
893
+ this.requestUpdate();
894
+ this.#emit("pw:array-add", { path });
895
+ };
896
+ const remove = (idx) => {
897
+ arr.splice(idx, 1);
898
+ this.requestUpdate();
899
+ this.#emit("pw:array-remove", { path, index: idx });
900
+ };
901
+ const move = (from, to) => {
902
+ if (to < 0 || to >= arr.length) return;
903
+ const [v] = arr.splice(from, 1);
904
+ arr.splice(to, 0, v);
905
+ this.requestUpdate();
906
+ this.#emit("pw:array-reorder", { path, from, to });
907
+ };
908
+
909
+ return html`
910
+ <fieldset data-path=${path}>
911
+ <legend>${node.title ?? "List"}</legend>
912
+ <div class="array-list">
913
+ ${arr.map(
914
+ (_, i) => html`
915
+ <div class="array-item" data-index=${i}>
916
+ ${this.#renderNode(this.#repath(node.item, path + "/" + i))}
917
+ <div class="array-controls">
918
+ <button
919
+ type="button"
920
+ @click=${() => move(i, i - 1)}
921
+ title="Move up"
922
+ >
923
+
924
+ </button>
925
+ <button
926
+ type="button"
927
+ @click=${() => move(i, i + 1)}
928
+ title="Move down"
929
+ >
930
+
931
+ </button>
932
+ <button
933
+ type="button"
934
+ @click=${() => remove(i)}
935
+ title="Remove"
936
+ >
937
+ Remove
938
+ </button>
939
+ </div>
940
+ </div>
941
+ `
942
+ )}
943
+ </div>
944
+ <div class="array-controls">
945
+ <button type="button" @click=${add}>Add</button>
946
+ </div>
947
+ </fieldset>
948
+ `;
949
+ }
950
+
951
+ #repath(subNode, newPath) {
952
+ const updated = { ...subNode, path: newPath };
953
+ // Clear cached UI so it gets looked up with the new path
954
+ delete updated.ui;
955
+ // Recursively update children paths if this is a fieldset
956
+ if (updated.kind === "fieldset" && updated.children) {
957
+ const oldPath = subNode.path;
958
+ updated.children = updated.children.map((child) => {
959
+ // Replace the old path prefix with the new path
960
+ const childNewPath = child.path.replace(oldPath, newPath);
961
+ return this.#repath(child, childNewPath);
962
+ });
963
+ }
964
+ return updated;
965
+ }
966
+
967
+ #renderField(node) {
968
+ const path = node.path;
969
+ const id = this.#idFromPath(path);
970
+ const label = node.title ?? this.#titleFromPath(path);
971
+ const value = this.#getByPath(this.#data, path);
972
+ const required = this.#isRequired(path);
973
+ const ui = node.ui || this.#uiFor(path);
974
+
975
+ // Override hook before default field render
976
+ {
977
+ const override = this.#emitCancelable("pw:before-render-field", {
978
+ path,
979
+ schema: node.schema,
980
+ ui,
981
+ mount: null,
982
+ render: null,
983
+ });
984
+ if (override?.render) return override.render();
985
+ }
986
+
987
+ // Default renderer lookup: returns ONLY the control markup
988
+ const renderer =
989
+ this.#renderers.get(node.widgetKey) || this.#renderers.get("*");
990
+ let controlTpl = renderer
991
+ ? renderer({
992
+ id,
993
+ path,
994
+ label,
995
+ value,
996
+ required,
997
+ ui,
998
+ schema: node.schema,
999
+ get: (p) => this.#getByPath(this.#data, p ?? path),
1000
+ set: (val, p) => this.#assignValue(p ?? path, val),
1001
+ attrs: this.#nativeConstraints(path, node.schema),
1002
+ host: this,
1003
+ })
1004
+ : nothing;
1005
+
1006
+ // Post-creation tweak
1007
+ controlTpl =
1008
+ this.#emitReadonly("pw:render-field", {
1009
+ path,
1010
+ schema: node.schema,
1011
+ node: controlTpl,
1012
+ }) ?? controlTpl;
1013
+
1014
+ // Wrap with icon if ui:icon is specified and enhancements.icons is enabled
1015
+ const iconName = ui?.["ui:icon"];
1016
+ const iconPos = ui?.["ui:iconPosition"] || "start";
1017
+ if (iconName && this.#getOption('enhancements.icons', true)) {
1018
+ const iconClasses = iconPos === "end" ? "input-icon input-icon-end" : "input-icon";
1019
+ controlTpl = html`
1020
+ <div class=${iconClasses}>
1021
+ ${iconPos === "start" ? html`<pds-icon icon=${iconName}></pds-icon>` : nothing}
1022
+ ${controlTpl}
1023
+ ${iconPos === "end" ? html`<pds-icon icon=${iconName}></pds-icon>` : nothing}
1024
+ </div>
1025
+ `;
1026
+ }
1027
+
1028
+ const help = ui?.["ui:help"];
1029
+
1030
+ // Group widgets use fieldset
1031
+ if (this.#isGroupWidget(node.widgetKey)) {
1032
+ queueMicrotask(() =>
1033
+ this.#emit("pw:after-render-field", { path, schema: node.schema })
1034
+ );
1035
+ const role =
1036
+ node.widgetKey === "radio"
1037
+ ? "radiogroup"
1038
+ : node.widgetKey === "checkbox-group"
1039
+ ? "group"
1040
+ : undefined;
1041
+ const fieldsetClass = ui?.["ui:class"];
1042
+ return html`
1043
+ <fieldset data-path=${path} role=${ifDefined(role)} class=${ifDefined(fieldsetClass)}>
1044
+ <legend>${label}</legend>
1045
+ ${controlTpl} ${help ? html`<div data-help>${help}</div>` : nothing}
1046
+ </fieldset>
1047
+ `;
1048
+ }
1049
+
1050
+ // Standard label wrapper
1051
+ queueMicrotask(() =>
1052
+ this.#emit("pw:after-render-field", { path, schema: node.schema })
1053
+ );
1054
+
1055
+ // Add data-toggle for toggle switches
1056
+ const isToggle = node.widgetKey === "toggle";
1057
+
1058
+ // Add range-output class for range inputs if enabled
1059
+ const isRange = node.widgetKey === "input-range";
1060
+ const useRangeOutput = isRange && this.#getOption('enhancements.rangeOutput', true);
1061
+ const labelClass = useRangeOutput ? "range-output" : undefined;
1062
+
1063
+ const renderControlAndLabel = (isToggle) => {
1064
+ if(isToggle)
1065
+ return html`${controlTpl} <span data-label>${label}</span>`;
1066
+
1067
+ return html`<span data-label>${label}</span> ${controlTpl}`;
1068
+
1069
+ }
1070
+
1071
+ return html`
1072
+ <label for=${id} ?data-toggle=${isToggle} class=${ifDefined(labelClass)}>
1073
+
1074
+ ${renderControlAndLabel(isToggle)}
1075
+
1076
+ ${help ? html`<div data-help>${help}</div>` : nothing}
1077
+ </label>
1078
+ `;
1079
+ }
1080
+
1081
+ // ===== Default renderers: controls only (no spread arrays) =====
1082
+ #installDefaultRenderers() {
1083
+ // Fallback text input
1084
+ this.#renderers.set(
1085
+ "*",
1086
+ ({ id, path, value, attrs, set }) => html`
1087
+ <input
1088
+ id=${id}
1089
+ name=${path}
1090
+ placeholder=${ifDefined(attrs.placeholder)}
1091
+ type="text"
1092
+ .value=${value ?? ""}
1093
+ minlength=${ifDefined(attrs.minLength)}
1094
+ maxlength=${ifDefined(attrs.maxLength)}
1095
+ pattern=${ifDefined(attrs.pattern)}
1096
+ ?readonly=${!!attrs.readOnly}
1097
+ ?required=${!!attrs.required}
1098
+ autocomplete=${ifDefined(attrs.autocomplete)}
1099
+ @input=${(e) => set(e.target.value)}
1100
+ />
1101
+ `
1102
+ );
1103
+
1104
+ this.defineRenderer(
1105
+ "input-text",
1106
+ ({ id, path, value, attrs, set, ui }) => html`
1107
+ <input
1108
+ id=${id}
1109
+ name=${path}
1110
+ placeholder=${ifDefined(attrs.placeholder)}
1111
+ type="text"
1112
+ .value=${value ?? ""}
1113
+ minlength=${ifDefined(attrs.minLength)}
1114
+ maxlength=${ifDefined(attrs.maxLength)}
1115
+ pattern=${ifDefined(attrs.pattern)}
1116
+ ?readonly=${!!attrs.readOnly}
1117
+ ?required=${!!attrs.required}
1118
+ autocomplete=${ifDefined(attrs.autocomplete)}
1119
+ list=${ifDefined(ui?.["ui:datalist"] ? `${id}-datalist` : attrs.list)}
1120
+ @input=${(e) => set(e.target.value)}
1121
+ />
1122
+ ${ui?.["ui:datalist"]
1123
+ ? html`
1124
+ <datalist id="${id}-datalist">
1125
+ ${ui["ui:datalist"].map(
1126
+ (opt) => html`<option value="${opt}"></option>`
1127
+ )}
1128
+ </datalist>
1129
+ `
1130
+ : nothing}
1131
+ `
1132
+ );
1133
+
1134
+ this.defineRenderer(
1135
+ "textarea",
1136
+ ({ id, path, value, attrs, set, ui }) => html`
1137
+ <textarea
1138
+ id=${id}
1139
+ name=${path}
1140
+ placeholder=${ifDefined(attrs.placeholder)}
1141
+ .value=${value ?? ""}
1142
+ rows=${ui?.["ui:rows"] ?? 4}
1143
+ minlength=${ifDefined(attrs.minLength)}
1144
+ maxlength=${ifDefined(attrs.maxLength)}
1145
+ ?readonly=${!!attrs.readOnly}
1146
+ ?required=${!!attrs.required}
1147
+ @input=${(e) => set(e.target.value)}
1148
+ ></textarea>
1149
+ `
1150
+ );
1151
+
1152
+ this.defineRenderer("input-number", ({ id, path, value, attrs, set, schema }) => {
1153
+ const step = attrs.step || getStep(value);
1154
+ return html`
1155
+ <input
1156
+ id=${id}
1157
+ name=${path}
1158
+ type="number"
1159
+ placeholder=${ifDefined(attrs.placeholder)}
1160
+ .value=${value ?? ""}
1161
+ min=${ifDefined(attrs.min)}
1162
+ max=${ifDefined(attrs.max)}
1163
+ step=${ifDefined(step)}
1164
+ ?readonly=${!!attrs.readOnly}
1165
+ ?required=${!!attrs.required}
1166
+ @input=${(e) => {
1167
+ const v = e.target.value;
1168
+ const numValue =
1169
+ schema.type === "integer" ? parseInt(v, 10) : parseFloat(v);
1170
+ const step =
1171
+ attrs.step ||
1172
+ (numValue % 1 !== 0
1173
+ ? `0.${"1".padStart(
1174
+ numValue.toString().split(".")[1]?.length || 0,
1175
+ "0"
1176
+ )}`
1177
+ : "1");
1178
+ e.target.step = step; // Dynamically set step based on value precision
1179
+ if (
1180
+ (attrs.min != null && numValue < attrs.min) ||
1181
+ (attrs.max != null && numValue > attrs.max) ||
1182
+ (attrs.step != null && numValue % parseFloat(attrs.step) !== 0)
1183
+ ) {
1184
+ e.target.setCustomValidity("Invalid value");
1185
+ } else {
1186
+ e.target.setCustomValidity("");
1187
+ set(numValue);
1188
+ }
1189
+ }}
1190
+ />
1191
+ `;
1192
+ });
1193
+
1194
+ // Range input renderer for ui:widget = 'input-range'
1195
+ this.defineRenderer(
1196
+ "input-range",
1197
+ ({ id, path, value, attrs, set, ui }) => {
1198
+ const min = ui?.["ui:min"] ?? attrs.min ?? 0;
1199
+ const max = ui?.["ui:max"] ?? attrs.max ?? 100;
1200
+ const step = attrs.step || 1;
1201
+ return html`
1202
+ <div class="range-container">
1203
+ <input
1204
+ id=${id}
1205
+ name=${path}
1206
+ type="range"
1207
+ min=${min}
1208
+ max=${max}
1209
+ step=${step}
1210
+ .value=${value ?? min}
1211
+ @input=${(e) => set(Number(e.target.value))}
1212
+ />
1213
+ <div class="range-bubble" aria-hidden="true">${value ?? min}</div>
1214
+ </div>
1215
+ `;
1216
+ }
1217
+ );
1218
+
1219
+ this.defineRenderer(
1220
+ "input-email",
1221
+ ({ id, path, value, attrs, set }) => html`
1222
+ <input
1223
+ id=${id}
1224
+ name=${path}
1225
+ placeholder=${ifDefined(attrs.placeholder)}
1226
+ type="email"
1227
+ .value=${value ?? ""}
1228
+ ?readonly=${!!attrs.readOnly}
1229
+ ?required=${!!attrs.required}
1230
+ autocomplete=${ifDefined(attrs.autocomplete)}
1231
+ @input=${(e) => set(e.target.value)}
1232
+ />
1233
+ `
1234
+ );
1235
+
1236
+ this.defineRenderer(
1237
+ "input-password",
1238
+ ({ id, path, value, attrs, set, ui }) => {
1239
+ // Determine autocomplete value based on UI hints or use "current-password" as default
1240
+ const autocomplete = ui?.["ui:autocomplete"] || attrs.autocomplete || "current-password";
1241
+
1242
+ return html`
1243
+ <input
1244
+ id=${id}
1245
+ name=${path}
1246
+ placeholder=${ifDefined(attrs.placeholder)}
1247
+ type="password"
1248
+ .value=${value ?? ""}
1249
+ minlength=${ifDefined(attrs.minLength)}
1250
+ maxlength=${ifDefined(attrs.maxLength)}
1251
+ ?readonly=${!!attrs.readOnly}
1252
+ ?required=${!!attrs.required}
1253
+ autocomplete=${autocomplete}
1254
+ @input=${(e) => set(e.target.value)}
1255
+ />
1256
+ `;
1257
+ }
1258
+ );
1259
+
1260
+ this.defineRenderer(
1261
+ "input-url",
1262
+ ({ id, path, value, attrs, set }) => html`
1263
+ <input
1264
+ id=${id}
1265
+ name=${path}
1266
+ placeholder=${ifDefined(attrs.placeholder)}
1267
+ type="url"
1268
+ .value=${value ?? ""}
1269
+ ?readonly=${!!attrs.readOnly}
1270
+ ?required=${!!attrs.required}
1271
+ @input=${(e) => set(e.target.value)}
1272
+ />
1273
+ `
1274
+ );
1275
+
1276
+ this.defineRenderer(
1277
+ "input-date",
1278
+ ({ id, path, value, attrs, set }) => html`
1279
+ <input
1280
+ id=${id}
1281
+ name=${path}
1282
+ placeholder=${ifDefined(attrs.placeholder)}
1283
+ type="date"
1284
+ .value=${value ?? ""}
1285
+ min=${ifDefined(attrs.min)}
1286
+ max=${ifDefined(attrs.max)}
1287
+ ?readonly=${!!attrs.readOnly}
1288
+ ?required=${!!attrs.required}
1289
+ @input=${(e) => set(e.target.value)}
1290
+ />
1291
+ `
1292
+ );
1293
+
1294
+ this.defineRenderer(
1295
+ "input-time",
1296
+ ({ id, path, value, attrs, set }) => html`
1297
+ <input
1298
+ id=${id}
1299
+ name=${path}
1300
+ placeholder=${ifDefined(attrs.placeholder)}
1301
+ type="time"
1302
+ .value=${value ?? ""}
1303
+ ?readonly=${!!attrs.readOnly}
1304
+ ?required=${!!attrs.required}
1305
+ @input=${(e) => set(e.target.value)}
1306
+ />
1307
+ `
1308
+ );
1309
+
1310
+ this.defineRenderer(
1311
+ "input-color",
1312
+ ({ id, path, value, attrs, set }) => html`
1313
+ <input
1314
+ id=${id}
1315
+ name=${path}
1316
+ placeholder=${ifDefined(attrs.placeholder)}
1317
+ type="color"
1318
+ .value=${value ?? ""}
1319
+ ?readonly=${!!attrs.readOnly}
1320
+ ?required=${!!attrs.required}
1321
+ @input=${(e) => set(e.target.value)}
1322
+ />
1323
+ `
1324
+ );
1325
+
1326
+ this.defineRenderer(
1327
+ "input-datetime",
1328
+ ({ id, path, value, attrs, set }) => html`
1329
+ <input
1330
+ id=${id}
1331
+ name=${path}
1332
+ placeholder=${ifDefined(attrs.placeholder)}
1333
+ type="datetime-local"
1334
+ .value=${value ?? ""}
1335
+ ?readonly=${!!attrs.readOnly}
1336
+ ?required=${!!attrs.required}
1337
+ @input=${(e) => set(e.target.value)}
1338
+ />
1339
+ `
1340
+ );
1341
+
1342
+ this.defineRenderer(
1343
+ "checkbox",
1344
+ ({ id, path, value, attrs, set }) => html`
1345
+ <input
1346
+ id=${id}
1347
+ name=${path}
1348
+ type="checkbox"
1349
+ .checked=${!!value}
1350
+ ?required=${!!attrs.required}
1351
+ @change=${(e) => set(!!e.target.checked)}
1352
+ />
1353
+ `
1354
+ );
1355
+
1356
+ // Toggle switch (uses data-toggle attribute on label, rendered in #renderField)
1357
+ this.defineRenderer(
1358
+ "toggle",
1359
+ ({ id, path, value, attrs, set }) => html`
1360
+ <input
1361
+ id=${id}
1362
+ name=${path}
1363
+ type="checkbox"
1364
+ .checked=${!!value}
1365
+ ?required=${!!attrs.required}
1366
+ @change=${(e) => set(!!e.target.checked)}
1367
+ />
1368
+ `
1369
+ );
1370
+
1371
+ this.defineRenderer(
1372
+ "select",
1373
+ ({ id, path, value, attrs, set, schema, ui, host }) => {
1374
+ const useDropdown = host.#getOption('widgets.selects', 'standard') === 'dropdown' ||
1375
+ ui?.["ui:dropdown"] === true;
1376
+ const enumValues = schema.enum || [];
1377
+ const enumLabels = schema.enumNames || enumValues;
1378
+ return html`
1379
+ <select
1380
+ id=${id}
1381
+ name=${path}
1382
+ .value=${value ?? ""}
1383
+ ?required=${!!attrs.required}
1384
+ ?data-dropdown=${useDropdown}
1385
+ @change=${(e) => set(e.target.value)}
1386
+ >
1387
+ <option value="" ?selected=${value == null}>—</option>
1388
+ ${enumValues.map(
1389
+ (v, i) => html`<option value=${String(v)}>${String(enumLabels[i])}</option>`
1390
+ )}
1391
+ </select>
1392
+ `;
1393
+ }
1394
+ );
1395
+
1396
+ // Radio group: returns ONLY the labeled inputs
1397
+ // Matches PDS pattern: input hidden, label styled as button
1398
+ this.defineRenderer(
1399
+ "radio",
1400
+ ({ id, path, value, attrs, set, schema }) => {
1401
+ const enumValues = schema.enum || [];
1402
+ const enumLabels = schema.enumNames || enumValues;
1403
+ return html`
1404
+ ${enumValues.map((v, i) => {
1405
+ const rid = `${id}-${i}`;
1406
+ return html`
1407
+ <label for=${rid}>
1408
+ <input
1409
+ id=${rid}
1410
+ type="radio"
1411
+ name=${path}
1412
+ .value=${String(v)}
1413
+ .checked=${String(value) === String(v)}
1414
+ ?required=${!!attrs.required}
1415
+ @change=${(e) => {
1416
+ if (e.target.checked) set(enumValues[i]);
1417
+ }}
1418
+ />
1419
+ ${String(enumLabels[i])}
1420
+ </label>
1421
+ `;
1422
+ })}
1423
+ `;
1424
+ }
1425
+ );
1426
+
1427
+ // Checkbox group: for multi-select from enum (array type with enum items)
1428
+ // Shows actual checkboxes (not button-style like radios)
1429
+ this.defineRenderer(
1430
+ "checkbox-group",
1431
+ ({ id, path, value, attrs, set, schema }) => {
1432
+ const selected = Array.isArray(value) ? value : [];
1433
+ const options = schema.items?.enum || schema.enum || [];
1434
+ const optionLabels = schema.items?.enumNames || schema.enumNames || options;
1435
+
1436
+ return html`
1437
+ ${options.map((v, i) => {
1438
+ const cid = `${id}-${i}`;
1439
+ const isChecked = selected.includes(v);
1440
+
1441
+ return html`
1442
+ <label for=${cid}>
1443
+ <input
1444
+ id=${cid}
1445
+ name="${path}[]"
1446
+ type="checkbox"
1447
+ .value=${String(v)}
1448
+ .checked=${isChecked}
1449
+ @change=${(e) => {
1450
+ // Use e.target.checked to get the NEW state after the change
1451
+ const newSelected = e.target.checked
1452
+ ? [...selected, v]
1453
+ : selected.filter((x) => x !== v);
1454
+ set(newSelected);
1455
+ }}
1456
+ />
1457
+ <span>${String(optionLabels[i])}</span>
1458
+ </label>
1459
+ `;
1460
+ })}
1461
+ `;
1462
+ }
1463
+ );
1464
+
1465
+ this.defineRenderer(
1466
+ "const",
1467
+ ({ id, path, value, schema }) => html`
1468
+ <input
1469
+ id=${id}
1470
+ name=${path}
1471
+ type="text"
1472
+ .value=${schema.const ?? value ?? ""}
1473
+ readonly
1474
+ />
1475
+ `
1476
+ );
1477
+
1478
+ // pds-upload: File upload component
1479
+ this.defineRenderer(
1480
+ "upload",
1481
+ ({ id, value, attrs, set, ui, path }) => {
1482
+ const uploadOpts = ui?.["ui:options"] || {};
1483
+ return html`
1484
+ <pds-upload
1485
+ id=${id}
1486
+ accept=${ifDefined(uploadOpts.accept)}
1487
+ ?multiple=${uploadOpts.multiple ?? false}
1488
+ max-files=${ifDefined(uploadOpts.maxFiles)}
1489
+ max-size=${ifDefined(uploadOpts.maxSize)}
1490
+ label=${ifDefined(uploadOpts.label)}
1491
+ ?required=${!!attrs.required}
1492
+ @pw:change=${(e) => set(e.detail.files)}
1493
+ ></pds-upload>
1494
+ `;
1495
+ }
1496
+ );
1497
+
1498
+ // pds-richtext: Rich text editor
1499
+ this.defineRenderer(
1500
+ "richtext",
1501
+ ({ id, value, attrs, set, ui, path }) => {
1502
+ const richtextOpts = ui?.["ui:options"] || {};
1503
+ return html`
1504
+ <pds-richtext
1505
+ id=${id}
1506
+ name=${path}
1507
+ placeholder=${ifDefined(richtextOpts.placeholder || attrs.placeholder)}
1508
+ .value=${value ?? ""}
1509
+ toolbar=${ifDefined(richtextOpts.toolbar)}
1510
+ ?required=${!!attrs.required}
1511
+ ?submit-on-enter=${richtextOpts.submitOnEnter ?? false}
1512
+ spellcheck=${richtextOpts.spellcheck ?? true ? "true" : "false"}
1513
+ @input=${(e) => set(e.target.value)}
1514
+ ></pds-richtext>
1515
+ `;
1516
+ }
1517
+ );
1518
+ }
1519
+
1520
+ // ===== Form submit =====
1521
+ async #onSubmit(e) {
1522
+ if (e) e.preventDefault?.();
1523
+ const form = this.renderRoot?.querySelector("form");
1524
+ let nativeValid = true;
1525
+ if (form) nativeValid = form.checkValidity();
1526
+
1527
+ let schemaValid = { valid: true };
1528
+ if (this.#validator) {
1529
+ try {
1530
+ schemaValid = await this.#validator(this.#data, this.jsonSchema);
1531
+ } catch (err) {
1532
+ schemaValid = { valid: false, errors: [{ message: String(err) }] };
1533
+ }
1534
+ }
1535
+
1536
+ const payload = this.serialize();
1537
+ const serialDetail = {
1538
+ ...payload,
1539
+ valid: nativeValid && schemaValid.valid,
1540
+ issues: schemaValid.errors || [],
1541
+ };
1542
+ const pre = this.#emitCancelable("pw:serialize", serialDetail);
1543
+ const final = pre || serialDetail;
1544
+
1545
+ this.#emit("pw:submit", final);
1546
+ return final;
1547
+ }
1548
+
1549
+ // ===== Utilities =====
1550
+ #uiFor(path) {
1551
+ if (!this.uiSchema) return undefined;
1552
+
1553
+ // Try exact match first (flat structure)
1554
+ if (this.uiSchema[path]) return this.uiSchema[path];
1555
+
1556
+ // Try with leading slash
1557
+ const withSlash = this.#asRel(path);
1558
+ if (this.uiSchema[withSlash]) return this.uiSchema[withSlash];
1559
+
1560
+ // Try without leading slash for convenience
1561
+ const withoutSlash = path.startsWith("/") ? path.substring(1) : path;
1562
+ if (this.uiSchema[withoutSlash]) return this.uiSchema[withoutSlash];
1563
+
1564
+ // Try nested navigation (e.g., userProfile/settings/preferences/theme)
1565
+ // Skip array indices (numeric parts and wildcard *) when navigating UI schema
1566
+ const parts = path.replace(/^\//, '').split('/');
1567
+ let current = this.uiSchema;
1568
+ for (const part of parts) {
1569
+ // Skip numeric array indices and wildcard in UI schema navigation
1570
+ if (/^\d+$/.test(part) || part === '*') continue;
1571
+
1572
+ if (current && typeof current === 'object' && part in current) {
1573
+ current = current[part];
1574
+ } else {
1575
+ return undefined;
1576
+ }
1577
+ }
1578
+ // Only return if we found a UI config object (not a nested parent)
1579
+ return current && typeof current === 'object' ? current : undefined;
1580
+ }
1581
+ #asRel(path) {
1582
+ return path.startsWith("/") ? path : "/" + path;
1583
+ }
1584
+
1585
+ #idFromPath(path) {
1586
+ const norm = path.replace(/[^a-zA-Z0-9_\-]+/g, "-");
1587
+ return `${this.#idBase}${norm ? "-" + norm : ""}`;
1588
+ }
1589
+
1590
+ #titleFromPath(path) {
1591
+ const seg = path.split("/").filter(Boolean).pop() || "";
1592
+ return seg
1593
+ .replace(/-/g, " ")
1594
+ .replace(/_/g, " ")
1595
+ .replace(/\*/g, "")
1596
+ .replace(/\b\w/g, (c) => c.toUpperCase());
1597
+ }
1598
+
1599
+ #nativeConstraints(path, schema) {
1600
+ // Use placeholder if explicitly set, otherwise use first example as placeholder
1601
+ const attrs = {
1602
+ placeholder: schema.placeholder || (schema.examples && schema.examples.length > 0 ? schema.examples[0] : undefined)
1603
+ };
1604
+
1605
+ if (schema.type === "string") {
1606
+ if (schema.minLength != null) attrs.minLength = schema.minLength;
1607
+ if (schema.maxLength != null) attrs.maxLength = schema.maxLength;
1608
+ if (schema.pattern) attrs.pattern = schema.pattern;
1609
+ }
1610
+ if (schema.type === "number" || schema.type === "integer") {
1611
+ if (schema.minimum != null) attrs.min = schema.minimum;
1612
+ if (schema.maximum != null) attrs.max = schema.maximum;
1613
+ if (schema.multipleOf != null) attrs.step = schema.multipleOf;
1614
+ }
1615
+ if (schema.readOnly) attrs.readOnly = true;
1616
+ if (schema.writeOnly) attrs.readOnly = true;
1617
+ if (schema.format === "email") attrs.autocomplete = "email";
1618
+ if (this.#isRequired(path, schema)) attrs.required = true;
1619
+ return attrs;
1620
+ }
1621
+
1622
+ #isRequired(path, schemaNode = null) {
1623
+ if (
1624
+ schemaNode &&
1625
+ Object.prototype.hasOwnProperty.call(schemaNode, "required")
1626
+ )
1627
+ return !!schemaNode.required;
1628
+ try {
1629
+ const parts = path.split("/").filter(Boolean);
1630
+ const prop = parts.pop();
1631
+ const parentPath = "/" + parts.join("/");
1632
+ const parent = this.#schemaAt(parentPath);
1633
+ return !!(
1634
+ parent?.required &&
1635
+ Array.isArray(parent.required) &&
1636
+ parent.required.includes(this.#unescapeJsonPointer(prop))
1637
+ );
1638
+ } catch {
1639
+ return false;
1640
+ }
1641
+ }
1642
+
1643
+ #schemaAt(path) {
1644
+ return this.#schemaAtPath(this.jsonSchema, path);
1645
+ }
1646
+
1647
+ #schemaAtPath(schemaRoot, path) {
1648
+ let cur = schemaRoot;
1649
+ for (const seg of path.split("/").filter(Boolean)) {
1650
+ const key = this.#unescapeJsonPointer(seg);
1651
+ if (cur?.type === "object" && cur.properties && key in cur.properties) {
1652
+ cur = cur.properties[key];
1653
+ } else if (cur?.type === "array") {
1654
+ cur = cur.items;
1655
+ } else {
1656
+ return null;
1657
+ }
1658
+ }
1659
+ return cur;
1660
+ }
1661
+
1662
+ #defaultFor(schema) {
1663
+ if (schema && schema.default !== undefined)
1664
+ return structuredClone(schema.default);
1665
+ switch (schema?.type) {
1666
+ case "string":
1667
+ return "";
1668
+ case "number":
1669
+ case "integer":
1670
+ return 0;
1671
+ case "boolean":
1672
+ return false;
1673
+ case "object":
1674
+ return {};
1675
+ case "array":
1676
+ return [];
1677
+ default:
1678
+ return null;
1679
+ }
1680
+ }
1681
+
1682
+ #ensureArrayAtPath(path) {
1683
+ let arr = this.#getByPath(this.#data, path);
1684
+ if (!Array.isArray(arr)) {
1685
+ arr = [];
1686
+ this.#setByPath(this.#data, path, arr);
1687
+ }
1688
+ return arr;
1689
+ }
1690
+
1691
+ #assignValue(path, val) {
1692
+ this.#setByPath(this.#data, path, val);
1693
+ this.requestUpdate();
1694
+ const validity = { valid: true };
1695
+ this.#emit("pw:value-change", { name: path, value: val, validity });
1696
+ }
1697
+
1698
+ #getByPath(obj, path) {
1699
+ if (!path || path === "") return obj;
1700
+ const parts = path.split("/").filter(Boolean);
1701
+ let cur = obj;
1702
+ for (const seg of parts) {
1703
+ const k = seg === "*" ? seg : this.#unescapeJsonPointer(seg);
1704
+ if (k === "*") return cur;
1705
+ if (cur == null) return undefined;
1706
+ cur = cur[k];
1707
+ }
1708
+ return cur;
1709
+ }
1710
+
1711
+ #setByPath(obj, path, val) {
1712
+ if (!path || path === "") throw new Error("Cannot set root directly");
1713
+ const parts = path.split("/").filter(Boolean);
1714
+ let cur = obj;
1715
+ for (let i = 0; i < parts.length - 1; i++) {
1716
+ const seg = this.#unescapeJsonPointer(parts[i]);
1717
+ if (!(seg in cur) || typeof cur[seg] !== "object" || cur[seg] === null)
1718
+ cur[seg] = {};
1719
+ cur = cur[seg];
1720
+ }
1721
+ const last = this.#unescapeJsonPointer(parts[parts.length - 1]);
1722
+ cur[last] = val;
1723
+ }
1724
+
1725
+ #deleteByPathPrefix(obj, prefix) {
1726
+ const parts = prefix.split("/").filter(Boolean);
1727
+ const stack = [];
1728
+ let cur = obj;
1729
+ for (const seg of parts) {
1730
+ const key = this.#unescapeJsonPointer(seg);
1731
+ stack.push([cur, key]);
1732
+ cur = cur?.[key];
1733
+ if (cur == null) return;
1734
+ }
1735
+ const [parent, key] = stack.pop();
1736
+ if (parent && key in parent) delete parent[key];
1737
+ }
1738
+
1739
+ #escapeJsonPointer(s) {
1740
+ return s.replace(/~/g, "~0").replace(/\//g, "~1");
1741
+ }
1742
+ #unescapeJsonPointer(s) {
1743
+ return s.replace(/~1/g, "/").replace(/~0/g, "~");
1744
+ }
1745
+
1746
+ #isGroupWidget(key) {
1747
+ return key === "radio" || key === "checkbox-group";
1748
+ }
1749
+
1750
+ // ===== Event helpers =====
1751
+ #emit(name, detail) {
1752
+ this.dispatchEvent(
1753
+ new CustomEvent(name, { detail, bubbles: true, composed: true })
1754
+ );
1755
+ return detail;
1756
+ }
1757
+ #emitReadonly(name, detail) {
1758
+ this.dispatchEvent(
1759
+ new CustomEvent(name, { detail, bubbles: true, composed: true })
1760
+ );
1761
+ return detail?.node;
1762
+ }
1763
+ #emitCancelable(name, detail) {
1764
+ const ev = new CustomEvent(name, {
1765
+ detail,
1766
+ bubbles: true,
1767
+ composed: true,
1768
+ cancelable: true,
1769
+ });
1770
+ this.dispatchEvent(ev);
1771
+ return ev.defaultPrevented ? detail : null;
1772
+ }
1773
+ }
1774
+
1775
+ customElements.define("pds-jsonform", SchemaForm);