@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.
- package/.storybook/addons/description/preview.js +15 -0
- package/.storybook/addons/description/register.js +60 -0
- package/.storybook/addons/html-preview/Panel.jsx +327 -0
- package/.storybook/addons/html-preview/constants.js +6 -0
- package/.storybook/addons/html-preview/preview.js +178 -0
- package/.storybook/addons/html-preview/register.js +16 -0
- package/.storybook/addons/pds-configurator/SearchTool.js +44 -0
- package/.storybook/addons/pds-configurator/Tool.js +30 -0
- package/.storybook/addons/pds-configurator/constants.js +9 -0
- package/.storybook/addons/pds-configurator/preview.js +159 -0
- package/.storybook/addons/pds-configurator/register.js +24 -0
- package/.storybook/docs.css +35 -0
- package/.storybook/htmlPreview.css +103 -0
- package/.storybook/htmlPreview.js +271 -0
- package/.storybook/main.js +160 -0
- package/.storybook/preview-body.html +48 -0
- package/.storybook/preview-head.html +11 -0
- package/.storybook/preview.js +1563 -0
- package/README.md +266 -0
- package/bin/index.js +40 -0
- package/dist/pds-reference.json +2101 -0
- package/package.json +45 -0
- package/pds.config.js +6 -0
- package/public/assets/css/app.css +1216 -0
- package/public/assets/data/auto-design-advanced.json +704 -0
- package/public/assets/data/auto-design-simple.json +123 -0
- package/public/assets/img/icon-512x512.png +0 -0
- package/public/assets/img/logo-trans.png +0 -0
- package/public/assets/img/logo.png +0 -0
- package/public/assets/js/app.js +15088 -0
- package/public/assets/js/app.js.map +7 -0
- package/public/assets/js/lit.js +1176 -0
- package/public/assets/js/lit.js.map +7 -0
- package/public/assets/js/pds.js +9801 -0
- package/public/assets/js/pds.js.map +7 -0
- package/public/assets/pds/components/pds-calendar.js +837 -0
- package/public/assets/pds/components/pds-drawer.js +857 -0
- package/public/assets/pds/components/pds-icon.js +338 -0
- package/public/assets/pds/components/pds-jsonform.js +1775 -0
- package/public/assets/pds/components/pds-richtext.js +1035 -0
- package/public/assets/pds/components/pds-scrollrow.js +331 -0
- package/public/assets/pds/components/pds-splitpanel.js +401 -0
- package/public/assets/pds/components/pds-tabstrip.js +251 -0
- package/public/assets/pds/components/pds-toaster.js +446 -0
- package/public/assets/pds/components/pds-upload.js +657 -0
- package/public/assets/pds/custom-elements.json +2003 -0
- package/public/assets/pds/icons/pds-icons.svg +498 -0
- package/public/assets/pds/pds-css-complete.json +1861 -0
- package/public/assets/pds/pds-runtime-config.json +11 -0
- package/public/assets/pds/pds.css-data.json +2152 -0
- package/public/assets/pds/styles/pds-components.css +1944 -0
- package/public/assets/pds/styles/pds-components.css.js +3895 -0
- package/public/assets/pds/styles/pds-primitives.css +352 -0
- package/public/assets/pds/styles/pds-primitives.css.js +711 -0
- package/public/assets/pds/styles/pds-styles.css +3761 -0
- package/public/assets/pds/styles/pds-styles.css.js +7529 -0
- package/public/assets/pds/styles/pds-tokens.css +699 -0
- package/public/assets/pds/styles/pds-tokens.css.js +1405 -0
- package/public/assets/pds/styles/pds-utilities.css +763 -0
- package/public/assets/pds/styles/pds-utilities.css.js +1533 -0
- package/public/assets/pds/vscode-custom-data.json +824 -0
- package/scripts/build-pds-reference.mjs +807 -0
- package/scripts/generate-stories.js +542 -0
- package/scripts/package-build.js +86 -0
- package/src/js/app.js +17 -0
- package/src/js/common/ask.js +208 -0
- package/src/js/common/common.js +20 -0
- package/src/js/common/font-loader.js +200 -0
- package/src/js/common/msg.js +90 -0
- package/src/js/lit.js +40 -0
- package/src/js/pds-core/pds-config.js +1162 -0
- package/src/js/pds-core/pds-enhancer-metadata.js +75 -0
- package/src/js/pds-core/pds-enhancers.js +357 -0
- package/src/js/pds-core/pds-enums.js +86 -0
- package/src/js/pds-core/pds-generator.js +5317 -0
- package/src/js/pds-core/pds-ontology.js +256 -0
- package/src/js/pds-core/pds-paths.js +109 -0
- package/src/js/pds-core/pds-query.js +571 -0
- package/src/js/pds-core/pds-registry.js +129 -0
- package/src/js/pds-core/pds.d.ts +129 -0
- package/src/js/pds.d.ts +408 -0
- package/src/js/pds.js +1579 -0
- package/src/pds-core/pds-api.js +105 -0
- package/stories/GettingStarted.md +96 -0
- package/stories/GettingStarted.stories.js +144 -0
- package/stories/WhatIsPDS.md +194 -0
- package/stories/WhatIsPDS.stories.js +144 -0
- package/stories/components/PdsCalendar.stories.js +263 -0
- package/stories/components/PdsDrawer.stories.js +623 -0
- package/stories/components/PdsIcon.stories.js +78 -0
- package/stories/components/PdsJsonform.stories.js +1444 -0
- package/stories/components/PdsRichtext.stories.js +367 -0
- package/stories/components/PdsScrollrow.stories.js +140 -0
- package/stories/components/PdsSplitpanel.stories.js +502 -0
- package/stories/components/PdsTabstrip.stories.js +442 -0
- package/stories/components/PdsToaster.stories.js +186 -0
- package/stories/components/PdsUpload.stories.js +66 -0
- package/stories/enhancements/Dropdowns.stories.js +185 -0
- package/stories/enhancements/InteractiveStates.stories.js +625 -0
- package/stories/enhancements/MeshGradients.stories.js +320 -0
- package/stories/enhancements/OpenGroups.stories.js +227 -0
- package/stories/enhancements/RangeSliders.stories.js +232 -0
- package/stories/enhancements/RequiredFields.stories.js +189 -0
- package/stories/enhancements/Toggles.stories.js +167 -0
- package/stories/foundations/Colors.stories.js +283 -0
- package/stories/foundations/Icons.stories.js +305 -0
- package/stories/foundations/SmartSurfaces.stories.js +367 -0
- package/stories/foundations/Spacing.stories.js +175 -0
- package/stories/foundations/Typography.stories.js +960 -0
- package/stories/foundations/ZIndex.stories.js +325 -0
- package/stories/patterns/BorderEffects.stories.js +72 -0
- package/stories/patterns/Layout.stories.js +99 -0
- package/stories/patterns/Utilities.stories.js +107 -0
- package/stories/primitives/Accordion.stories.js +359 -0
- package/stories/primitives/Alerts.stories.js +64 -0
- package/stories/primitives/Badges.stories.js +183 -0
- package/stories/primitives/Buttons.stories.js +229 -0
- package/stories/primitives/Cards.stories.js +353 -0
- package/stories/primitives/FormGroups.stories.js +569 -0
- package/stories/primitives/Forms.stories.js +131 -0
- package/stories/primitives/Media.stories.js +203 -0
- package/stories/primitives/Tables.stories.js +232 -0
- package/stories/reference/ReferenceCatalog.stories.js +28 -0
- package/stories/reference/reference-catalog.js +413 -0
- package/stories/reference/reference-docs.js +302 -0
- package/stories/reference/reference-helpers.js +310 -0
- package/stories/utilities/GridSystem.stories.js +208 -0
- package/stories/utils/PdsAsk.stories.js +420 -0
- 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);
|