@dmitryvim/form-builder 0.1.42 → 0.2.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/README.md +244 -22
- package/dist/browser/formbuilder.min.js +179 -0
- package/dist/browser/formbuilder.v0.2.0.min.js +179 -0
- package/dist/cjs/index.cjs +3582 -0
- package/dist/cjs/index.cjs.map +1 -0
- package/dist/esm/index.js +3534 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/form-builder.js +152 -3372
- package/dist/types/components/container.d.ts +15 -0
- package/dist/types/components/file.d.ts +26 -0
- package/dist/types/components/group.d.ts +24 -0
- package/dist/types/components/index.d.ts +11 -0
- package/dist/types/components/number.d.ts +11 -0
- package/dist/types/components/registry.d.ts +15 -0
- package/dist/types/components/select.d.ts +11 -0
- package/dist/types/components/text.d.ts +11 -0
- package/dist/types/components/textarea.d.ts +11 -0
- package/dist/types/index.d.ts +33 -0
- package/dist/types/instance/FormBuilderInstance.d.ts +134 -0
- package/dist/types/instance/state.d.ts +13 -0
- package/dist/types/styles/theme.d.ts +63 -0
- package/dist/types/types/component-operations.d.ts +45 -0
- package/dist/types/types/config.d.ts +44 -0
- package/dist/types/types/index.d.ts +4 -0
- package/dist/types/types/schema.d.ts +115 -0
- package/dist/types/types/state.d.ts +11 -0
- package/dist/types/utils/helpers.d.ts +4 -0
- package/dist/types/utils/styles.d.ts +21 -0
- package/dist/types/utils/translation.d.ts +8 -0
- package/dist/types/utils/validation.d.ts +2 -0
- package/package.json +32 -15
- package/dist/demo.js +0 -861
- package/dist/elements.html +0 -1130
- package/dist/elements.js +0 -488
- package/dist/index.html +0 -315
|
@@ -0,0 +1,3534 @@
|
|
|
1
|
+
// src/utils/validation.ts
|
|
2
|
+
function addLengthHint(element, parts) {
|
|
3
|
+
if (element.minLength !== null || element.maxLength !== null) {
|
|
4
|
+
if (element.minLength !== null && element.maxLength !== null) {
|
|
5
|
+
parts.push(`length=${element.minLength}-${element.maxLength} characters`);
|
|
6
|
+
} else if (element.maxLength !== null) {
|
|
7
|
+
parts.push(`max=${element.maxLength} characters`);
|
|
8
|
+
} else if (element.minLength !== null) {
|
|
9
|
+
parts.push(`min=${element.minLength} characters`);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
function addRangeHint(element, parts) {
|
|
14
|
+
if (element.min !== null || element.max !== null) {
|
|
15
|
+
if (element.min !== null && element.max !== null) {
|
|
16
|
+
parts.push(`range=${element.min}-${element.max}`);
|
|
17
|
+
} else if (element.max !== null) {
|
|
18
|
+
parts.push(`max=${element.max}`);
|
|
19
|
+
} else if (element.min !== null) {
|
|
20
|
+
parts.push(`min=${element.min}`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function addFileSizeHint(element, parts) {
|
|
25
|
+
if (element.maxSizeMB) {
|
|
26
|
+
parts.push(`max_size=${element.maxSizeMB}MB`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function addFormatHint(element, parts) {
|
|
30
|
+
if (element.accept?.extensions) {
|
|
31
|
+
parts.push(
|
|
32
|
+
`formats=${element.accept.extensions.map((ext) => ext.toUpperCase()).join(",")}`
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function addPatternHint(element, parts) {
|
|
37
|
+
if (element.pattern && !element.pattern.includes("\u0410-\u042F")) {
|
|
38
|
+
parts.push("plain text only");
|
|
39
|
+
} else if (element.pattern?.includes("\u0410-\u042F")) {
|
|
40
|
+
parts.push("text with punctuation");
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function makeFieldHint(element) {
|
|
44
|
+
const parts = [];
|
|
45
|
+
parts.push(element.required ? "required" : "optional");
|
|
46
|
+
addLengthHint(element, parts);
|
|
47
|
+
addRangeHint(element, parts);
|
|
48
|
+
addFileSizeHint(element, parts);
|
|
49
|
+
addFormatHint(element, parts);
|
|
50
|
+
addPatternHint(element, parts);
|
|
51
|
+
return parts.join(" \u2022 ");
|
|
52
|
+
}
|
|
53
|
+
function validateSchema(schema) {
|
|
54
|
+
const errors = [];
|
|
55
|
+
if (!schema || typeof schema !== "object") {
|
|
56
|
+
errors.push("Schema must be an object");
|
|
57
|
+
return errors;
|
|
58
|
+
}
|
|
59
|
+
if (!schema.version) {
|
|
60
|
+
errors.push("Schema missing version");
|
|
61
|
+
}
|
|
62
|
+
if (!Array.isArray(schema.elements)) {
|
|
63
|
+
errors.push("Schema missing elements array");
|
|
64
|
+
return errors;
|
|
65
|
+
}
|
|
66
|
+
function validateElements(elements, path) {
|
|
67
|
+
elements.forEach((element, index) => {
|
|
68
|
+
const elementPath = `${path}[${index}]`;
|
|
69
|
+
if (!element.type) {
|
|
70
|
+
errors.push(`${elementPath}: missing type`);
|
|
71
|
+
}
|
|
72
|
+
if (!element.key) {
|
|
73
|
+
errors.push(`${elementPath}: missing key`);
|
|
74
|
+
}
|
|
75
|
+
if (element.type === "group" && "elements" in element && element.elements) {
|
|
76
|
+
validateElements(element.elements, `${elementPath}.elements`);
|
|
77
|
+
}
|
|
78
|
+
if (element.type === "container" && element.elements) {
|
|
79
|
+
validateElements(element.elements, `${elementPath}.elements`);
|
|
80
|
+
}
|
|
81
|
+
if (element.type === "select" && element.options) {
|
|
82
|
+
const defaultValue = element.default;
|
|
83
|
+
if (defaultValue !== void 0 && defaultValue !== null && defaultValue !== "") {
|
|
84
|
+
const hasMatchingOption = element.options.some(
|
|
85
|
+
(opt) => opt.value === defaultValue
|
|
86
|
+
);
|
|
87
|
+
if (!hasMatchingOption) {
|
|
88
|
+
errors.push(
|
|
89
|
+
`${elementPath}: default "${defaultValue}" not in options`
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
if (Array.isArray(schema.elements))
|
|
97
|
+
validateElements(schema.elements, "elements");
|
|
98
|
+
return errors;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// src/utils/helpers.ts
|
|
102
|
+
function isPlainObject(obj) {
|
|
103
|
+
return obj && typeof obj === "object" && obj.constructor === Object;
|
|
104
|
+
}
|
|
105
|
+
function pathJoin(base, key) {
|
|
106
|
+
return base ? `${base}.${key}` : key;
|
|
107
|
+
}
|
|
108
|
+
function clear(node) {
|
|
109
|
+
while (node.firstChild) node.removeChild(node.firstChild);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// src/components/text.ts
|
|
113
|
+
function renderTextElement(element, ctx, wrapper, pathKey) {
|
|
114
|
+
const state = ctx.state;
|
|
115
|
+
const textInput = document.createElement("input");
|
|
116
|
+
textInput.type = "text";
|
|
117
|
+
textInput.className = "w-full rounded-lg";
|
|
118
|
+
textInput.style.cssText = `
|
|
119
|
+
padding: var(--fb-input-padding-y) var(--fb-input-padding-x);
|
|
120
|
+
border: var(--fb-border-width) solid var(--fb-border-color);
|
|
121
|
+
border-radius: var(--fb-border-radius);
|
|
122
|
+
background-color: ${state.config.readonly ? "var(--fb-background-readonly-color)" : "var(--fb-background-color)"};
|
|
123
|
+
color: var(--fb-text-color);
|
|
124
|
+
font-size: var(--fb-font-size);
|
|
125
|
+
font-family: var(--fb-font-family);
|
|
126
|
+
transition: all var(--fb-transition-duration) ease-in-out;
|
|
127
|
+
`;
|
|
128
|
+
textInput.name = pathKey;
|
|
129
|
+
textInput.placeholder = element.placeholder || "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0442\u0435\u043A\u0441\u0442";
|
|
130
|
+
textInput.value = ctx.prefill[element.key] || element.default || "";
|
|
131
|
+
textInput.readOnly = state.config.readonly;
|
|
132
|
+
if (!state.config.readonly) {
|
|
133
|
+
textInput.addEventListener("focus", () => {
|
|
134
|
+
textInput.style.borderColor = "var(--fb-border-focus-color)";
|
|
135
|
+
textInput.style.outline = `var(--fb-focus-ring-width) solid var(--fb-focus-ring-color)`;
|
|
136
|
+
textInput.style.outlineOffset = "0";
|
|
137
|
+
});
|
|
138
|
+
textInput.addEventListener("blur", () => {
|
|
139
|
+
textInput.style.borderColor = "var(--fb-border-color)";
|
|
140
|
+
textInput.style.outline = "none";
|
|
141
|
+
});
|
|
142
|
+
textInput.addEventListener("mouseenter", () => {
|
|
143
|
+
if (document.activeElement !== textInput) {
|
|
144
|
+
textInput.style.borderColor = "var(--fb-border-hover-color)";
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
textInput.addEventListener("mouseleave", () => {
|
|
148
|
+
if (document.activeElement !== textInput) {
|
|
149
|
+
textInput.style.borderColor = "var(--fb-border-color)";
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
if (!state.config.readonly && ctx.instance) {
|
|
154
|
+
const handleChange = () => {
|
|
155
|
+
ctx.instance.triggerOnChange(pathKey, textInput.value);
|
|
156
|
+
};
|
|
157
|
+
textInput.addEventListener("blur", handleChange);
|
|
158
|
+
textInput.addEventListener("input", handleChange);
|
|
159
|
+
}
|
|
160
|
+
wrapper.appendChild(textInput);
|
|
161
|
+
const textHint = document.createElement("p");
|
|
162
|
+
textHint.className = "mt-1";
|
|
163
|
+
textHint.style.cssText = `
|
|
164
|
+
font-size: var(--fb-font-size-small);
|
|
165
|
+
color: var(--fb-text-secondary-color);
|
|
166
|
+
`;
|
|
167
|
+
textHint.textContent = makeFieldHint(element);
|
|
168
|
+
wrapper.appendChild(textHint);
|
|
169
|
+
}
|
|
170
|
+
function renderMultipleTextElement(element, ctx, wrapper, pathKey) {
|
|
171
|
+
const state = ctx.state;
|
|
172
|
+
const prefillValues = ctx.prefill[element.key] || [];
|
|
173
|
+
const values = Array.isArray(prefillValues) ? [...prefillValues] : [];
|
|
174
|
+
const minCount = element.minCount ?? 1;
|
|
175
|
+
const maxCount = element.maxCount ?? Infinity;
|
|
176
|
+
while (values.length < minCount) {
|
|
177
|
+
values.push(element.default || "");
|
|
178
|
+
}
|
|
179
|
+
const container = document.createElement("div");
|
|
180
|
+
container.className = "space-y-2";
|
|
181
|
+
wrapper.appendChild(container);
|
|
182
|
+
function updateIndices() {
|
|
183
|
+
const items = container.querySelectorAll(".multiple-text-item");
|
|
184
|
+
items.forEach((item, index) => {
|
|
185
|
+
const input = item.querySelector("input");
|
|
186
|
+
if (input) {
|
|
187
|
+
input.name = `${pathKey}[${index}]`;
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
function addTextItem(value = "", index = -1) {
|
|
192
|
+
const itemWrapper = document.createElement("div");
|
|
193
|
+
itemWrapper.className = "multiple-text-item flex items-center gap-2";
|
|
194
|
+
const textInput = document.createElement("input");
|
|
195
|
+
textInput.type = "text";
|
|
196
|
+
textInput.className = "flex-1";
|
|
197
|
+
textInput.style.cssText = `
|
|
198
|
+
padding: var(--fb-input-padding-y) var(--fb-input-padding-x);
|
|
199
|
+
border: var(--fb-border-width) solid var(--fb-border-color);
|
|
200
|
+
border-radius: var(--fb-border-radius);
|
|
201
|
+
background-color: ${state.config.readonly ? "var(--fb-background-readonly-color)" : "var(--fb-background-color)"};
|
|
202
|
+
color: var(--fb-text-color);
|
|
203
|
+
font-size: var(--fb-font-size);
|
|
204
|
+
font-family: var(--fb-font-family);
|
|
205
|
+
transition: all var(--fb-transition-duration) ease-in-out;
|
|
206
|
+
`;
|
|
207
|
+
textInput.placeholder = element.placeholder || "Enter text";
|
|
208
|
+
textInput.value = value;
|
|
209
|
+
textInput.readOnly = state.config.readonly;
|
|
210
|
+
if (!state.config.readonly) {
|
|
211
|
+
textInput.addEventListener("focus", () => {
|
|
212
|
+
textInput.style.borderColor = "var(--fb-border-focus-color)";
|
|
213
|
+
textInput.style.outline = `var(--fb-focus-ring-width) solid var(--fb-focus-ring-color)`;
|
|
214
|
+
textInput.style.outlineOffset = "0";
|
|
215
|
+
});
|
|
216
|
+
textInput.addEventListener("blur", () => {
|
|
217
|
+
textInput.style.borderColor = "var(--fb-border-color)";
|
|
218
|
+
textInput.style.outline = "none";
|
|
219
|
+
});
|
|
220
|
+
textInput.addEventListener("mouseenter", () => {
|
|
221
|
+
if (document.activeElement !== textInput) {
|
|
222
|
+
textInput.style.borderColor = "var(--fb-border-hover-color)";
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
textInput.addEventListener("mouseleave", () => {
|
|
226
|
+
if (document.activeElement !== textInput) {
|
|
227
|
+
textInput.style.borderColor = "var(--fb-border-color)";
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
if (!state.config.readonly && ctx.instance) {
|
|
232
|
+
const handleChange = () => {
|
|
233
|
+
ctx.instance.triggerOnChange(textInput.name, textInput.value);
|
|
234
|
+
};
|
|
235
|
+
textInput.addEventListener("blur", handleChange);
|
|
236
|
+
textInput.addEventListener("input", handleChange);
|
|
237
|
+
}
|
|
238
|
+
itemWrapper.appendChild(textInput);
|
|
239
|
+
if (index === -1) {
|
|
240
|
+
container.appendChild(itemWrapper);
|
|
241
|
+
} else {
|
|
242
|
+
container.insertBefore(itemWrapper, container.children[index]);
|
|
243
|
+
}
|
|
244
|
+
updateIndices();
|
|
245
|
+
return itemWrapper;
|
|
246
|
+
}
|
|
247
|
+
function updateRemoveButtons() {
|
|
248
|
+
if (state.config.readonly) return;
|
|
249
|
+
const items = container.querySelectorAll(".multiple-text-item");
|
|
250
|
+
const currentCount = items.length;
|
|
251
|
+
items.forEach((item) => {
|
|
252
|
+
let removeBtn = item.querySelector(
|
|
253
|
+
".remove-item-btn"
|
|
254
|
+
);
|
|
255
|
+
if (!removeBtn) {
|
|
256
|
+
removeBtn = document.createElement("button");
|
|
257
|
+
removeBtn.type = "button";
|
|
258
|
+
removeBtn.className = "remove-item-btn px-2 py-1 rounded";
|
|
259
|
+
removeBtn.style.cssText = `
|
|
260
|
+
color: var(--fb-error-color);
|
|
261
|
+
background-color: transparent;
|
|
262
|
+
transition: background-color var(--fb-transition-duration);
|
|
263
|
+
`;
|
|
264
|
+
removeBtn.innerHTML = "\u2715";
|
|
265
|
+
removeBtn.addEventListener("mouseenter", () => {
|
|
266
|
+
removeBtn.style.backgroundColor = "var(--fb-background-hover-color)";
|
|
267
|
+
});
|
|
268
|
+
removeBtn.addEventListener("mouseleave", () => {
|
|
269
|
+
removeBtn.style.backgroundColor = "transparent";
|
|
270
|
+
});
|
|
271
|
+
removeBtn.onclick = () => {
|
|
272
|
+
const currentIndex = Array.from(container.children).indexOf(
|
|
273
|
+
item
|
|
274
|
+
);
|
|
275
|
+
if (container.children.length > minCount) {
|
|
276
|
+
values.splice(currentIndex, 1);
|
|
277
|
+
item.remove();
|
|
278
|
+
updateIndices();
|
|
279
|
+
updateAddButton();
|
|
280
|
+
updateRemoveButtons();
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
item.appendChild(removeBtn);
|
|
284
|
+
}
|
|
285
|
+
const disabled = currentCount <= minCount;
|
|
286
|
+
removeBtn.disabled = disabled;
|
|
287
|
+
removeBtn.style.opacity = disabled ? "0.5" : "1";
|
|
288
|
+
removeBtn.style.pointerEvents = disabled ? "none" : "auto";
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
function updateAddButton() {
|
|
292
|
+
const existingAddBtn = wrapper.querySelector(".add-text-btn");
|
|
293
|
+
if (existingAddBtn) existingAddBtn.remove();
|
|
294
|
+
if (!state.config.readonly && values.length < maxCount) {
|
|
295
|
+
const addBtn = document.createElement("button");
|
|
296
|
+
addBtn.type = "button";
|
|
297
|
+
addBtn.className = "add-text-btn mt-2 px-3 py-1 rounded";
|
|
298
|
+
addBtn.style.cssText = `
|
|
299
|
+
color: var(--fb-primary-color);
|
|
300
|
+
border: var(--fb-border-width) solid var(--fb-primary-color);
|
|
301
|
+
background-color: transparent;
|
|
302
|
+
font-size: var(--fb-font-size);
|
|
303
|
+
transition: all var(--fb-transition-duration);
|
|
304
|
+
`;
|
|
305
|
+
addBtn.textContent = `+ Add ${element.label || "Text"}`;
|
|
306
|
+
addBtn.addEventListener("mouseenter", () => {
|
|
307
|
+
addBtn.style.backgroundColor = "var(--fb-background-hover-color)";
|
|
308
|
+
});
|
|
309
|
+
addBtn.addEventListener("mouseleave", () => {
|
|
310
|
+
addBtn.style.backgroundColor = "transparent";
|
|
311
|
+
});
|
|
312
|
+
addBtn.onclick = () => {
|
|
313
|
+
values.push(element.default || "");
|
|
314
|
+
addTextItem(element.default || "");
|
|
315
|
+
updateAddButton();
|
|
316
|
+
updateRemoveButtons();
|
|
317
|
+
};
|
|
318
|
+
wrapper.appendChild(addBtn);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
values.forEach((value) => addTextItem(value));
|
|
322
|
+
updateAddButton();
|
|
323
|
+
updateRemoveButtons();
|
|
324
|
+
const hint = document.createElement("p");
|
|
325
|
+
hint.className = "mt-1";
|
|
326
|
+
hint.style.cssText = `
|
|
327
|
+
font-size: var(--fb-font-size-small);
|
|
328
|
+
color: var(--fb-text-secondary-color);
|
|
329
|
+
`;
|
|
330
|
+
hint.textContent = makeFieldHint(element);
|
|
331
|
+
wrapper.appendChild(hint);
|
|
332
|
+
}
|
|
333
|
+
function validateTextElement(element, key, context) {
|
|
334
|
+
const errors = [];
|
|
335
|
+
const { scopeRoot, skipValidation } = context;
|
|
336
|
+
const markValidity = (input, errorMessage) => {
|
|
337
|
+
if (!input) return;
|
|
338
|
+
const errorId = `error-${input.getAttribute("name") || Math.random().toString(36).substring(7)}`;
|
|
339
|
+
let errorElement = document.getElementById(errorId);
|
|
340
|
+
if (errorMessage) {
|
|
341
|
+
input.classList.add("invalid");
|
|
342
|
+
input.title = errorMessage;
|
|
343
|
+
if (!errorElement) {
|
|
344
|
+
errorElement = document.createElement("div");
|
|
345
|
+
errorElement.id = errorId;
|
|
346
|
+
errorElement.className = "error-message";
|
|
347
|
+
errorElement.style.cssText = `
|
|
348
|
+
color: var(--fb-error-color);
|
|
349
|
+
font-size: var(--fb-font-size-small);
|
|
350
|
+
margin-top: 0.25rem;
|
|
351
|
+
`;
|
|
352
|
+
if (input.nextSibling) {
|
|
353
|
+
input.parentNode?.insertBefore(errorElement, input.nextSibling);
|
|
354
|
+
} else {
|
|
355
|
+
input.parentNode?.appendChild(errorElement);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
errorElement.textContent = errorMessage;
|
|
359
|
+
errorElement.style.display = "block";
|
|
360
|
+
} else {
|
|
361
|
+
input.classList.remove("invalid");
|
|
362
|
+
input.title = "";
|
|
363
|
+
if (errorElement) {
|
|
364
|
+
errorElement.remove();
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
};
|
|
368
|
+
const validateTextInput = (input, val, fieldKey) => {
|
|
369
|
+
let hasError = false;
|
|
370
|
+
if (!skipValidation && val) {
|
|
371
|
+
if (element.minLength !== void 0 && element.minLength !== null && val.length < element.minLength) {
|
|
372
|
+
errors.push(`${fieldKey}: minLength=${element.minLength}`);
|
|
373
|
+
markValidity(input, `minLength=${element.minLength}`);
|
|
374
|
+
hasError = true;
|
|
375
|
+
} else if (element.maxLength !== void 0 && element.maxLength !== null && val.length > element.maxLength) {
|
|
376
|
+
errors.push(`${fieldKey}: maxLength=${element.maxLength}`);
|
|
377
|
+
markValidity(input, `maxLength=${element.maxLength}`);
|
|
378
|
+
hasError = true;
|
|
379
|
+
} else if (element.pattern) {
|
|
380
|
+
try {
|
|
381
|
+
const re = new RegExp(element.pattern);
|
|
382
|
+
if (!re.test(val)) {
|
|
383
|
+
errors.push(`${fieldKey}: pattern mismatch`);
|
|
384
|
+
markValidity(input, "pattern mismatch");
|
|
385
|
+
hasError = true;
|
|
386
|
+
}
|
|
387
|
+
} catch {
|
|
388
|
+
errors.push(`${fieldKey}: invalid pattern`);
|
|
389
|
+
markValidity(input, "invalid pattern");
|
|
390
|
+
hasError = true;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
if (!hasError) {
|
|
395
|
+
markValidity(input, null);
|
|
396
|
+
}
|
|
397
|
+
};
|
|
398
|
+
if (element.multiple) {
|
|
399
|
+
const inputs = scopeRoot.querySelectorAll(
|
|
400
|
+
`[name^="${key}["]`
|
|
401
|
+
);
|
|
402
|
+
const values = [];
|
|
403
|
+
inputs.forEach((input, index) => {
|
|
404
|
+
const val = input?.value ?? "";
|
|
405
|
+
values.push(val);
|
|
406
|
+
validateTextInput(input, val, `${key}[${index}]`);
|
|
407
|
+
});
|
|
408
|
+
if (!skipValidation) {
|
|
409
|
+
const minCount = element.minCount ?? 1;
|
|
410
|
+
const maxCount = element.maxCount ?? Infinity;
|
|
411
|
+
const filteredValues = values.filter((v) => v.trim() !== "");
|
|
412
|
+
if (element.required && filteredValues.length === 0) {
|
|
413
|
+
errors.push(`${key}: required`);
|
|
414
|
+
}
|
|
415
|
+
if (filteredValues.length < minCount) {
|
|
416
|
+
errors.push(`${key}: minimum ${minCount} items required`);
|
|
417
|
+
}
|
|
418
|
+
if (filteredValues.length > maxCount) {
|
|
419
|
+
errors.push(`${key}: maximum ${maxCount} items allowed`);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
return { value: values, errors };
|
|
423
|
+
} else {
|
|
424
|
+
const input = scopeRoot.querySelector(
|
|
425
|
+
`[name$="${key}"]`
|
|
426
|
+
);
|
|
427
|
+
const val = input?.value ?? "";
|
|
428
|
+
if (!skipValidation && element.required && val === "") {
|
|
429
|
+
errors.push(`${key}: required`);
|
|
430
|
+
markValidity(input, "required");
|
|
431
|
+
return { value: "", errors };
|
|
432
|
+
}
|
|
433
|
+
validateTextInput(input, val, key);
|
|
434
|
+
return { value: val, errors };
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
function updateTextField(element, fieldPath, value, context) {
|
|
438
|
+
const { scopeRoot } = context;
|
|
439
|
+
if (element.multiple) {
|
|
440
|
+
if (!Array.isArray(value)) {
|
|
441
|
+
console.warn(
|
|
442
|
+
`updateTextField: Expected array for multiple field "${fieldPath}", got ${typeof value}`
|
|
443
|
+
);
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
const inputs = scopeRoot.querySelectorAll(
|
|
447
|
+
`[name^="${fieldPath}["]`
|
|
448
|
+
);
|
|
449
|
+
inputs.forEach((input, index) => {
|
|
450
|
+
if (index < value.length) {
|
|
451
|
+
input.value = value[index] != null ? String(value[index]) : "";
|
|
452
|
+
input.classList.remove("invalid");
|
|
453
|
+
input.title = "";
|
|
454
|
+
}
|
|
455
|
+
});
|
|
456
|
+
if (value.length !== inputs.length) {
|
|
457
|
+
console.warn(
|
|
458
|
+
`updateTextField: Multiple field "${fieldPath}" has ${inputs.length} inputs but received ${value.length} values. Consider re-rendering for add/remove.`
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
} else {
|
|
462
|
+
const input = scopeRoot.querySelector(
|
|
463
|
+
`[name="${fieldPath}"]`
|
|
464
|
+
);
|
|
465
|
+
if (input) {
|
|
466
|
+
input.value = value != null ? String(value) : "";
|
|
467
|
+
input.classList.remove("invalid");
|
|
468
|
+
input.title = "";
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// src/components/textarea.ts
|
|
474
|
+
function renderTextareaElement(element, ctx, wrapper, pathKey) {
|
|
475
|
+
const state = ctx.state;
|
|
476
|
+
const textareaInput = document.createElement("textarea");
|
|
477
|
+
textareaInput.className = "w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 resize-none";
|
|
478
|
+
textareaInput.name = pathKey;
|
|
479
|
+
textareaInput.placeholder = element.placeholder || "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0442\u0435\u043A\u0441\u0442";
|
|
480
|
+
textareaInput.rows = element.rows || 4;
|
|
481
|
+
textareaInput.value = ctx.prefill[element.key] || element.default || "";
|
|
482
|
+
textareaInput.readOnly = state.config.readonly;
|
|
483
|
+
if (!state.config.readonly && ctx.instance) {
|
|
484
|
+
const handleChange = () => {
|
|
485
|
+
ctx.instance.triggerOnChange(pathKey, textareaInput.value);
|
|
486
|
+
};
|
|
487
|
+
textareaInput.addEventListener("blur", handleChange);
|
|
488
|
+
textareaInput.addEventListener("input", handleChange);
|
|
489
|
+
}
|
|
490
|
+
wrapper.appendChild(textareaInput);
|
|
491
|
+
const textareaHint = document.createElement("p");
|
|
492
|
+
textareaHint.className = "text-xs text-gray-500 mt-1";
|
|
493
|
+
textareaHint.textContent = makeFieldHint(element);
|
|
494
|
+
wrapper.appendChild(textareaHint);
|
|
495
|
+
}
|
|
496
|
+
function renderMultipleTextareaElement(element, ctx, wrapper, pathKey) {
|
|
497
|
+
const state = ctx.state;
|
|
498
|
+
const prefillValues = ctx.prefill[element.key] || [];
|
|
499
|
+
const values = Array.isArray(prefillValues) ? [...prefillValues] : [];
|
|
500
|
+
const minCount = element.minCount ?? 1;
|
|
501
|
+
const maxCount = element.maxCount ?? Infinity;
|
|
502
|
+
while (values.length < minCount) {
|
|
503
|
+
values.push(element.default || "");
|
|
504
|
+
}
|
|
505
|
+
const container = document.createElement("div");
|
|
506
|
+
container.className = "space-y-2";
|
|
507
|
+
wrapper.appendChild(container);
|
|
508
|
+
function updateIndices() {
|
|
509
|
+
const items = container.querySelectorAll(".multiple-textarea-item");
|
|
510
|
+
items.forEach((item, index) => {
|
|
511
|
+
const textarea = item.querySelector("textarea");
|
|
512
|
+
if (textarea) {
|
|
513
|
+
textarea.name = `${pathKey}[${index}]`;
|
|
514
|
+
}
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
function addTextareaItem(value = "", index = -1) {
|
|
518
|
+
const itemWrapper = document.createElement("div");
|
|
519
|
+
itemWrapper.className = "multiple-textarea-item";
|
|
520
|
+
const textareaInput = document.createElement("textarea");
|
|
521
|
+
textareaInput.className = "w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 resize-none";
|
|
522
|
+
textareaInput.placeholder = element.placeholder || "Enter text";
|
|
523
|
+
textareaInput.rows = element.rows || 4;
|
|
524
|
+
textareaInput.value = value;
|
|
525
|
+
textareaInput.readOnly = state.config.readonly;
|
|
526
|
+
if (!state.config.readonly && ctx.instance) {
|
|
527
|
+
const handleChange = () => {
|
|
528
|
+
ctx.instance.triggerOnChange(textareaInput.name, textareaInput.value);
|
|
529
|
+
};
|
|
530
|
+
textareaInput.addEventListener("blur", handleChange);
|
|
531
|
+
textareaInput.addEventListener("input", handleChange);
|
|
532
|
+
}
|
|
533
|
+
itemWrapper.appendChild(textareaInput);
|
|
534
|
+
if (index === -1) {
|
|
535
|
+
container.appendChild(itemWrapper);
|
|
536
|
+
} else {
|
|
537
|
+
container.insertBefore(itemWrapper, container.children[index]);
|
|
538
|
+
}
|
|
539
|
+
updateIndices();
|
|
540
|
+
return itemWrapper;
|
|
541
|
+
}
|
|
542
|
+
function updateRemoveButtons() {
|
|
543
|
+
if (state.config.readonly) return;
|
|
544
|
+
const items = container.querySelectorAll(".multiple-textarea-item");
|
|
545
|
+
const currentCount = items.length;
|
|
546
|
+
items.forEach((item) => {
|
|
547
|
+
let removeBtn = item.querySelector(
|
|
548
|
+
".remove-item-btn"
|
|
549
|
+
);
|
|
550
|
+
if (!removeBtn) {
|
|
551
|
+
removeBtn = document.createElement("button");
|
|
552
|
+
removeBtn.type = "button";
|
|
553
|
+
removeBtn.className = "remove-item-btn mt-1 px-2 py-1 text-red-600 hover:bg-red-50 rounded text-sm";
|
|
554
|
+
removeBtn.innerHTML = "\u2715 Remove";
|
|
555
|
+
removeBtn.onclick = () => {
|
|
556
|
+
const currentIndex = Array.from(container.children).indexOf(
|
|
557
|
+
item
|
|
558
|
+
);
|
|
559
|
+
if (container.children.length > minCount) {
|
|
560
|
+
values.splice(currentIndex, 1);
|
|
561
|
+
item.remove();
|
|
562
|
+
updateIndices();
|
|
563
|
+
updateAddButton();
|
|
564
|
+
updateRemoveButtons();
|
|
565
|
+
}
|
|
566
|
+
};
|
|
567
|
+
item.appendChild(removeBtn);
|
|
568
|
+
}
|
|
569
|
+
const disabled = currentCount <= minCount;
|
|
570
|
+
removeBtn.disabled = disabled;
|
|
571
|
+
removeBtn.style.opacity = disabled ? "0.5" : "1";
|
|
572
|
+
removeBtn.style.pointerEvents = disabled ? "none" : "auto";
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
function updateAddButton() {
|
|
576
|
+
const existingAddBtn = wrapper.querySelector(".add-textarea-btn");
|
|
577
|
+
if (existingAddBtn) existingAddBtn.remove();
|
|
578
|
+
if (!state.config.readonly && values.length < maxCount) {
|
|
579
|
+
const addBtn = document.createElement("button");
|
|
580
|
+
addBtn.type = "button";
|
|
581
|
+
addBtn.className = "add-textarea-btn mt-2 px-3 py-1 text-blue-600 border border-blue-300 rounded hover:bg-blue-50 text-sm";
|
|
582
|
+
addBtn.textContent = `+ Add ${element.label || "Textarea"}`;
|
|
583
|
+
addBtn.onclick = () => {
|
|
584
|
+
values.push(element.default || "");
|
|
585
|
+
addTextareaItem(element.default || "");
|
|
586
|
+
updateAddButton();
|
|
587
|
+
updateRemoveButtons();
|
|
588
|
+
};
|
|
589
|
+
wrapper.appendChild(addBtn);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
values.forEach((value) => addTextareaItem(value));
|
|
593
|
+
updateAddButton();
|
|
594
|
+
updateRemoveButtons();
|
|
595
|
+
const hint = document.createElement("p");
|
|
596
|
+
hint.className = "text-xs text-gray-500 mt-1";
|
|
597
|
+
hint.textContent = makeFieldHint(element);
|
|
598
|
+
wrapper.appendChild(hint);
|
|
599
|
+
}
|
|
600
|
+
function validateTextareaElement(element, key, context) {
|
|
601
|
+
return validateTextElement(element, key, context);
|
|
602
|
+
}
|
|
603
|
+
function updateTextareaField(element, fieldPath, value, context) {
|
|
604
|
+
updateTextField(element, fieldPath, value, context);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// src/components/number.ts
|
|
608
|
+
function renderNumberElement(element, ctx, wrapper, pathKey) {
|
|
609
|
+
const state = ctx.state;
|
|
610
|
+
const numberInput = document.createElement("input");
|
|
611
|
+
numberInput.type = "number";
|
|
612
|
+
numberInput.className = "w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500";
|
|
613
|
+
numberInput.name = pathKey;
|
|
614
|
+
numberInput.placeholder = element.placeholder || "0";
|
|
615
|
+
if (element.min !== void 0) numberInput.min = element.min.toString();
|
|
616
|
+
if (element.max !== void 0) numberInput.max = element.max.toString();
|
|
617
|
+
if (element.step !== void 0) numberInput.step = element.step.toString();
|
|
618
|
+
numberInput.value = ctx.prefill[element.key] || element.default || "";
|
|
619
|
+
numberInput.readOnly = state.config.readonly;
|
|
620
|
+
if (!state.config.readonly && ctx.instance) {
|
|
621
|
+
const handleChange = () => {
|
|
622
|
+
const value = numberInput.value ? parseFloat(numberInput.value) : null;
|
|
623
|
+
ctx.instance.triggerOnChange(pathKey, value);
|
|
624
|
+
};
|
|
625
|
+
numberInput.addEventListener("blur", handleChange);
|
|
626
|
+
numberInput.addEventListener("input", handleChange);
|
|
627
|
+
}
|
|
628
|
+
wrapper.appendChild(numberInput);
|
|
629
|
+
const numberHint = document.createElement("p");
|
|
630
|
+
numberHint.className = "text-xs text-gray-500 mt-1";
|
|
631
|
+
numberHint.textContent = makeFieldHint(element);
|
|
632
|
+
wrapper.appendChild(numberHint);
|
|
633
|
+
}
|
|
634
|
+
function renderMultipleNumberElement(element, ctx, wrapper, pathKey) {
|
|
635
|
+
const state = ctx.state;
|
|
636
|
+
const prefillValues = ctx.prefill[element.key] || [];
|
|
637
|
+
const values = Array.isArray(prefillValues) ? [...prefillValues] : [];
|
|
638
|
+
const minCount = element.minCount ?? 1;
|
|
639
|
+
const maxCount = element.maxCount ?? Infinity;
|
|
640
|
+
while (values.length < minCount) {
|
|
641
|
+
values.push(element.default || "");
|
|
642
|
+
}
|
|
643
|
+
const container = document.createElement("div");
|
|
644
|
+
container.className = "space-y-2";
|
|
645
|
+
wrapper.appendChild(container);
|
|
646
|
+
function updateIndices() {
|
|
647
|
+
const items = container.querySelectorAll(".multiple-number-item");
|
|
648
|
+
items.forEach((item, index) => {
|
|
649
|
+
const input = item.querySelector("input");
|
|
650
|
+
if (input) {
|
|
651
|
+
input.name = `${pathKey}[${index}]`;
|
|
652
|
+
}
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
function addNumberItem(value = "", index = -1) {
|
|
656
|
+
const itemWrapper = document.createElement("div");
|
|
657
|
+
itemWrapper.className = "multiple-number-item flex items-center gap-2";
|
|
658
|
+
const numberInput = document.createElement("input");
|
|
659
|
+
numberInput.type = "number";
|
|
660
|
+
numberInput.className = "flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500";
|
|
661
|
+
numberInput.placeholder = element.placeholder || "0";
|
|
662
|
+
if (element.min !== void 0) numberInput.min = element.min.toString();
|
|
663
|
+
if (element.max !== void 0) numberInput.max = element.max.toString();
|
|
664
|
+
if (element.step !== void 0) numberInput.step = element.step.toString();
|
|
665
|
+
numberInput.value = value.toString();
|
|
666
|
+
numberInput.readOnly = state.config.readonly;
|
|
667
|
+
if (!state.config.readonly && ctx.instance) {
|
|
668
|
+
const handleChange = () => {
|
|
669
|
+
const val = numberInput.value ? parseFloat(numberInput.value) : null;
|
|
670
|
+
ctx.instance.triggerOnChange(numberInput.name, val);
|
|
671
|
+
};
|
|
672
|
+
numberInput.addEventListener("blur", handleChange);
|
|
673
|
+
numberInput.addEventListener("input", handleChange);
|
|
674
|
+
}
|
|
675
|
+
itemWrapper.appendChild(numberInput);
|
|
676
|
+
if (index === -1) {
|
|
677
|
+
container.appendChild(itemWrapper);
|
|
678
|
+
} else {
|
|
679
|
+
container.insertBefore(itemWrapper, container.children[index]);
|
|
680
|
+
}
|
|
681
|
+
updateIndices();
|
|
682
|
+
return itemWrapper;
|
|
683
|
+
}
|
|
684
|
+
function updateRemoveButtons() {
|
|
685
|
+
if (state.config.readonly) return;
|
|
686
|
+
const items = container.querySelectorAll(".multiple-number-item");
|
|
687
|
+
const currentCount = items.length;
|
|
688
|
+
items.forEach((item) => {
|
|
689
|
+
let removeBtn = item.querySelector(
|
|
690
|
+
".remove-item-btn"
|
|
691
|
+
);
|
|
692
|
+
if (!removeBtn) {
|
|
693
|
+
removeBtn = document.createElement("button");
|
|
694
|
+
removeBtn.type = "button";
|
|
695
|
+
removeBtn.className = "remove-item-btn px-2 py-1 text-red-600 hover:bg-red-50 rounded";
|
|
696
|
+
removeBtn.innerHTML = "\u2715";
|
|
697
|
+
removeBtn.onclick = () => {
|
|
698
|
+
const currentIndex = Array.from(container.children).indexOf(
|
|
699
|
+
item
|
|
700
|
+
);
|
|
701
|
+
if (container.children.length > minCount) {
|
|
702
|
+
values.splice(currentIndex, 1);
|
|
703
|
+
item.remove();
|
|
704
|
+
updateIndices();
|
|
705
|
+
updateAddButton();
|
|
706
|
+
updateRemoveButtons();
|
|
707
|
+
}
|
|
708
|
+
};
|
|
709
|
+
item.appendChild(removeBtn);
|
|
710
|
+
}
|
|
711
|
+
const disabled = currentCount <= minCount;
|
|
712
|
+
removeBtn.disabled = disabled;
|
|
713
|
+
removeBtn.style.opacity = disabled ? "0.5" : "1";
|
|
714
|
+
removeBtn.style.pointerEvents = disabled ? "none" : "auto";
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
function updateAddButton() {
|
|
718
|
+
const existingAddBtn = wrapper.querySelector(".add-number-btn");
|
|
719
|
+
if (existingAddBtn) existingAddBtn.remove();
|
|
720
|
+
if (!state.config.readonly && values.length < maxCount) {
|
|
721
|
+
const addBtn = document.createElement("button");
|
|
722
|
+
addBtn.type = "button";
|
|
723
|
+
addBtn.className = "add-number-btn mt-2 px-3 py-1 text-blue-600 border border-blue-300 rounded hover:bg-blue-50 text-sm";
|
|
724
|
+
addBtn.textContent = `+ Add ${element.label || "Number"}`;
|
|
725
|
+
addBtn.onclick = () => {
|
|
726
|
+
values.push(element.default || "");
|
|
727
|
+
addNumberItem(element.default || "");
|
|
728
|
+
updateAddButton();
|
|
729
|
+
updateRemoveButtons();
|
|
730
|
+
};
|
|
731
|
+
wrapper.appendChild(addBtn);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
values.forEach((value) => addNumberItem(value));
|
|
735
|
+
updateAddButton();
|
|
736
|
+
updateRemoveButtons();
|
|
737
|
+
const hint = document.createElement("p");
|
|
738
|
+
hint.className = "text-xs text-gray-500 mt-1";
|
|
739
|
+
hint.textContent = makeFieldHint(element);
|
|
740
|
+
wrapper.appendChild(hint);
|
|
741
|
+
}
|
|
742
|
+
function validateNumberElement(element, key, context) {
|
|
743
|
+
const errors = [];
|
|
744
|
+
const { scopeRoot, skipValidation } = context;
|
|
745
|
+
const markValidity = (input, errorMessage) => {
|
|
746
|
+
if (!input) return;
|
|
747
|
+
const errorId = `error-${input.getAttribute("name") || Math.random().toString(36).substring(7)}`;
|
|
748
|
+
let errorElement = document.getElementById(errorId);
|
|
749
|
+
if (errorMessage) {
|
|
750
|
+
input.classList.add("invalid");
|
|
751
|
+
input.title = errorMessage;
|
|
752
|
+
if (!errorElement) {
|
|
753
|
+
errorElement = document.createElement("div");
|
|
754
|
+
errorElement.id = errorId;
|
|
755
|
+
errorElement.className = "error-message";
|
|
756
|
+
errorElement.style.cssText = `
|
|
757
|
+
color: var(--fb-error-color);
|
|
758
|
+
font-size: var(--fb-font-size-small);
|
|
759
|
+
margin-top: 0.25rem;
|
|
760
|
+
`;
|
|
761
|
+
if (input.nextSibling) {
|
|
762
|
+
input.parentNode?.insertBefore(errorElement, input.nextSibling);
|
|
763
|
+
} else {
|
|
764
|
+
input.parentNode?.appendChild(errorElement);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
errorElement.textContent = errorMessage;
|
|
768
|
+
errorElement.style.display = "block";
|
|
769
|
+
} else {
|
|
770
|
+
input.classList.remove("invalid");
|
|
771
|
+
input.title = "";
|
|
772
|
+
if (errorElement) {
|
|
773
|
+
errorElement.remove();
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
};
|
|
777
|
+
const validateNumberInput = (input, v, fieldKey) => {
|
|
778
|
+
let hasError = false;
|
|
779
|
+
if (!skipValidation && element.min !== void 0 && element.min !== null && v < element.min) {
|
|
780
|
+
errors.push(`${fieldKey}: < min=${element.min}`);
|
|
781
|
+
markValidity(input, `< min=${element.min}`);
|
|
782
|
+
hasError = true;
|
|
783
|
+
} else if (!skipValidation && element.max !== void 0 && element.max !== null && v > element.max) {
|
|
784
|
+
errors.push(`${fieldKey}: > max=${element.max}`);
|
|
785
|
+
markValidity(input, `> max=${element.max}`);
|
|
786
|
+
hasError = true;
|
|
787
|
+
}
|
|
788
|
+
if (!hasError) {
|
|
789
|
+
markValidity(input, null);
|
|
790
|
+
}
|
|
791
|
+
};
|
|
792
|
+
if (element.multiple) {
|
|
793
|
+
const inputs = scopeRoot.querySelectorAll(
|
|
794
|
+
`[name^="${key}["]`
|
|
795
|
+
);
|
|
796
|
+
const values = [];
|
|
797
|
+
inputs.forEach((input, index) => {
|
|
798
|
+
const raw = input?.value ?? "";
|
|
799
|
+
if (raw === "") {
|
|
800
|
+
values.push(null);
|
|
801
|
+
markValidity(input, null);
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
const v = parseFloat(raw);
|
|
805
|
+
if (!skipValidation && !Number.isFinite(v)) {
|
|
806
|
+
errors.push(`${key}[${index}]: not a number`);
|
|
807
|
+
markValidity(input, "not a number");
|
|
808
|
+
values.push(null);
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
validateNumberInput(input, v, `${key}[${index}]`);
|
|
812
|
+
const d = Number.isInteger(element.decimals ?? 0) ? element.decimals ?? 0 : 0;
|
|
813
|
+
values.push(Number(v.toFixed(d)));
|
|
814
|
+
});
|
|
815
|
+
if (!skipValidation) {
|
|
816
|
+
const minCount = element.minCount ?? 1;
|
|
817
|
+
const maxCount = element.maxCount ?? Infinity;
|
|
818
|
+
const filteredValues = values.filter((v) => v !== null);
|
|
819
|
+
if (element.required && filteredValues.length === 0) {
|
|
820
|
+
errors.push(`${key}: required`);
|
|
821
|
+
}
|
|
822
|
+
if (filteredValues.length < minCount) {
|
|
823
|
+
errors.push(`${key}: minimum ${minCount} items required`);
|
|
824
|
+
}
|
|
825
|
+
if (filteredValues.length > maxCount) {
|
|
826
|
+
errors.push(`${key}: maximum ${maxCount} items allowed`);
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
return { value: values, errors };
|
|
830
|
+
} else {
|
|
831
|
+
const input = scopeRoot.querySelector(`[name$="${key}"]`);
|
|
832
|
+
const raw = input?.value ?? "";
|
|
833
|
+
if (!skipValidation && element.required && raw === "") {
|
|
834
|
+
errors.push(`${key}: required`);
|
|
835
|
+
markValidity(input, "required");
|
|
836
|
+
return { value: null, errors };
|
|
837
|
+
}
|
|
838
|
+
if (raw === "") {
|
|
839
|
+
markValidity(input, null);
|
|
840
|
+
return { value: null, errors };
|
|
841
|
+
}
|
|
842
|
+
const v = parseFloat(raw);
|
|
843
|
+
if (!skipValidation && !Number.isFinite(v)) {
|
|
844
|
+
errors.push(`${key}: not a number`);
|
|
845
|
+
markValidity(input, "not a number");
|
|
846
|
+
return { value: null, errors };
|
|
847
|
+
}
|
|
848
|
+
validateNumberInput(input, v, key);
|
|
849
|
+
const d = Number.isInteger(element.decimals ?? 0) ? element.decimals ?? 0 : 0;
|
|
850
|
+
return { value: Number(v.toFixed(d)), errors };
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
function updateNumberField(element, fieldPath, value, context) {
|
|
854
|
+
const { scopeRoot } = context;
|
|
855
|
+
if (element.multiple) {
|
|
856
|
+
if (!Array.isArray(value)) {
|
|
857
|
+
console.warn(
|
|
858
|
+
`updateNumberField: Expected array for multiple field "${fieldPath}", got ${typeof value}`
|
|
859
|
+
);
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
const inputs = scopeRoot.querySelectorAll(
|
|
863
|
+
`[name^="${fieldPath}["]`
|
|
864
|
+
);
|
|
865
|
+
inputs.forEach((input, index) => {
|
|
866
|
+
if (index < value.length) {
|
|
867
|
+
input.value = value[index] != null ? String(value[index]) : "";
|
|
868
|
+
input.classList.remove("invalid");
|
|
869
|
+
input.title = "";
|
|
870
|
+
}
|
|
871
|
+
});
|
|
872
|
+
if (value.length !== inputs.length) {
|
|
873
|
+
console.warn(
|
|
874
|
+
`updateNumberField: Multiple field "${fieldPath}" has ${inputs.length} inputs but received ${value.length} values. Consider re-rendering for add/remove.`
|
|
875
|
+
);
|
|
876
|
+
}
|
|
877
|
+
} else {
|
|
878
|
+
const input = scopeRoot.querySelector(
|
|
879
|
+
`[name="${fieldPath}"]`
|
|
880
|
+
);
|
|
881
|
+
if (input) {
|
|
882
|
+
input.value = value != null ? String(value) : "";
|
|
883
|
+
input.classList.remove("invalid");
|
|
884
|
+
input.title = "";
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
// src/components/select.ts
|
|
890
|
+
function renderSelectElement(element, ctx, wrapper, pathKey) {
|
|
891
|
+
const state = ctx.state;
|
|
892
|
+
const selectInput = document.createElement("select");
|
|
893
|
+
selectInput.className = "w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500";
|
|
894
|
+
selectInput.name = pathKey;
|
|
895
|
+
selectInput.disabled = state.config.readonly;
|
|
896
|
+
(element.options || []).forEach((option) => {
|
|
897
|
+
const optionEl = document.createElement("option");
|
|
898
|
+
optionEl.value = option.value;
|
|
899
|
+
optionEl.textContent = option.label;
|
|
900
|
+
if ((ctx.prefill[element.key] || element.default) === option.value) {
|
|
901
|
+
optionEl.selected = true;
|
|
902
|
+
}
|
|
903
|
+
selectInput.appendChild(optionEl);
|
|
904
|
+
});
|
|
905
|
+
if (!state.config.readonly && ctx.instance) {
|
|
906
|
+
const handleChange = () => {
|
|
907
|
+
ctx.instance.triggerOnChange(pathKey, selectInput.value);
|
|
908
|
+
};
|
|
909
|
+
selectInput.addEventListener("change", handleChange);
|
|
910
|
+
}
|
|
911
|
+
wrapper.appendChild(selectInput);
|
|
912
|
+
const selectHint = document.createElement("p");
|
|
913
|
+
selectHint.className = "text-xs text-gray-500 mt-1";
|
|
914
|
+
selectHint.textContent = makeFieldHint(element);
|
|
915
|
+
wrapper.appendChild(selectHint);
|
|
916
|
+
}
|
|
917
|
+
function renderMultipleSelectElement(element, ctx, wrapper, pathKey) {
|
|
918
|
+
const state = ctx.state;
|
|
919
|
+
const prefillValues = ctx.prefill[element.key] || [];
|
|
920
|
+
const values = Array.isArray(prefillValues) ? [...prefillValues] : [];
|
|
921
|
+
const minCount = element.minCount ?? 1;
|
|
922
|
+
const maxCount = element.maxCount ?? Infinity;
|
|
923
|
+
while (values.length < minCount) {
|
|
924
|
+
values.push(element.default || element.options?.[0]?.value || "");
|
|
925
|
+
}
|
|
926
|
+
const container = document.createElement("div");
|
|
927
|
+
container.className = "space-y-2";
|
|
928
|
+
wrapper.appendChild(container);
|
|
929
|
+
function updateIndices() {
|
|
930
|
+
const items = container.querySelectorAll(".multiple-select-item");
|
|
931
|
+
items.forEach((item, index) => {
|
|
932
|
+
const select = item.querySelector("select");
|
|
933
|
+
if (select) {
|
|
934
|
+
select.name = `${pathKey}[${index}]`;
|
|
935
|
+
}
|
|
936
|
+
});
|
|
937
|
+
}
|
|
938
|
+
function addSelectItem(value = "", index = -1) {
|
|
939
|
+
const itemWrapper = document.createElement("div");
|
|
940
|
+
itemWrapper.className = "multiple-select-item flex items-center gap-2";
|
|
941
|
+
const selectInput = document.createElement("select");
|
|
942
|
+
selectInput.className = "flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500";
|
|
943
|
+
selectInput.disabled = state.config.readonly;
|
|
944
|
+
(element.options || []).forEach((option) => {
|
|
945
|
+
const optionElement = document.createElement("option");
|
|
946
|
+
optionElement.value = option.value;
|
|
947
|
+
optionElement.textContent = option.label;
|
|
948
|
+
if (value === option.value) {
|
|
949
|
+
optionElement.selected = true;
|
|
950
|
+
}
|
|
951
|
+
selectInput.appendChild(optionElement);
|
|
952
|
+
});
|
|
953
|
+
if (!state.config.readonly && ctx.instance) {
|
|
954
|
+
const handleChange = () => {
|
|
955
|
+
ctx.instance.triggerOnChange(selectInput.name, selectInput.value);
|
|
956
|
+
};
|
|
957
|
+
selectInput.addEventListener("change", handleChange);
|
|
958
|
+
}
|
|
959
|
+
itemWrapper.appendChild(selectInput);
|
|
960
|
+
if (index === -1) {
|
|
961
|
+
container.appendChild(itemWrapper);
|
|
962
|
+
} else {
|
|
963
|
+
container.insertBefore(itemWrapper, container.children[index]);
|
|
964
|
+
}
|
|
965
|
+
updateIndices();
|
|
966
|
+
return itemWrapper;
|
|
967
|
+
}
|
|
968
|
+
function updateRemoveButtons() {
|
|
969
|
+
if (state.config.readonly) return;
|
|
970
|
+
const items = container.querySelectorAll(".multiple-select-item");
|
|
971
|
+
const currentCount = items.length;
|
|
972
|
+
items.forEach((item) => {
|
|
973
|
+
let removeBtn = item.querySelector(
|
|
974
|
+
".remove-item-btn"
|
|
975
|
+
);
|
|
976
|
+
if (!removeBtn) {
|
|
977
|
+
removeBtn = document.createElement("button");
|
|
978
|
+
removeBtn.type = "button";
|
|
979
|
+
removeBtn.className = "remove-item-btn px-2 py-1 text-red-600 hover:bg-red-50 rounded";
|
|
980
|
+
removeBtn.innerHTML = "\u2715";
|
|
981
|
+
removeBtn.onclick = () => {
|
|
982
|
+
const currentIndex = Array.from(container.children).indexOf(item);
|
|
983
|
+
if (container.children.length > minCount) {
|
|
984
|
+
values.splice(currentIndex, 1);
|
|
985
|
+
item.remove();
|
|
986
|
+
updateIndices();
|
|
987
|
+
updateAddButton();
|
|
988
|
+
updateRemoveButtons();
|
|
989
|
+
}
|
|
990
|
+
};
|
|
991
|
+
item.appendChild(removeBtn);
|
|
992
|
+
}
|
|
993
|
+
const disabled = currentCount <= minCount;
|
|
994
|
+
removeBtn.disabled = disabled;
|
|
995
|
+
removeBtn.style.opacity = disabled ? "0.5" : "1";
|
|
996
|
+
removeBtn.style.pointerEvents = disabled ? "none" : "auto";
|
|
997
|
+
});
|
|
998
|
+
}
|
|
999
|
+
function updateAddButton() {
|
|
1000
|
+
const existingAddBtn = wrapper.querySelector(".add-select-btn");
|
|
1001
|
+
if (existingAddBtn) existingAddBtn.remove();
|
|
1002
|
+
if (!state.config.readonly && values.length < maxCount) {
|
|
1003
|
+
const addBtn = document.createElement("button");
|
|
1004
|
+
addBtn.type = "button";
|
|
1005
|
+
addBtn.className = "add-select-btn mt-2 px-3 py-1 text-blue-600 border border-blue-300 rounded hover:bg-blue-50 text-sm";
|
|
1006
|
+
addBtn.textContent = `+ Add ${element.label || "Selection"}`;
|
|
1007
|
+
addBtn.onclick = () => {
|
|
1008
|
+
const defaultValue = element.default || element.options?.[0]?.value || "";
|
|
1009
|
+
values.push(defaultValue);
|
|
1010
|
+
addSelectItem(defaultValue);
|
|
1011
|
+
updateAddButton();
|
|
1012
|
+
updateRemoveButtons();
|
|
1013
|
+
};
|
|
1014
|
+
wrapper.appendChild(addBtn);
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
values.forEach((value) => addSelectItem(value));
|
|
1018
|
+
updateAddButton();
|
|
1019
|
+
updateRemoveButtons();
|
|
1020
|
+
const hint = document.createElement("p");
|
|
1021
|
+
hint.className = "text-xs text-gray-500 mt-1";
|
|
1022
|
+
hint.textContent = makeFieldHint(element);
|
|
1023
|
+
wrapper.appendChild(hint);
|
|
1024
|
+
}
|
|
1025
|
+
function validateSelectElement(element, key, context) {
|
|
1026
|
+
const errors = [];
|
|
1027
|
+
const { scopeRoot, skipValidation } = context;
|
|
1028
|
+
const markValidity = (input, errorMessage) => {
|
|
1029
|
+
if (!input) return;
|
|
1030
|
+
const errorId = `error-${input.getAttribute("name") || Math.random().toString(36).substring(7)}`;
|
|
1031
|
+
let errorElement = document.getElementById(errorId);
|
|
1032
|
+
if (errorMessage) {
|
|
1033
|
+
input.classList.add("invalid");
|
|
1034
|
+
input.title = errorMessage;
|
|
1035
|
+
if (!errorElement) {
|
|
1036
|
+
errorElement = document.createElement("div");
|
|
1037
|
+
errorElement.id = errorId;
|
|
1038
|
+
errorElement.className = "error-message";
|
|
1039
|
+
errorElement.style.cssText = `
|
|
1040
|
+
color: var(--fb-error-color);
|
|
1041
|
+
font-size: var(--fb-font-size-small);
|
|
1042
|
+
margin-top: 0.25rem;
|
|
1043
|
+
`;
|
|
1044
|
+
if (input.nextSibling) {
|
|
1045
|
+
input.parentNode?.insertBefore(errorElement, input.nextSibling);
|
|
1046
|
+
} else {
|
|
1047
|
+
input.parentNode?.appendChild(errorElement);
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
errorElement.textContent = errorMessage;
|
|
1051
|
+
errorElement.style.display = "block";
|
|
1052
|
+
} else {
|
|
1053
|
+
input.classList.remove("invalid");
|
|
1054
|
+
input.title = "";
|
|
1055
|
+
if (errorElement) {
|
|
1056
|
+
errorElement.remove();
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
};
|
|
1060
|
+
const validateMultipleCount = (key2, values, element2, filterFn) => {
|
|
1061
|
+
if (skipValidation) return;
|
|
1062
|
+
const filteredValues = values.filter(filterFn);
|
|
1063
|
+
const minCount = "minCount" in element2 ? element2.minCount ?? 1 : 1;
|
|
1064
|
+
const maxCount = "maxCount" in element2 ? element2.maxCount ?? Infinity : Infinity;
|
|
1065
|
+
if (element2.required && filteredValues.length === 0) {
|
|
1066
|
+
errors.push(`${key2}: required`);
|
|
1067
|
+
}
|
|
1068
|
+
if (filteredValues.length < minCount) {
|
|
1069
|
+
errors.push(`${key2}: minimum ${minCount} items required`);
|
|
1070
|
+
}
|
|
1071
|
+
if (filteredValues.length > maxCount) {
|
|
1072
|
+
errors.push(`${key2}: maximum ${maxCount} items allowed`);
|
|
1073
|
+
}
|
|
1074
|
+
};
|
|
1075
|
+
if ("multiple" in element && element.multiple) {
|
|
1076
|
+
const inputs = scopeRoot.querySelectorAll(
|
|
1077
|
+
`[name^="${key}["]`
|
|
1078
|
+
);
|
|
1079
|
+
const values = [];
|
|
1080
|
+
inputs.forEach((input) => {
|
|
1081
|
+
const val = input?.value ?? "";
|
|
1082
|
+
values.push(val);
|
|
1083
|
+
markValidity(input, null);
|
|
1084
|
+
});
|
|
1085
|
+
validateMultipleCount(key, values, element, (v) => v !== "");
|
|
1086
|
+
return { value: values, errors };
|
|
1087
|
+
} else {
|
|
1088
|
+
const input = scopeRoot.querySelector(
|
|
1089
|
+
`[name$="${key}"]`
|
|
1090
|
+
);
|
|
1091
|
+
const val = input?.value ?? "";
|
|
1092
|
+
if (!skipValidation && element.required && val === "") {
|
|
1093
|
+
errors.push(`${key}: required`);
|
|
1094
|
+
markValidity(input, "required");
|
|
1095
|
+
return { value: null, errors };
|
|
1096
|
+
} else {
|
|
1097
|
+
markValidity(input, null);
|
|
1098
|
+
}
|
|
1099
|
+
return { value: val === "" ? null : val, errors };
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
function updateSelectField(element, fieldPath, value, context) {
|
|
1103
|
+
const { scopeRoot } = context;
|
|
1104
|
+
if ("multiple" in element && element.multiple) {
|
|
1105
|
+
if (!Array.isArray(value)) {
|
|
1106
|
+
console.warn(
|
|
1107
|
+
`updateSelectField: Expected array for multiple field "${fieldPath}", got ${typeof value}`
|
|
1108
|
+
);
|
|
1109
|
+
return;
|
|
1110
|
+
}
|
|
1111
|
+
const selects = scopeRoot.querySelectorAll(
|
|
1112
|
+
`[name^="${fieldPath}["]`
|
|
1113
|
+
);
|
|
1114
|
+
selects.forEach((select, index) => {
|
|
1115
|
+
if (index < value.length) {
|
|
1116
|
+
select.value = value[index] != null ? String(value[index]) : "";
|
|
1117
|
+
const options = select.querySelectorAll("option");
|
|
1118
|
+
options.forEach((option) => {
|
|
1119
|
+
option.selected = option.value === String(value[index]);
|
|
1120
|
+
});
|
|
1121
|
+
select.classList.remove("invalid");
|
|
1122
|
+
select.title = "";
|
|
1123
|
+
}
|
|
1124
|
+
});
|
|
1125
|
+
if (value.length !== selects.length) {
|
|
1126
|
+
console.warn(
|
|
1127
|
+
`updateSelectField: Multiple field "${fieldPath}" has ${selects.length} selects but received ${value.length} values. Consider re-rendering for add/remove.`
|
|
1128
|
+
);
|
|
1129
|
+
}
|
|
1130
|
+
} else {
|
|
1131
|
+
const select = scopeRoot.querySelector(
|
|
1132
|
+
`[name="${fieldPath}"]`
|
|
1133
|
+
);
|
|
1134
|
+
if (select) {
|
|
1135
|
+
select.value = value != null ? String(value) : "";
|
|
1136
|
+
const options = select.querySelectorAll("option");
|
|
1137
|
+
options.forEach((option) => {
|
|
1138
|
+
option.selected = option.value === String(value);
|
|
1139
|
+
});
|
|
1140
|
+
select.classList.remove("invalid");
|
|
1141
|
+
select.title = "";
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
// src/utils/translation.ts
|
|
1147
|
+
function t(key, state) {
|
|
1148
|
+
const locale = state.config.locale || "en";
|
|
1149
|
+
const translations = state.config.translations[locale] || state.config.translations.en;
|
|
1150
|
+
return translations[key] || key;
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
// src/components/file.ts
|
|
1154
|
+
async function renderFilePreview(container, resourceId, state, options = {}) {
|
|
1155
|
+
const { fileName = "", isReadonly = false, deps = null } = options;
|
|
1156
|
+
if (!isReadonly && deps && (!deps.picker || !deps.fileUploadHandler || !deps.dragHandler)) {
|
|
1157
|
+
throw new Error(
|
|
1158
|
+
"renderFilePreview: missing deps {picker, fileUploadHandler, dragHandler}"
|
|
1159
|
+
);
|
|
1160
|
+
}
|
|
1161
|
+
clear(container);
|
|
1162
|
+
if (isReadonly) {
|
|
1163
|
+
container.classList.add("cursor-pointer");
|
|
1164
|
+
}
|
|
1165
|
+
const img = document.createElement("img");
|
|
1166
|
+
img.className = "w-full h-full object-contain";
|
|
1167
|
+
img.alt = fileName || "Preview";
|
|
1168
|
+
const meta = state.resourceIndex.get(resourceId);
|
|
1169
|
+
if (meta && meta.file && meta.file instanceof File) {
|
|
1170
|
+
if (meta.type && meta.type.startsWith("image/")) {
|
|
1171
|
+
const reader = new FileReader();
|
|
1172
|
+
reader.onload = (e) => {
|
|
1173
|
+
img.src = e.target?.result || "";
|
|
1174
|
+
};
|
|
1175
|
+
reader.readAsDataURL(meta.file);
|
|
1176
|
+
container.appendChild(img);
|
|
1177
|
+
} else if (meta.type && meta.type.startsWith("video/")) {
|
|
1178
|
+
const videoUrl = URL.createObjectURL(meta.file);
|
|
1179
|
+
container.onclick = null;
|
|
1180
|
+
const newContainer = container.cloneNode(false);
|
|
1181
|
+
if (container.parentNode) {
|
|
1182
|
+
container.parentNode.replaceChild(newContainer, container);
|
|
1183
|
+
}
|
|
1184
|
+
container = newContainer;
|
|
1185
|
+
container.innerHTML = `
|
|
1186
|
+
<div class="relative group h-full">
|
|
1187
|
+
<video class="w-full h-full object-contain" controls preload="auto" muted>
|
|
1188
|
+
<source src="${videoUrl}" type="${meta.type}">
|
|
1189
|
+
Your browser does not support the video tag.
|
|
1190
|
+
</video>
|
|
1191
|
+
<div class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity z-10 flex gap-1">
|
|
1192
|
+
<button class="bg-red-600 bg-opacity-75 hover:bg-opacity-90 text-white p-1 rounded text-xs delete-file-btn">
|
|
1193
|
+
${t("removeElement", state)}
|
|
1194
|
+
</button>
|
|
1195
|
+
<button class="bg-gray-800 bg-opacity-75 hover:bg-opacity-90 text-white p-1 rounded text-xs change-file-btn">
|
|
1196
|
+
Change
|
|
1197
|
+
</button>
|
|
1198
|
+
</div>
|
|
1199
|
+
</div>
|
|
1200
|
+
`;
|
|
1201
|
+
const changeBtn = container.querySelector(
|
|
1202
|
+
".change-file-btn"
|
|
1203
|
+
);
|
|
1204
|
+
if (changeBtn) {
|
|
1205
|
+
changeBtn.onclick = (e) => {
|
|
1206
|
+
e.stopPropagation();
|
|
1207
|
+
if (deps?.picker) {
|
|
1208
|
+
deps.picker.click();
|
|
1209
|
+
}
|
|
1210
|
+
};
|
|
1211
|
+
}
|
|
1212
|
+
const deleteBtn = container.querySelector(
|
|
1213
|
+
".delete-file-btn"
|
|
1214
|
+
);
|
|
1215
|
+
if (deleteBtn) {
|
|
1216
|
+
deleteBtn.onclick = (e) => {
|
|
1217
|
+
e.stopPropagation();
|
|
1218
|
+
state.resourceIndex.delete(resourceId);
|
|
1219
|
+
const hiddenInput = container.parentElement?.querySelector(
|
|
1220
|
+
'input[type="hidden"]'
|
|
1221
|
+
);
|
|
1222
|
+
if (hiddenInput) {
|
|
1223
|
+
hiddenInput.value = "";
|
|
1224
|
+
}
|
|
1225
|
+
if (deps?.fileUploadHandler) {
|
|
1226
|
+
container.onclick = deps.fileUploadHandler;
|
|
1227
|
+
}
|
|
1228
|
+
if (deps?.dragHandler) {
|
|
1229
|
+
setupDragAndDrop(container, deps.dragHandler);
|
|
1230
|
+
}
|
|
1231
|
+
container.innerHTML = `
|
|
1232
|
+
<div class="flex flex-col items-center justify-center h-full text-gray-400">
|
|
1233
|
+
<svg class="w-6 h-6 mb-2" fill="currentColor" viewBox="0 0 24 24">
|
|
1234
|
+
<path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/>
|
|
1235
|
+
</svg>
|
|
1236
|
+
<div class="text-sm text-center">${t("clickDragText", state)}</div>
|
|
1237
|
+
</div>
|
|
1238
|
+
`;
|
|
1239
|
+
};
|
|
1240
|
+
}
|
|
1241
|
+
} else {
|
|
1242
|
+
container.innerHTML = `<div class="flex flex-col items-center justify-center h-full text-gray-400"><div class="text-2xl mb-2">\u{1F4C1}</div><div class="text-sm">${fileName}</div></div>`;
|
|
1243
|
+
}
|
|
1244
|
+
if (!isReadonly && !(meta && meta.type && meta.type.startsWith("video/"))) {
|
|
1245
|
+
addDeleteButton(container, state, () => {
|
|
1246
|
+
state.resourceIndex.delete(resourceId);
|
|
1247
|
+
const hiddenInput = container.parentElement?.querySelector(
|
|
1248
|
+
'input[type="hidden"]'
|
|
1249
|
+
);
|
|
1250
|
+
if (hiddenInput) {
|
|
1251
|
+
hiddenInput.value = "";
|
|
1252
|
+
}
|
|
1253
|
+
container.innerHTML = `
|
|
1254
|
+
<div class="flex flex-col items-center justify-center h-full text-gray-400">
|
|
1255
|
+
<svg class="w-6 h-6 mb-2" fill="currentColor" viewBox="0 0 24 24">
|
|
1256
|
+
<path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/>
|
|
1257
|
+
</svg>
|
|
1258
|
+
<div class="text-sm text-center">${t("clickDragText", state)}</div>
|
|
1259
|
+
</div>
|
|
1260
|
+
`;
|
|
1261
|
+
});
|
|
1262
|
+
}
|
|
1263
|
+
} else if (state.config.getThumbnail) {
|
|
1264
|
+
try {
|
|
1265
|
+
const thumbnailUrl = await state.config.getThumbnail(resourceId);
|
|
1266
|
+
if (thumbnailUrl) {
|
|
1267
|
+
clear(container);
|
|
1268
|
+
img.src = thumbnailUrl;
|
|
1269
|
+
container.appendChild(img);
|
|
1270
|
+
} else {
|
|
1271
|
+
setEmptyFileContainer(container, state);
|
|
1272
|
+
}
|
|
1273
|
+
} catch (error) {
|
|
1274
|
+
console.error("Failed to get thumbnail:", error);
|
|
1275
|
+
container.innerHTML = `
|
|
1276
|
+
<div class="flex flex-col items-center justify-center h-full text-gray-400">
|
|
1277
|
+
<svg class="w-6 h-6 mb-2" fill="currentColor" viewBox="0 0 24 24">
|
|
1278
|
+
<path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/>
|
|
1279
|
+
</svg>
|
|
1280
|
+
<div class="text-sm text-center">${fileName || "Preview unavailable"}</div>
|
|
1281
|
+
</div>
|
|
1282
|
+
`;
|
|
1283
|
+
}
|
|
1284
|
+
} else {
|
|
1285
|
+
setEmptyFileContainer(container, state);
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
async function renderFilePreviewReadonly(resourceId, state, fileName) {
|
|
1289
|
+
const meta = state.resourceIndex.get(resourceId);
|
|
1290
|
+
const actualFileName = meta?.name || resourceId.split("/").pop() || "file";
|
|
1291
|
+
const isPSD = actualFileName.toLowerCase().match(/\.psd$/);
|
|
1292
|
+
const fileResult = document.createElement("div");
|
|
1293
|
+
fileResult.className = isPSD ? "space-y-2" : "space-y-3";
|
|
1294
|
+
const previewContainer = document.createElement("div");
|
|
1295
|
+
if (isPSD) {
|
|
1296
|
+
previewContainer.className = "bg-gray-100 rounded-lg overflow-hidden cursor-pointer hover:opacity-90 transition-opacity flex items-center p-3 max-w-sm";
|
|
1297
|
+
} else {
|
|
1298
|
+
previewContainer.className = "bg-gray-100 rounded-lg overflow-hidden cursor-pointer hover:opacity-90 transition-opacity";
|
|
1299
|
+
}
|
|
1300
|
+
const isImage = !isPSD && (meta?.type?.startsWith("image/") || actualFileName.toLowerCase().match(/\.(jpg|jpeg|png|gif|webp)$/));
|
|
1301
|
+
const isVideo = meta?.type?.startsWith("video/") || actualFileName.toLowerCase().match(/\.(mp4|webm|avi|mov)$/);
|
|
1302
|
+
if (isImage) {
|
|
1303
|
+
if (state.config.getThumbnail) {
|
|
1304
|
+
try {
|
|
1305
|
+
const thumbnailUrl = await state.config.getThumbnail(resourceId);
|
|
1306
|
+
if (thumbnailUrl) {
|
|
1307
|
+
previewContainer.innerHTML = `<img src="${thumbnailUrl}" alt="${actualFileName}" class="w-full h-auto">`;
|
|
1308
|
+
} else {
|
|
1309
|
+
previewContainer.innerHTML = `<div class="aspect-video flex items-center justify-center text-gray-400"><div class="text-center"><div class="text-4xl mb-2">\u{1F5BC}\uFE0F</div><div class="text-sm">${actualFileName}</div></div></div>`;
|
|
1310
|
+
}
|
|
1311
|
+
} catch (error) {
|
|
1312
|
+
console.warn("getThumbnail failed for", resourceId, error);
|
|
1313
|
+
previewContainer.innerHTML = `<div class="aspect-video flex items-center justify-center text-gray-400"><div class="text-center"><div class="text-4xl mb-2">\u{1F5BC}\uFE0F</div><div class="text-sm">${actualFileName}</div></div></div>`;
|
|
1314
|
+
}
|
|
1315
|
+
} else {
|
|
1316
|
+
previewContainer.innerHTML = `<div class="aspect-video flex items-center justify-center text-gray-400"><div class="text-center"><div class="text-4xl mb-2">\u{1F5BC}\uFE0F</div><div class="text-sm">${actualFileName}</div></div></div>`;
|
|
1317
|
+
}
|
|
1318
|
+
} else if (isVideo) {
|
|
1319
|
+
if (state.config.getThumbnail) {
|
|
1320
|
+
try {
|
|
1321
|
+
const videoUrl = await state.config.getThumbnail(resourceId);
|
|
1322
|
+
if (videoUrl) {
|
|
1323
|
+
previewContainer.innerHTML = `
|
|
1324
|
+
<div class="relative group">
|
|
1325
|
+
<video class="w-full h-auto" controls preload="auto" muted>
|
|
1326
|
+
<source src="${videoUrl}" type="${meta?.type || "video/mp4"}">
|
|
1327
|
+
\u0412\u0430\u0448 \u0431\u0440\u0430\u0443\u0437\u0435\u0440 \u043D\u0435 \u043F\u043E\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442 \u0432\u0438\u0434\u0435\u043E.
|
|
1328
|
+
</video>
|
|
1329
|
+
<div class="absolute inset-0 bg-black bg-opacity-20 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center pointer-events-none">
|
|
1330
|
+
<div class="bg-white bg-opacity-90 rounded-full p-3">
|
|
1331
|
+
<svg class="w-8 h-8 text-gray-800" fill="currentColor" viewBox="0 0 24 24">
|
|
1332
|
+
<path d="M8 5v14l11-7z"/>
|
|
1333
|
+
</svg>
|
|
1334
|
+
</div>
|
|
1335
|
+
</div>
|
|
1336
|
+
</div>
|
|
1337
|
+
`;
|
|
1338
|
+
} else {
|
|
1339
|
+
previewContainer.innerHTML = `<div class="aspect-video flex items-center justify-center text-gray-400"><div class="text-center"><div class="text-4xl mb-2">\u{1F3A5}</div><div class="text-sm">${actualFileName}</div></div></div>`;
|
|
1340
|
+
}
|
|
1341
|
+
} catch (error) {
|
|
1342
|
+
console.warn("getThumbnail failed for video", resourceId, error);
|
|
1343
|
+
previewContainer.innerHTML = `<div class="aspect-video flex items-center justify-center text-gray-400"><div class="text-center"><div class="text-4xl mb-2">\u{1F3A5}</div><div class="text-sm">${actualFileName}</div></div></div>`;
|
|
1344
|
+
}
|
|
1345
|
+
} else {
|
|
1346
|
+
previewContainer.innerHTML = `<div class="aspect-video flex items-center justify-center text-gray-400"><div class="text-center"><div class="text-4xl mb-2">\u{1F3A5}</div><div class="text-sm">${actualFileName}</div></div></div>`;
|
|
1347
|
+
}
|
|
1348
|
+
} else {
|
|
1349
|
+
const fileIcon = isPSD ? "\u{1F3A8}" : "\u{1F4C1}";
|
|
1350
|
+
const fileDescription = isPSD ? "PSD File" : "Document";
|
|
1351
|
+
if (isPSD) {
|
|
1352
|
+
previewContainer.innerHTML = `
|
|
1353
|
+
<div class="flex items-center space-x-3">
|
|
1354
|
+
<div class="text-3xl text-gray-400">${fileIcon}</div>
|
|
1355
|
+
<div class="flex-1 min-w-0">
|
|
1356
|
+
<div class="text-sm font-medium text-gray-900 truncate">${actualFileName}</div>
|
|
1357
|
+
<div class="text-xs text-gray-500">${fileDescription}</div>
|
|
1358
|
+
</div>
|
|
1359
|
+
</div>
|
|
1360
|
+
`;
|
|
1361
|
+
} else {
|
|
1362
|
+
previewContainer.innerHTML = `<div class="aspect-video flex items-center justify-center text-gray-400"><div class="text-center"><div class="text-4xl mb-2">${fileIcon}</div><div class="text-sm">${actualFileName}</div><div class="text-xs text-gray-500 mt-1">${fileDescription}</div></div></div>`;
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
const fileNameElement = document.createElement("p");
|
|
1366
|
+
fileNameElement.className = isPSD ? "hidden" : "text-sm font-medium text-gray-900 text-center";
|
|
1367
|
+
fileNameElement.textContent = actualFileName;
|
|
1368
|
+
const downloadButton = document.createElement("button");
|
|
1369
|
+
downloadButton.className = "w-full px-3 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors";
|
|
1370
|
+
downloadButton.textContent = t("downloadButton", state);
|
|
1371
|
+
downloadButton.onclick = (e) => {
|
|
1372
|
+
e.preventDefault();
|
|
1373
|
+
e.stopPropagation();
|
|
1374
|
+
if (state.config.downloadFile) {
|
|
1375
|
+
state.config.downloadFile(resourceId, actualFileName);
|
|
1376
|
+
} else {
|
|
1377
|
+
forceDownload(resourceId, actualFileName, state);
|
|
1378
|
+
}
|
|
1379
|
+
};
|
|
1380
|
+
fileResult.appendChild(previewContainer);
|
|
1381
|
+
fileResult.appendChild(fileNameElement);
|
|
1382
|
+
fileResult.appendChild(downloadButton);
|
|
1383
|
+
return fileResult;
|
|
1384
|
+
}
|
|
1385
|
+
function renderResourcePills(container, rids, state, onRemove) {
|
|
1386
|
+
clear(container);
|
|
1387
|
+
const isInitialRender = !container.classList.contains("grid");
|
|
1388
|
+
if ((!rids || rids.length === 0) && isInitialRender) {
|
|
1389
|
+
const gridContainer = document.createElement("div");
|
|
1390
|
+
gridContainer.className = "grid grid-cols-4 gap-3 mb-3";
|
|
1391
|
+
for (let i = 0; i < 4; i++) {
|
|
1392
|
+
const slot = document.createElement("div");
|
|
1393
|
+
slot.className = "aspect-square bg-gray-100 border-2 border-dashed border-gray-300 rounded flex items-center justify-center cursor-pointer hover:border-gray-400 transition-colors";
|
|
1394
|
+
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
1395
|
+
svg.setAttribute("class", "w-12 h-12 text-gray-400");
|
|
1396
|
+
svg.setAttribute("fill", "currentColor");
|
|
1397
|
+
svg.setAttribute("viewBox", "0 0 24 24");
|
|
1398
|
+
const path = document.createElementNS(
|
|
1399
|
+
"http://www.w3.org/2000/svg",
|
|
1400
|
+
"path"
|
|
1401
|
+
);
|
|
1402
|
+
path.setAttribute(
|
|
1403
|
+
"d",
|
|
1404
|
+
"M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"
|
|
1405
|
+
);
|
|
1406
|
+
svg.appendChild(path);
|
|
1407
|
+
slot.appendChild(svg);
|
|
1408
|
+
slot.onclick = () => {
|
|
1409
|
+
let filesWrapper = container.parentElement;
|
|
1410
|
+
while (filesWrapper && !filesWrapper.classList.contains("space-y-2")) {
|
|
1411
|
+
filesWrapper = filesWrapper.parentElement;
|
|
1412
|
+
}
|
|
1413
|
+
if (!filesWrapper && container.classList.contains("space-y-2")) {
|
|
1414
|
+
filesWrapper = container;
|
|
1415
|
+
}
|
|
1416
|
+
const fileInput = filesWrapper?.querySelector(
|
|
1417
|
+
'input[type="file"]'
|
|
1418
|
+
);
|
|
1419
|
+
if (fileInput) fileInput.click();
|
|
1420
|
+
};
|
|
1421
|
+
gridContainer.appendChild(slot);
|
|
1422
|
+
}
|
|
1423
|
+
const textContainer = document.createElement("div");
|
|
1424
|
+
textContainer.className = "text-center text-xs text-gray-600";
|
|
1425
|
+
const uploadLink = document.createElement("span");
|
|
1426
|
+
uploadLink.className = "underline cursor-pointer";
|
|
1427
|
+
uploadLink.textContent = t("uploadText", state);
|
|
1428
|
+
uploadLink.onclick = (e) => {
|
|
1429
|
+
e.stopPropagation();
|
|
1430
|
+
let filesWrapper = container.parentElement;
|
|
1431
|
+
while (filesWrapper && !filesWrapper.classList.contains("space-y-2")) {
|
|
1432
|
+
filesWrapper = filesWrapper.parentElement;
|
|
1433
|
+
}
|
|
1434
|
+
if (!filesWrapper && container.classList.contains("space-y-2")) {
|
|
1435
|
+
filesWrapper = container;
|
|
1436
|
+
}
|
|
1437
|
+
const fileInput = filesWrapper?.querySelector(
|
|
1438
|
+
'input[type="file"]'
|
|
1439
|
+
);
|
|
1440
|
+
if (fileInput) fileInput.click();
|
|
1441
|
+
};
|
|
1442
|
+
textContainer.appendChild(uploadLink);
|
|
1443
|
+
textContainer.appendChild(document.createTextNode(` ${t("dragDropText", state)}`));
|
|
1444
|
+
container.appendChild(gridContainer);
|
|
1445
|
+
container.appendChild(textContainer);
|
|
1446
|
+
return;
|
|
1447
|
+
}
|
|
1448
|
+
container.className = "files-list grid grid-cols-4 gap-3 mt-2";
|
|
1449
|
+
const currentImagesCount = rids ? rids.length : 0;
|
|
1450
|
+
const rowsNeeded = Math.floor(currentImagesCount / 4) + 1;
|
|
1451
|
+
const slotsNeeded = rowsNeeded * 4;
|
|
1452
|
+
for (let i = 0; i < slotsNeeded; i++) {
|
|
1453
|
+
const slot = document.createElement("div");
|
|
1454
|
+
if (rids && i < rids.length) {
|
|
1455
|
+
const rid = rids[i];
|
|
1456
|
+
const meta = state.resourceIndex.get(rid);
|
|
1457
|
+
slot.className = "resource-pill aspect-square bg-gray-100 rounded-lg overflow-hidden relative group border border-gray-300";
|
|
1458
|
+
slot.dataset.resourceId = rid;
|
|
1459
|
+
renderThumbnailForResource(slot, rid, meta, state).catch((err) => {
|
|
1460
|
+
console.error("Failed to render thumbnail:", err);
|
|
1461
|
+
slot.innerHTML = `<div class="flex flex-col items-center justify-center h-full text-gray-400">
|
|
1462
|
+
<div class="text-2xl mb-1">\u{1F4C1}</div>
|
|
1463
|
+
<div class="text-xs">Preview error</div>
|
|
1464
|
+
</div>`;
|
|
1465
|
+
});
|
|
1466
|
+
if (onRemove) {
|
|
1467
|
+
const overlay = document.createElement("div");
|
|
1468
|
+
overlay.className = "absolute inset-0 bg-black bg-opacity-50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center";
|
|
1469
|
+
const removeBtn = document.createElement("button");
|
|
1470
|
+
removeBtn.className = "bg-red-600 text-white px-2 py-1 rounded text-xs";
|
|
1471
|
+
removeBtn.textContent = t("removeElement", state);
|
|
1472
|
+
removeBtn.onclick = (e) => {
|
|
1473
|
+
e.stopPropagation();
|
|
1474
|
+
onRemove(rid);
|
|
1475
|
+
};
|
|
1476
|
+
overlay.appendChild(removeBtn);
|
|
1477
|
+
slot.appendChild(overlay);
|
|
1478
|
+
}
|
|
1479
|
+
} else {
|
|
1480
|
+
slot.className = "aspect-square bg-gray-100 border-2 border-dashed border-gray-300 rounded-lg flex items-center justify-center cursor-pointer hover:border-gray-400 transition-colors";
|
|
1481
|
+
slot.innerHTML = '<svg class="w-12 h-12 text-gray-400" fill="currentColor" viewBox="0 0 24 24"><path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/></svg>';
|
|
1482
|
+
slot.onclick = () => {
|
|
1483
|
+
let filesWrapper = container.parentElement;
|
|
1484
|
+
while (filesWrapper && !filesWrapper.classList.contains("space-y-2")) {
|
|
1485
|
+
filesWrapper = filesWrapper.parentElement;
|
|
1486
|
+
}
|
|
1487
|
+
if (!filesWrapper && container.classList.contains("space-y-2")) {
|
|
1488
|
+
filesWrapper = container;
|
|
1489
|
+
}
|
|
1490
|
+
const fileInput = filesWrapper?.querySelector(
|
|
1491
|
+
'input[type="file"]'
|
|
1492
|
+
);
|
|
1493
|
+
if (fileInput) fileInput.click();
|
|
1494
|
+
};
|
|
1495
|
+
}
|
|
1496
|
+
container.appendChild(slot);
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
async function renderThumbnailForResource(slot, rid, meta, state) {
|
|
1500
|
+
if (meta && meta.type?.startsWith("image/")) {
|
|
1501
|
+
if (meta.file && meta.file instanceof File) {
|
|
1502
|
+
const img = document.createElement("img");
|
|
1503
|
+
img.className = "w-full h-full object-contain";
|
|
1504
|
+
img.alt = meta.name;
|
|
1505
|
+
const reader = new FileReader();
|
|
1506
|
+
reader.onload = (e) => {
|
|
1507
|
+
img.src = e.target?.result || "";
|
|
1508
|
+
};
|
|
1509
|
+
reader.readAsDataURL(meta.file);
|
|
1510
|
+
slot.appendChild(img);
|
|
1511
|
+
} else if (state.config.getThumbnail) {
|
|
1512
|
+
const url = await state.config.getThumbnail(rid);
|
|
1513
|
+
if (url) {
|
|
1514
|
+
const img = document.createElement("img");
|
|
1515
|
+
img.className = "w-full h-full object-contain";
|
|
1516
|
+
img.alt = meta.name;
|
|
1517
|
+
img.src = url;
|
|
1518
|
+
slot.appendChild(img);
|
|
1519
|
+
} else {
|
|
1520
|
+
slot.innerHTML = `<div class="flex flex-col items-center justify-center h-full text-gray-400">
|
|
1521
|
+
<svg class="w-12 h-12" fill="currentColor" viewBox="0 0 24 24">
|
|
1522
|
+
<path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/>
|
|
1523
|
+
</svg>
|
|
1524
|
+
</div>`;
|
|
1525
|
+
}
|
|
1526
|
+
} else {
|
|
1527
|
+
slot.innerHTML = `<div class="flex flex-col items-center justify-center h-full text-gray-400">
|
|
1528
|
+
<svg class="w-12 h-12" fill="currentColor" viewBox="0 0 24 24">
|
|
1529
|
+
<path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/>
|
|
1530
|
+
</svg>
|
|
1531
|
+
</div>`;
|
|
1532
|
+
}
|
|
1533
|
+
} else if (meta && meta.type?.startsWith("video/")) {
|
|
1534
|
+
if (meta.file && meta.file instanceof File) {
|
|
1535
|
+
const videoUrl = URL.createObjectURL(meta.file);
|
|
1536
|
+
slot.innerHTML = `
|
|
1537
|
+
<div class="relative group h-full w-full">
|
|
1538
|
+
<video class="w-full h-full object-contain" preload="metadata" muted>
|
|
1539
|
+
<source src="${videoUrl}" type="${meta.type}">
|
|
1540
|
+
</video>
|
|
1541
|
+
<div class="absolute inset-0 bg-black bg-opacity-30 flex items-center justify-center">
|
|
1542
|
+
<div class="bg-white bg-opacity-90 rounded-full p-1">
|
|
1543
|
+
<svg class="w-4 h-4 text-gray-800" fill="currentColor" viewBox="0 0 24 24">
|
|
1544
|
+
<path d="M8 5v14l11-7z"/>
|
|
1545
|
+
</svg>
|
|
1546
|
+
</div>
|
|
1547
|
+
</div>
|
|
1548
|
+
</div>
|
|
1549
|
+
`;
|
|
1550
|
+
} else if (state.config.getThumbnail) {
|
|
1551
|
+
const videoUrl = await state.config.getThumbnail(rid);
|
|
1552
|
+
if (videoUrl) {
|
|
1553
|
+
slot.innerHTML = `
|
|
1554
|
+
<div class="relative group h-full w-full">
|
|
1555
|
+
<video class="w-full h-full object-contain" preload="metadata" muted>
|
|
1556
|
+
<source src="${videoUrl}" type="${meta.type}">
|
|
1557
|
+
</video>
|
|
1558
|
+
<div class="absolute inset-0 bg-black bg-opacity-30 flex items-center justify-center">
|
|
1559
|
+
<div class="bg-white bg-opacity-90 rounded-full p-1">
|
|
1560
|
+
<svg class="w-4 h-4 text-gray-800" fill="currentColor" viewBox="0 0 24 24">
|
|
1561
|
+
<path d="M8 5v14l11-7z"/>
|
|
1562
|
+
</svg>
|
|
1563
|
+
</div>
|
|
1564
|
+
</div>
|
|
1565
|
+
</div>
|
|
1566
|
+
`;
|
|
1567
|
+
} else {
|
|
1568
|
+
slot.innerHTML = `<div class="flex flex-col items-center justify-center h-full text-gray-400">
|
|
1569
|
+
<svg class="w-8 h-8" fill="currentColor" viewBox="0 0 24 24">
|
|
1570
|
+
<path d="M8 5v14l11-7z"/>
|
|
1571
|
+
</svg>
|
|
1572
|
+
<div class="text-xs mt-1">${meta?.name || "Video"}</div>
|
|
1573
|
+
</div>`;
|
|
1574
|
+
}
|
|
1575
|
+
} else {
|
|
1576
|
+
slot.innerHTML = `<div class="flex flex-col items-center justify-center h-full text-gray-400">
|
|
1577
|
+
<svg class="w-8 h-8" fill="currentColor" viewBox="0 0 24 24">
|
|
1578
|
+
<path d="M8 5v14l11-7z"/>
|
|
1579
|
+
</svg>
|
|
1580
|
+
<div class="text-xs mt-1">${meta?.name || "Video"}</div>
|
|
1581
|
+
</div>`;
|
|
1582
|
+
}
|
|
1583
|
+
} else {
|
|
1584
|
+
slot.innerHTML = `<div class="flex flex-col items-center justify-center h-full text-gray-400">
|
|
1585
|
+
<div class="text-2xl mb-1">\u{1F4C1}</div>
|
|
1586
|
+
<div class="text-xs">${meta?.name || "File"}</div>
|
|
1587
|
+
</div>`;
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
function setEmptyFileContainer(fileContainer, state) {
|
|
1591
|
+
fileContainer.innerHTML = `
|
|
1592
|
+
<div class="flex flex-col items-center justify-center h-full text-gray-400">
|
|
1593
|
+
<svg class="w-6 h-6 mb-2" fill="currentColor" viewBox="0 0 24 24">
|
|
1594
|
+
<path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/>
|
|
1595
|
+
</svg>
|
|
1596
|
+
<div class="text-sm text-center">${t("clickDragText", state)}</div>
|
|
1597
|
+
</div>
|
|
1598
|
+
`;
|
|
1599
|
+
}
|
|
1600
|
+
async function handleFileSelect(file, container, fieldName, state, deps = null, instance) {
|
|
1601
|
+
let rid;
|
|
1602
|
+
if (state.config.uploadFile) {
|
|
1603
|
+
try {
|
|
1604
|
+
rid = await state.config.uploadFile(file);
|
|
1605
|
+
if (typeof rid !== "string") {
|
|
1606
|
+
throw new Error("Upload handler must return a string resource ID");
|
|
1607
|
+
}
|
|
1608
|
+
} catch (error) {
|
|
1609
|
+
throw new Error(`File upload failed: ${error.message}`);
|
|
1610
|
+
}
|
|
1611
|
+
} else {
|
|
1612
|
+
throw new Error(
|
|
1613
|
+
"No upload handler configured. Set uploadHandler via FormBuilder.setUploadHandler()"
|
|
1614
|
+
);
|
|
1615
|
+
}
|
|
1616
|
+
state.resourceIndex.set(rid, {
|
|
1617
|
+
name: file.name,
|
|
1618
|
+
type: file.type,
|
|
1619
|
+
size: file.size,
|
|
1620
|
+
uploadedAt: /* @__PURE__ */ new Date(),
|
|
1621
|
+
file
|
|
1622
|
+
// Store the file object for local preview
|
|
1623
|
+
});
|
|
1624
|
+
let hiddenInput = container.parentElement?.querySelector(
|
|
1625
|
+
'input[type="hidden"]'
|
|
1626
|
+
);
|
|
1627
|
+
if (!hiddenInput) {
|
|
1628
|
+
hiddenInput = document.createElement("input");
|
|
1629
|
+
hiddenInput.type = "hidden";
|
|
1630
|
+
hiddenInput.name = fieldName;
|
|
1631
|
+
container.parentElement?.appendChild(hiddenInput);
|
|
1632
|
+
}
|
|
1633
|
+
hiddenInput.value = rid;
|
|
1634
|
+
renderFilePreview(container, rid, state, {
|
|
1635
|
+
fileName: file.name,
|
|
1636
|
+
isReadonly: false,
|
|
1637
|
+
deps
|
|
1638
|
+
}).catch(console.error);
|
|
1639
|
+
if (instance && !state.config.readonly) {
|
|
1640
|
+
instance.triggerOnChange(fieldName, rid);
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
function setupDragAndDrop(element, dropHandler) {
|
|
1644
|
+
element.addEventListener("dragover", (e) => {
|
|
1645
|
+
e.preventDefault();
|
|
1646
|
+
element.classList.add("border-blue-500", "bg-blue-50");
|
|
1647
|
+
});
|
|
1648
|
+
element.addEventListener("dragleave", (e) => {
|
|
1649
|
+
e.preventDefault();
|
|
1650
|
+
element.classList.remove("border-blue-500", "bg-blue-50");
|
|
1651
|
+
});
|
|
1652
|
+
element.addEventListener("drop", (e) => {
|
|
1653
|
+
e.preventDefault();
|
|
1654
|
+
element.classList.remove("border-blue-500", "bg-blue-50");
|
|
1655
|
+
if (e.dataTransfer?.files) {
|
|
1656
|
+
dropHandler(e.dataTransfer.files);
|
|
1657
|
+
}
|
|
1658
|
+
});
|
|
1659
|
+
}
|
|
1660
|
+
function addDeleteButton(container, state, onDelete) {
|
|
1661
|
+
const existingOverlay = container.querySelector(".delete-overlay");
|
|
1662
|
+
if (existingOverlay) {
|
|
1663
|
+
existingOverlay.remove();
|
|
1664
|
+
}
|
|
1665
|
+
const overlay = document.createElement("div");
|
|
1666
|
+
overlay.className = "delete-overlay absolute inset-0 bg-black bg-opacity-50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center";
|
|
1667
|
+
const deleteBtn = document.createElement("button");
|
|
1668
|
+
deleteBtn.className = "bg-red-600 text-white px-3 py-1 rounded text-sm hover:bg-red-700 transition-colors";
|
|
1669
|
+
deleteBtn.textContent = t("removeElement", state);
|
|
1670
|
+
deleteBtn.onclick = (e) => {
|
|
1671
|
+
e.stopPropagation();
|
|
1672
|
+
onDelete();
|
|
1673
|
+
};
|
|
1674
|
+
overlay.appendChild(deleteBtn);
|
|
1675
|
+
container.appendChild(overlay);
|
|
1676
|
+
}
|
|
1677
|
+
async function uploadSingleFile(file, state) {
|
|
1678
|
+
if (state.config.uploadFile) {
|
|
1679
|
+
try {
|
|
1680
|
+
const rid = await state.config.uploadFile(file);
|
|
1681
|
+
if (typeof rid !== "string") {
|
|
1682
|
+
throw new Error("Upload handler must return a string resource ID");
|
|
1683
|
+
}
|
|
1684
|
+
return rid;
|
|
1685
|
+
} catch (error) {
|
|
1686
|
+
throw new Error(`File upload failed: ${error.message}`);
|
|
1687
|
+
}
|
|
1688
|
+
} else {
|
|
1689
|
+
throw new Error(
|
|
1690
|
+
"No upload handler configured. Set uploadHandler via FormBuilder.setUploadHandler()"
|
|
1691
|
+
);
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1694
|
+
async function forceDownload(resourceId, fileName, state) {
|
|
1695
|
+
let fileUrl = null;
|
|
1696
|
+
if (state.config.getDownloadUrl) {
|
|
1697
|
+
fileUrl = state.config.getDownloadUrl(resourceId);
|
|
1698
|
+
} else if (state.config.getThumbnail) {
|
|
1699
|
+
fileUrl = await state.config.getThumbnail(resourceId);
|
|
1700
|
+
}
|
|
1701
|
+
if (fileUrl) {
|
|
1702
|
+
const finalUrl = fileUrl.startsWith("http") ? fileUrl : new URL(fileUrl, window.location.href).href;
|
|
1703
|
+
fetch(finalUrl).then((response) => {
|
|
1704
|
+
if (!response.ok) {
|
|
1705
|
+
throw new Error(`HTTP error! status: ${response.status}`);
|
|
1706
|
+
}
|
|
1707
|
+
return response.blob();
|
|
1708
|
+
}).then((blob) => {
|
|
1709
|
+
downloadBlob(blob, fileName);
|
|
1710
|
+
}).catch((error) => {
|
|
1711
|
+
throw new Error(`File download failed: ${error.message}`);
|
|
1712
|
+
});
|
|
1713
|
+
} else {
|
|
1714
|
+
console.warn("No download URL available for resource:", resourceId);
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
function downloadBlob(blob, fileName) {
|
|
1718
|
+
try {
|
|
1719
|
+
const blobUrl = URL.createObjectURL(blob);
|
|
1720
|
+
const link = document.createElement("a");
|
|
1721
|
+
link.href = blobUrl;
|
|
1722
|
+
link.download = fileName;
|
|
1723
|
+
link.style.display = "none";
|
|
1724
|
+
document.body.appendChild(link);
|
|
1725
|
+
link.click();
|
|
1726
|
+
document.body.removeChild(link);
|
|
1727
|
+
setTimeout(() => {
|
|
1728
|
+
URL.revokeObjectURL(blobUrl);
|
|
1729
|
+
}, 100);
|
|
1730
|
+
} catch (error) {
|
|
1731
|
+
throw new Error(`Blob download failed: ${error.message}`);
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
function addPrefillFilesToIndex(initialFiles, state) {
|
|
1735
|
+
if (initialFiles.length > 0) {
|
|
1736
|
+
initialFiles.forEach((resourceId) => {
|
|
1737
|
+
if (!state.resourceIndex.has(resourceId)) {
|
|
1738
|
+
const filename = resourceId.split("/").pop() || "file";
|
|
1739
|
+
const extension = filename.split(".").pop()?.toLowerCase();
|
|
1740
|
+
const fileType = extension && ["jpg", "jpeg", "png", "gif", "webp"].includes(extension) ? `image/${extension === "jpg" ? "jpeg" : extension}` : "application/octet-stream";
|
|
1741
|
+
state.resourceIndex.set(resourceId, {
|
|
1742
|
+
name: filename,
|
|
1743
|
+
type: fileType,
|
|
1744
|
+
size: 0,
|
|
1745
|
+
uploadedAt: /* @__PURE__ */ new Date(),
|
|
1746
|
+
file: void 0
|
|
1747
|
+
});
|
|
1748
|
+
}
|
|
1749
|
+
});
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
function handleInitialFileData(initial, fileContainer, pathKey, fileWrapper, state, deps) {
|
|
1753
|
+
if (!state.resourceIndex.has(initial)) {
|
|
1754
|
+
const filename = initial.split("/").pop() || "file";
|
|
1755
|
+
const extension = filename.split(".").pop()?.toLowerCase();
|
|
1756
|
+
let fileType = "application/octet-stream";
|
|
1757
|
+
if (extension) {
|
|
1758
|
+
if (["jpg", "jpeg", "png", "gif", "webp"].includes(extension)) {
|
|
1759
|
+
fileType = `image/${extension === "jpg" ? "jpeg" : extension}`;
|
|
1760
|
+
} else if (["mp4", "webm", "mov", "avi"].includes(extension)) {
|
|
1761
|
+
fileType = `video/${extension === "mov" ? "quicktime" : extension}`;
|
|
1762
|
+
}
|
|
1763
|
+
}
|
|
1764
|
+
state.resourceIndex.set(initial, {
|
|
1765
|
+
name: filename,
|
|
1766
|
+
type: fileType,
|
|
1767
|
+
size: 0,
|
|
1768
|
+
uploadedAt: /* @__PURE__ */ new Date(),
|
|
1769
|
+
file: void 0
|
|
1770
|
+
});
|
|
1771
|
+
}
|
|
1772
|
+
renderFilePreview(fileContainer, initial, state, {
|
|
1773
|
+
fileName: initial,
|
|
1774
|
+
isReadonly: false,
|
|
1775
|
+
deps
|
|
1776
|
+
}).catch(console.error);
|
|
1777
|
+
const hiddenInput = document.createElement("input");
|
|
1778
|
+
hiddenInput.type = "hidden";
|
|
1779
|
+
hiddenInput.name = pathKey;
|
|
1780
|
+
hiddenInput.value = initial;
|
|
1781
|
+
fileWrapper.appendChild(hiddenInput);
|
|
1782
|
+
}
|
|
1783
|
+
function setupFilesDropHandler(filesContainer, initialFiles, state, updateCallback, pathKey, instance) {
|
|
1784
|
+
setupDragAndDrop(filesContainer, async (files) => {
|
|
1785
|
+
const arr = Array.from(files);
|
|
1786
|
+
for (const file of arr) {
|
|
1787
|
+
const rid = await uploadSingleFile(file, state);
|
|
1788
|
+
state.resourceIndex.set(rid, {
|
|
1789
|
+
name: file.name,
|
|
1790
|
+
type: file.type,
|
|
1791
|
+
size: file.size,
|
|
1792
|
+
uploadedAt: /* @__PURE__ */ new Date(),
|
|
1793
|
+
file: void 0
|
|
1794
|
+
});
|
|
1795
|
+
initialFiles.push(rid);
|
|
1796
|
+
}
|
|
1797
|
+
updateCallback();
|
|
1798
|
+
if (instance && pathKey && !state.config.readonly) {
|
|
1799
|
+
instance.triggerOnChange(pathKey, initialFiles);
|
|
1800
|
+
}
|
|
1801
|
+
});
|
|
1802
|
+
}
|
|
1803
|
+
function setupFilesPickerHandler(filesPicker, initialFiles, state, updateCallback, pathKey, instance) {
|
|
1804
|
+
filesPicker.onchange = async () => {
|
|
1805
|
+
if (filesPicker.files) {
|
|
1806
|
+
for (const file of Array.from(filesPicker.files)) {
|
|
1807
|
+
const rid = await uploadSingleFile(file, state);
|
|
1808
|
+
state.resourceIndex.set(rid, {
|
|
1809
|
+
name: file.name,
|
|
1810
|
+
type: file.type,
|
|
1811
|
+
size: file.size,
|
|
1812
|
+
uploadedAt: /* @__PURE__ */ new Date(),
|
|
1813
|
+
file: void 0
|
|
1814
|
+
});
|
|
1815
|
+
initialFiles.push(rid);
|
|
1816
|
+
}
|
|
1817
|
+
}
|
|
1818
|
+
updateCallback();
|
|
1819
|
+
filesPicker.value = "";
|
|
1820
|
+
if (instance && pathKey && !state.config.readonly) {
|
|
1821
|
+
instance.triggerOnChange(pathKey, initialFiles);
|
|
1822
|
+
}
|
|
1823
|
+
};
|
|
1824
|
+
}
|
|
1825
|
+
function renderFileElement(element, ctx, wrapper, pathKey) {
|
|
1826
|
+
const state = ctx.state;
|
|
1827
|
+
if (state.config.readonly) {
|
|
1828
|
+
const initial = ctx.prefill[element.key];
|
|
1829
|
+
if (initial) {
|
|
1830
|
+
renderFilePreviewReadonly(initial, state).then((filePreview) => {
|
|
1831
|
+
wrapper.appendChild(filePreview);
|
|
1832
|
+
}).catch((err) => {
|
|
1833
|
+
console.error("Failed to render file preview:", err);
|
|
1834
|
+
const emptyState = document.createElement("div");
|
|
1835
|
+
emptyState.className = "aspect-video bg-gray-100 rounded-lg flex items-center justify-center text-gray-500";
|
|
1836
|
+
emptyState.innerHTML = `<div class="text-center">Preview unavailable</div>`;
|
|
1837
|
+
wrapper.appendChild(emptyState);
|
|
1838
|
+
});
|
|
1839
|
+
} else {
|
|
1840
|
+
const emptyState = document.createElement("div");
|
|
1841
|
+
emptyState.className = "aspect-video bg-gray-100 rounded-lg flex items-center justify-center text-gray-500";
|
|
1842
|
+
emptyState.innerHTML = `<div class="text-center">${t("noFileSelected", state)}</div>`;
|
|
1843
|
+
wrapper.appendChild(emptyState);
|
|
1844
|
+
}
|
|
1845
|
+
} else {
|
|
1846
|
+
const fileWrapper = document.createElement("div");
|
|
1847
|
+
fileWrapper.className = "space-y-2";
|
|
1848
|
+
const picker = document.createElement("input");
|
|
1849
|
+
picker.type = "file";
|
|
1850
|
+
picker.name = pathKey;
|
|
1851
|
+
picker.style.display = "none";
|
|
1852
|
+
if (element.accept) {
|
|
1853
|
+
picker.accept = typeof element.accept === "string" ? element.accept : element.accept.extensions?.map((ext) => `.${ext}`).join(",") || "";
|
|
1854
|
+
}
|
|
1855
|
+
const fileContainer = document.createElement("div");
|
|
1856
|
+
fileContainer.className = "file-preview-container w-full aspect-square max-w-xs bg-gray-100 rounded-lg overflow-hidden relative group cursor-pointer";
|
|
1857
|
+
const initial = ctx.prefill[element.key];
|
|
1858
|
+
const fileUploadHandler = () => picker.click();
|
|
1859
|
+
const dragHandler = (files) => {
|
|
1860
|
+
if (files.length > 0) {
|
|
1861
|
+
const deps = { picker, fileUploadHandler, dragHandler };
|
|
1862
|
+
handleFileSelect(files[0], fileContainer, pathKey, state, deps, ctx.instance);
|
|
1863
|
+
}
|
|
1864
|
+
};
|
|
1865
|
+
if (initial) {
|
|
1866
|
+
handleInitialFileData(
|
|
1867
|
+
initial,
|
|
1868
|
+
fileContainer,
|
|
1869
|
+
pathKey,
|
|
1870
|
+
fileWrapper,
|
|
1871
|
+
state,
|
|
1872
|
+
{
|
|
1873
|
+
picker,
|
|
1874
|
+
fileUploadHandler,
|
|
1875
|
+
dragHandler
|
|
1876
|
+
}
|
|
1877
|
+
);
|
|
1878
|
+
} else {
|
|
1879
|
+
setEmptyFileContainer(fileContainer, state);
|
|
1880
|
+
}
|
|
1881
|
+
fileContainer.onclick = fileUploadHandler;
|
|
1882
|
+
setupDragAndDrop(fileContainer, dragHandler);
|
|
1883
|
+
picker.onchange = () => {
|
|
1884
|
+
if (picker.files && picker.files.length > 0) {
|
|
1885
|
+
const deps = { picker, fileUploadHandler, dragHandler };
|
|
1886
|
+
handleFileSelect(picker.files[0], fileContainer, pathKey, state, deps, ctx.instance);
|
|
1887
|
+
}
|
|
1888
|
+
};
|
|
1889
|
+
fileWrapper.appendChild(fileContainer);
|
|
1890
|
+
fileWrapper.appendChild(picker);
|
|
1891
|
+
const uploadText = document.createElement("p");
|
|
1892
|
+
uploadText.className = "text-xs text-gray-600 mt-2 text-center";
|
|
1893
|
+
uploadText.innerHTML = `<span class="underline cursor-pointer">${t("uploadText", state)}</span> ${t("dragDropTextSingle", state)}`;
|
|
1894
|
+
const uploadSpan = uploadText.querySelector("span");
|
|
1895
|
+
if (uploadSpan) {
|
|
1896
|
+
uploadSpan.onclick = () => picker.click();
|
|
1897
|
+
}
|
|
1898
|
+
fileWrapper.appendChild(uploadText);
|
|
1899
|
+
const fileHint = document.createElement("p");
|
|
1900
|
+
fileHint.className = "text-xs text-gray-500 mt-1 text-center";
|
|
1901
|
+
fileHint.textContent = makeFieldHint(element);
|
|
1902
|
+
fileWrapper.appendChild(fileHint);
|
|
1903
|
+
wrapper.appendChild(fileWrapper);
|
|
1904
|
+
}
|
|
1905
|
+
}
|
|
1906
|
+
function renderFilesElement(element, ctx, wrapper, pathKey) {
|
|
1907
|
+
const state = ctx.state;
|
|
1908
|
+
if (state.config.readonly) {
|
|
1909
|
+
const resultsWrapper = document.createElement("div");
|
|
1910
|
+
resultsWrapper.className = "space-y-4";
|
|
1911
|
+
const initialFiles = ctx.prefill[element.key] || [];
|
|
1912
|
+
if (initialFiles.length > 0) {
|
|
1913
|
+
initialFiles.forEach((resourceId) => {
|
|
1914
|
+
renderFilePreviewReadonly(resourceId, state).then((filePreview) => {
|
|
1915
|
+
resultsWrapper.appendChild(filePreview);
|
|
1916
|
+
}).catch((err) => {
|
|
1917
|
+
console.error("Failed to render file preview:", err);
|
|
1918
|
+
});
|
|
1919
|
+
});
|
|
1920
|
+
} else {
|
|
1921
|
+
resultsWrapper.innerHTML = `<div class="aspect-video bg-gray-100 rounded-lg flex items-center justify-center text-gray-500"><div class="text-center">${t("noFilesSelected", state)}</div></div>`;
|
|
1922
|
+
}
|
|
1923
|
+
wrapper.appendChild(resultsWrapper);
|
|
1924
|
+
} else {
|
|
1925
|
+
let updateFilesList2 = function() {
|
|
1926
|
+
renderResourcePills(list, initialFiles, state, (ridToRemove) => {
|
|
1927
|
+
const index = initialFiles.indexOf(ridToRemove);
|
|
1928
|
+
if (index > -1) {
|
|
1929
|
+
initialFiles.splice(index, 1);
|
|
1930
|
+
}
|
|
1931
|
+
updateFilesList2();
|
|
1932
|
+
});
|
|
1933
|
+
};
|
|
1934
|
+
const filesWrapper = document.createElement("div");
|
|
1935
|
+
filesWrapper.className = "space-y-2";
|
|
1936
|
+
const filesPicker = document.createElement("input");
|
|
1937
|
+
filesPicker.type = "file";
|
|
1938
|
+
filesPicker.name = pathKey;
|
|
1939
|
+
filesPicker.multiple = true;
|
|
1940
|
+
filesPicker.style.display = "none";
|
|
1941
|
+
if (element.accept) {
|
|
1942
|
+
filesPicker.accept = typeof element.accept === "string" ? element.accept : element.accept.extensions?.map((ext) => `.${ext}`).join(",") || "";
|
|
1943
|
+
}
|
|
1944
|
+
const filesContainer = document.createElement("div");
|
|
1945
|
+
filesContainer.className = "border-2 border-dashed border-gray-300 rounded-lg p-3 hover:border-gray-400 transition-colors";
|
|
1946
|
+
const list = document.createElement("div");
|
|
1947
|
+
list.className = "files-list";
|
|
1948
|
+
const initialFiles = ctx.prefill[element.key] || [];
|
|
1949
|
+
addPrefillFilesToIndex(initialFiles, state);
|
|
1950
|
+
updateFilesList2();
|
|
1951
|
+
setupFilesDropHandler(filesContainer, initialFiles, state, updateFilesList2, pathKey, ctx.instance);
|
|
1952
|
+
setupFilesPickerHandler(filesPicker, initialFiles, state, updateFilesList2, pathKey, ctx.instance);
|
|
1953
|
+
filesContainer.appendChild(list);
|
|
1954
|
+
filesWrapper.appendChild(filesContainer);
|
|
1955
|
+
filesWrapper.appendChild(filesPicker);
|
|
1956
|
+
const filesHint = document.createElement("p");
|
|
1957
|
+
filesHint.className = "text-xs text-gray-500 mt-1 text-center";
|
|
1958
|
+
filesHint.textContent = makeFieldHint(element);
|
|
1959
|
+
filesWrapper.appendChild(filesHint);
|
|
1960
|
+
wrapper.appendChild(filesWrapper);
|
|
1961
|
+
}
|
|
1962
|
+
}
|
|
1963
|
+
function renderMultipleFileElement(element, ctx, wrapper, pathKey) {
|
|
1964
|
+
const state = ctx.state;
|
|
1965
|
+
const minFiles = element.minCount ?? 0;
|
|
1966
|
+
const maxFiles = element.maxCount ?? Infinity;
|
|
1967
|
+
if (state.config.readonly) {
|
|
1968
|
+
const resultsWrapper = document.createElement("div");
|
|
1969
|
+
resultsWrapper.className = "space-y-4";
|
|
1970
|
+
const initialFiles = ctx.prefill[element.key] || [];
|
|
1971
|
+
if (initialFiles.length > 0) {
|
|
1972
|
+
initialFiles.forEach((resourceId) => {
|
|
1973
|
+
renderFilePreviewReadonly(resourceId, state).then((filePreview) => {
|
|
1974
|
+
resultsWrapper.appendChild(filePreview);
|
|
1975
|
+
}).catch((err) => {
|
|
1976
|
+
console.error("Failed to render file preview:", err);
|
|
1977
|
+
});
|
|
1978
|
+
});
|
|
1979
|
+
} else {
|
|
1980
|
+
resultsWrapper.innerHTML = `<div class="aspect-video bg-gray-100 rounded-lg flex items-center justify-center text-gray-500"><div class="text-center">${t("noFilesSelected", state)}</div></div>`;
|
|
1981
|
+
}
|
|
1982
|
+
wrapper.appendChild(resultsWrapper);
|
|
1983
|
+
} else {
|
|
1984
|
+
const filesWrapper = document.createElement("div");
|
|
1985
|
+
filesWrapper.className = "space-y-2";
|
|
1986
|
+
const filesPicker = document.createElement("input");
|
|
1987
|
+
filesPicker.type = "file";
|
|
1988
|
+
filesPicker.name = pathKey;
|
|
1989
|
+
filesPicker.multiple = true;
|
|
1990
|
+
filesPicker.style.display = "none";
|
|
1991
|
+
if (element.accept) {
|
|
1992
|
+
filesPicker.accept = typeof element.accept === "string" ? element.accept : element.accept.extensions?.map((ext) => `.${ext}`).join(",") || "";
|
|
1993
|
+
}
|
|
1994
|
+
const filesContainer = document.createElement("div");
|
|
1995
|
+
filesContainer.className = "files-list space-y-2";
|
|
1996
|
+
filesWrapper.appendChild(filesPicker);
|
|
1997
|
+
filesWrapper.appendChild(filesContainer);
|
|
1998
|
+
const initialFiles = Array.isArray(ctx.prefill[element.key]) ? [...ctx.prefill[element.key]] : [];
|
|
1999
|
+
addPrefillFilesToIndex(initialFiles, state);
|
|
2000
|
+
const updateFilesDisplay = () => {
|
|
2001
|
+
renderResourcePills(filesContainer, initialFiles, state, (index) => {
|
|
2002
|
+
initialFiles.splice(initialFiles.indexOf(index), 1);
|
|
2003
|
+
updateFilesDisplay();
|
|
2004
|
+
});
|
|
2005
|
+
const countInfo = document.createElement("div");
|
|
2006
|
+
countInfo.className = "text-xs text-gray-500 mt-2 file-count-info";
|
|
2007
|
+
const countText = `${initialFiles.length} file${initialFiles.length !== 1 ? "s" : ""}`;
|
|
2008
|
+
const minMaxText = minFiles > 0 || maxFiles < Infinity ? ` (${minFiles}-${maxFiles} allowed)` : "";
|
|
2009
|
+
countInfo.textContent = countText + minMaxText;
|
|
2010
|
+
const existingCount = filesWrapper.querySelector(".file-count-info");
|
|
2011
|
+
if (existingCount) existingCount.remove();
|
|
2012
|
+
filesWrapper.appendChild(countInfo);
|
|
2013
|
+
};
|
|
2014
|
+
setupFilesDropHandler(filesContainer, initialFiles, state, updateFilesDisplay, pathKey, ctx.instance);
|
|
2015
|
+
setupFilesPickerHandler(filesPicker, initialFiles, state, updateFilesDisplay, pathKey, ctx.instance);
|
|
2016
|
+
updateFilesDisplay();
|
|
2017
|
+
wrapper.appendChild(filesWrapper);
|
|
2018
|
+
}
|
|
2019
|
+
}
|
|
2020
|
+
function validateFileElement(element, key, context) {
|
|
2021
|
+
const errors = [];
|
|
2022
|
+
const { scopeRoot, skipValidation, path } = context;
|
|
2023
|
+
const validateFileCount = (key2, resourceIds, element2) => {
|
|
2024
|
+
if (skipValidation) return;
|
|
2025
|
+
const minFiles = "minCount" in element2 ? element2.minCount ?? 0 : 0;
|
|
2026
|
+
const maxFiles = "maxCount" in element2 ? element2.maxCount ?? Infinity : Infinity;
|
|
2027
|
+
if (element2.required && resourceIds.length === 0) {
|
|
2028
|
+
errors.push(`${key2}: required`);
|
|
2029
|
+
}
|
|
2030
|
+
if (resourceIds.length < minFiles) {
|
|
2031
|
+
errors.push(`${key2}: minimum ${minFiles} files required`);
|
|
2032
|
+
}
|
|
2033
|
+
if (resourceIds.length > maxFiles) {
|
|
2034
|
+
errors.push(`${key2}: maximum ${maxFiles} files allowed`);
|
|
2035
|
+
}
|
|
2036
|
+
};
|
|
2037
|
+
if ("multiple" in element && element.multiple) {
|
|
2038
|
+
const fullKey = pathJoin(path, key);
|
|
2039
|
+
const pickerInput = scopeRoot.querySelector(
|
|
2040
|
+
`input[type="file"][name="${fullKey}"]`
|
|
2041
|
+
);
|
|
2042
|
+
const filesWrapper = pickerInput?.closest(".space-y-2");
|
|
2043
|
+
const container = filesWrapper?.querySelector(".files-list") || null;
|
|
2044
|
+
const resourceIds = [];
|
|
2045
|
+
if (container) {
|
|
2046
|
+
const pills = container.querySelectorAll(".resource-pill");
|
|
2047
|
+
pills.forEach((pill) => {
|
|
2048
|
+
const resourceId = pill.dataset.resourceId;
|
|
2049
|
+
if (resourceId) {
|
|
2050
|
+
resourceIds.push(resourceId);
|
|
2051
|
+
}
|
|
2052
|
+
});
|
|
2053
|
+
}
|
|
2054
|
+
validateFileCount(key, resourceIds, element);
|
|
2055
|
+
return { value: resourceIds, errors };
|
|
2056
|
+
} else {
|
|
2057
|
+
const input = scopeRoot.querySelector(
|
|
2058
|
+
`input[name$="${key}"][type="hidden"]`
|
|
2059
|
+
);
|
|
2060
|
+
const rid = input?.value ?? "";
|
|
2061
|
+
if (!skipValidation && element.required && rid === "") {
|
|
2062
|
+
errors.push(`${key}: required`);
|
|
2063
|
+
return { value: null, errors };
|
|
2064
|
+
}
|
|
2065
|
+
return { value: rid || null, errors };
|
|
2066
|
+
}
|
|
2067
|
+
}
|
|
2068
|
+
function updateFileField(element, fieldPath, value, context) {
|
|
2069
|
+
const { scopeRoot, state } = context;
|
|
2070
|
+
if ("multiple" in element && element.multiple) {
|
|
2071
|
+
if (!Array.isArray(value)) {
|
|
2072
|
+
console.warn(
|
|
2073
|
+
`updateFileField: Expected array for multiple file field "${fieldPath}", got ${typeof value}`
|
|
2074
|
+
);
|
|
2075
|
+
return;
|
|
2076
|
+
}
|
|
2077
|
+
value.forEach((resourceId) => {
|
|
2078
|
+
if (resourceId && typeof resourceId === "string") {
|
|
2079
|
+
if (!state.resourceIndex.has(resourceId)) {
|
|
2080
|
+
const filename = resourceId.split("/").pop() || "file";
|
|
2081
|
+
const extension = filename.split(".").pop()?.toLowerCase();
|
|
2082
|
+
let fileType = "application/octet-stream";
|
|
2083
|
+
if (extension) {
|
|
2084
|
+
if (["jpg", "jpeg", "png", "gif", "webp"].includes(extension)) {
|
|
2085
|
+
fileType = `image/${extension === "jpg" ? "jpeg" : extension}`;
|
|
2086
|
+
} else if (["mp4", "webm", "mov", "avi"].includes(extension)) {
|
|
2087
|
+
fileType = `video/${extension === "mov" ? "quicktime" : extension}`;
|
|
2088
|
+
}
|
|
2089
|
+
}
|
|
2090
|
+
state.resourceIndex.set(resourceId, {
|
|
2091
|
+
name: filename,
|
|
2092
|
+
type: fileType,
|
|
2093
|
+
size: 0,
|
|
2094
|
+
uploadedAt: /* @__PURE__ */ new Date(),
|
|
2095
|
+
file: void 0
|
|
2096
|
+
});
|
|
2097
|
+
}
|
|
2098
|
+
}
|
|
2099
|
+
});
|
|
2100
|
+
console.info(
|
|
2101
|
+
`updateFileField: Multiple file field "${fieldPath}" updated. Preview update requires re-render.`
|
|
2102
|
+
);
|
|
2103
|
+
} else {
|
|
2104
|
+
const hiddenInput = scopeRoot.querySelector(
|
|
2105
|
+
`input[name="${fieldPath}"][type="hidden"]`
|
|
2106
|
+
);
|
|
2107
|
+
if (!hiddenInput) {
|
|
2108
|
+
console.warn(
|
|
2109
|
+
`updateFileField: Hidden input not found for file field "${fieldPath}"`
|
|
2110
|
+
);
|
|
2111
|
+
return;
|
|
2112
|
+
}
|
|
2113
|
+
hiddenInput.value = value != null ? String(value) : "";
|
|
2114
|
+
if (value && typeof value === "string") {
|
|
2115
|
+
if (!state.resourceIndex.has(value)) {
|
|
2116
|
+
const filename = value.split("/").pop() || "file";
|
|
2117
|
+
const extension = filename.split(".").pop()?.toLowerCase();
|
|
2118
|
+
let fileType = "application/octet-stream";
|
|
2119
|
+
if (extension) {
|
|
2120
|
+
if (["jpg", "jpeg", "png", "gif", "webp"].includes(extension)) {
|
|
2121
|
+
fileType = `image/${extension === "jpg" ? "jpeg" : extension}`;
|
|
2122
|
+
} else if (["mp4", "webm", "mov", "avi"].includes(extension)) {
|
|
2123
|
+
fileType = `video/${extension === "mov" ? "quicktime" : extension}`;
|
|
2124
|
+
}
|
|
2125
|
+
}
|
|
2126
|
+
state.resourceIndex.set(value, {
|
|
2127
|
+
name: filename,
|
|
2128
|
+
type: fileType,
|
|
2129
|
+
size: 0,
|
|
2130
|
+
uploadedAt: /* @__PURE__ */ new Date(),
|
|
2131
|
+
file: void 0
|
|
2132
|
+
});
|
|
2133
|
+
}
|
|
2134
|
+
console.info(
|
|
2135
|
+
`updateFileField: File field "${fieldPath}" updated. Preview update requires re-render.`
|
|
2136
|
+
);
|
|
2137
|
+
}
|
|
2138
|
+
}
|
|
2139
|
+
}
|
|
2140
|
+
|
|
2141
|
+
// src/components/container.ts
|
|
2142
|
+
var renderElementFunc = null;
|
|
2143
|
+
function setRenderElement(fn) {
|
|
2144
|
+
renderElementFunc = fn;
|
|
2145
|
+
}
|
|
2146
|
+
function renderElement(element, ctx) {
|
|
2147
|
+
if (!renderElementFunc) {
|
|
2148
|
+
throw new Error(
|
|
2149
|
+
"renderElement not initialized. Import from components/index.ts"
|
|
2150
|
+
);
|
|
2151
|
+
}
|
|
2152
|
+
return renderElementFunc(element, ctx);
|
|
2153
|
+
}
|
|
2154
|
+
function renderSingleContainerElement(element, ctx, wrapper, pathKey) {
|
|
2155
|
+
const containerWrap = document.createElement("div");
|
|
2156
|
+
containerWrap.className = "border border-gray-200 rounded-lg p-4 bg-gray-50";
|
|
2157
|
+
containerWrap.setAttribute("data-container", pathKey);
|
|
2158
|
+
const header = document.createElement("div");
|
|
2159
|
+
header.className = "flex justify-between items-center mb-4";
|
|
2160
|
+
const left = document.createElement("div");
|
|
2161
|
+
left.className = "flex-1";
|
|
2162
|
+
const itemsWrap = document.createElement("div");
|
|
2163
|
+
itemsWrap.className = "space-y-4";
|
|
2164
|
+
containerWrap.appendChild(header);
|
|
2165
|
+
header.appendChild(left);
|
|
2166
|
+
const subCtx = {
|
|
2167
|
+
path: pathJoin(ctx.path, element.key),
|
|
2168
|
+
prefill: ctx.prefill?.[element.key] || {},
|
|
2169
|
+
state: ctx.state
|
|
2170
|
+
};
|
|
2171
|
+
element.elements.forEach((child) => {
|
|
2172
|
+
if (!child.hidden) {
|
|
2173
|
+
itemsWrap.appendChild(renderElement(child, subCtx));
|
|
2174
|
+
}
|
|
2175
|
+
});
|
|
2176
|
+
containerWrap.appendChild(itemsWrap);
|
|
2177
|
+
left.innerHTML = `<span>${element.label || element.key}</span>`;
|
|
2178
|
+
wrapper.appendChild(containerWrap);
|
|
2179
|
+
}
|
|
2180
|
+
function renderMultipleContainerElement(element, ctx, wrapper, _pathKey) {
|
|
2181
|
+
const state = ctx.state;
|
|
2182
|
+
const containerWrap = document.createElement("div");
|
|
2183
|
+
containerWrap.className = "border border-gray-200 rounded-lg p-4 bg-gray-50";
|
|
2184
|
+
const header = document.createElement("div");
|
|
2185
|
+
header.className = "flex justify-between items-center mb-4";
|
|
2186
|
+
const left = document.createElement("div");
|
|
2187
|
+
left.className = "flex-1";
|
|
2188
|
+
const right = document.createElement("div");
|
|
2189
|
+
right.className = "flex gap-2";
|
|
2190
|
+
const itemsWrap = document.createElement("div");
|
|
2191
|
+
itemsWrap.className = "space-y-4";
|
|
2192
|
+
containerWrap.appendChild(header);
|
|
2193
|
+
header.appendChild(left);
|
|
2194
|
+
if (!state.config.readonly) {
|
|
2195
|
+
header.appendChild(right);
|
|
2196
|
+
}
|
|
2197
|
+
const min = element.minCount ?? 0;
|
|
2198
|
+
const max = element.maxCount ?? Infinity;
|
|
2199
|
+
const pre = Array.isArray(ctx.prefill?.[element.key]) ? ctx.prefill[element.key] : null;
|
|
2200
|
+
const countItems = () => itemsWrap.querySelectorAll(":scope > .containerItem").length;
|
|
2201
|
+
const createAddButton = () => {
|
|
2202
|
+
const add = document.createElement("button");
|
|
2203
|
+
add.type = "button";
|
|
2204
|
+
add.className = "px-3 py-1.5 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition-colors";
|
|
2205
|
+
add.textContent = t("addElement", state);
|
|
2206
|
+
add.onclick = () => {
|
|
2207
|
+
if (countItems() < max) {
|
|
2208
|
+
const idx = countItems();
|
|
2209
|
+
const subCtx = {
|
|
2210
|
+
state: ctx.state,
|
|
2211
|
+
path: pathJoin(ctx.path, `${element.key}[${idx}]`),
|
|
2212
|
+
prefill: {}
|
|
2213
|
+
};
|
|
2214
|
+
const item = document.createElement("div");
|
|
2215
|
+
item.className = "containerItem border border-gray-300 rounded-lg p-4 bg-white";
|
|
2216
|
+
item.setAttribute("data-container-item", `${element.key}[${idx}]`);
|
|
2217
|
+
element.elements.forEach((child) => {
|
|
2218
|
+
if (!child.hidden) {
|
|
2219
|
+
item.appendChild(renderElement(child, subCtx));
|
|
2220
|
+
}
|
|
2221
|
+
});
|
|
2222
|
+
if (!state.config.readonly) {
|
|
2223
|
+
const rem = document.createElement("button");
|
|
2224
|
+
rem.type = "button";
|
|
2225
|
+
rem.className = "absolute top-2 right-2 w-6 h-6 bg-red-500 text-white rounded-full text-xs hover:bg-red-600 transition-colors";
|
|
2226
|
+
rem.textContent = "\xD7";
|
|
2227
|
+
rem.onclick = () => {
|
|
2228
|
+
item.remove();
|
|
2229
|
+
updateAddButton();
|
|
2230
|
+
};
|
|
2231
|
+
item.style.position = "relative";
|
|
2232
|
+
item.appendChild(rem);
|
|
2233
|
+
}
|
|
2234
|
+
itemsWrap.appendChild(item);
|
|
2235
|
+
updateAddButton();
|
|
2236
|
+
}
|
|
2237
|
+
};
|
|
2238
|
+
return add;
|
|
2239
|
+
};
|
|
2240
|
+
const updateAddButton = () => {
|
|
2241
|
+
const currentCount = countItems();
|
|
2242
|
+
const addBtn = right.querySelector("button");
|
|
2243
|
+
if (addBtn) {
|
|
2244
|
+
addBtn.disabled = currentCount >= max;
|
|
2245
|
+
addBtn.style.opacity = currentCount >= max ? "0.5" : "1";
|
|
2246
|
+
}
|
|
2247
|
+
left.innerHTML = `<span>${element.label || element.key}</span> <span class="text-sm text-gray-500">(${currentCount}/${max === Infinity ? "\u221E" : max})</span>`;
|
|
2248
|
+
};
|
|
2249
|
+
if (!state.config.readonly) {
|
|
2250
|
+
right.appendChild(createAddButton());
|
|
2251
|
+
}
|
|
2252
|
+
if (pre && Array.isArray(pre)) {
|
|
2253
|
+
pre.forEach((prefillObj, idx) => {
|
|
2254
|
+
const subCtx = {
|
|
2255
|
+
state: ctx.state,
|
|
2256
|
+
path: pathJoin(ctx.path, `${element.key}[${idx}]`),
|
|
2257
|
+
prefill: prefillObj || {}
|
|
2258
|
+
};
|
|
2259
|
+
const item = document.createElement("div");
|
|
2260
|
+
item.className = "containerItem border border-gray-300 rounded-lg p-4 bg-white";
|
|
2261
|
+
item.setAttribute("data-container-item", `${element.key}[${idx}]`);
|
|
2262
|
+
element.elements.forEach((child) => {
|
|
2263
|
+
if (!child.hidden) {
|
|
2264
|
+
item.appendChild(renderElement(child, subCtx));
|
|
2265
|
+
}
|
|
2266
|
+
});
|
|
2267
|
+
if (!state.config.readonly) {
|
|
2268
|
+
const rem = document.createElement("button");
|
|
2269
|
+
rem.type = "button";
|
|
2270
|
+
rem.className = "absolute top-2 right-2 w-6 h-6 bg-red-500 text-white rounded-full text-xs hover:bg-red-600 transition-colors";
|
|
2271
|
+
rem.textContent = "\xD7";
|
|
2272
|
+
rem.onclick = () => {
|
|
2273
|
+
item.remove();
|
|
2274
|
+
updateAddButton();
|
|
2275
|
+
};
|
|
2276
|
+
item.style.position = "relative";
|
|
2277
|
+
item.appendChild(rem);
|
|
2278
|
+
}
|
|
2279
|
+
itemsWrap.appendChild(item);
|
|
2280
|
+
});
|
|
2281
|
+
}
|
|
2282
|
+
if (!state.config.readonly) {
|
|
2283
|
+
while (countItems() < min) {
|
|
2284
|
+
const idx = countItems();
|
|
2285
|
+
const subCtx = {
|
|
2286
|
+
state: ctx.state,
|
|
2287
|
+
path: pathJoin(ctx.path, `${element.key}[${idx}]`),
|
|
2288
|
+
prefill: {}
|
|
2289
|
+
};
|
|
2290
|
+
const item = document.createElement("div");
|
|
2291
|
+
item.className = "containerItem border border-gray-300 rounded-lg p-4 bg-white";
|
|
2292
|
+
item.setAttribute("data-container-item", `${element.key}[${idx}]`);
|
|
2293
|
+
element.elements.forEach((child) => {
|
|
2294
|
+
if (!child.hidden) {
|
|
2295
|
+
item.appendChild(renderElement(child, subCtx));
|
|
2296
|
+
}
|
|
2297
|
+
});
|
|
2298
|
+
const rem = document.createElement("button");
|
|
2299
|
+
rem.type = "button";
|
|
2300
|
+
rem.className = "absolute top-2 right-2 w-6 h-6 bg-red-500 text-white rounded-full text-xs hover:bg-red-600 transition-colors";
|
|
2301
|
+
rem.textContent = "\xD7";
|
|
2302
|
+
rem.onclick = () => {
|
|
2303
|
+
if (countItems() > min) {
|
|
2304
|
+
item.remove();
|
|
2305
|
+
updateAddButton();
|
|
2306
|
+
}
|
|
2307
|
+
};
|
|
2308
|
+
item.style.position = "relative";
|
|
2309
|
+
item.appendChild(rem);
|
|
2310
|
+
itemsWrap.appendChild(item);
|
|
2311
|
+
}
|
|
2312
|
+
}
|
|
2313
|
+
containerWrap.appendChild(itemsWrap);
|
|
2314
|
+
updateAddButton();
|
|
2315
|
+
wrapper.appendChild(containerWrap);
|
|
2316
|
+
}
|
|
2317
|
+
var validateElementFunc = null;
|
|
2318
|
+
function setValidateElement(fn) {
|
|
2319
|
+
validateElementFunc = fn;
|
|
2320
|
+
}
|
|
2321
|
+
function validateElement(element, ctx, customScopeRoot) {
|
|
2322
|
+
if (!validateElementFunc) {
|
|
2323
|
+
throw new Error(
|
|
2324
|
+
"validateElement not initialized. Should be set from FormBuilderInstance"
|
|
2325
|
+
);
|
|
2326
|
+
}
|
|
2327
|
+
return validateElementFunc(element, ctx, customScopeRoot);
|
|
2328
|
+
}
|
|
2329
|
+
function validateContainerElement(element, key, context) {
|
|
2330
|
+
const errors = [];
|
|
2331
|
+
const { scopeRoot, skipValidation, path } = context;
|
|
2332
|
+
if (!("elements" in element)) {
|
|
2333
|
+
return { value: null, errors };
|
|
2334
|
+
}
|
|
2335
|
+
const validateContainerCount = (key2, items, element2) => {
|
|
2336
|
+
if (skipValidation) return;
|
|
2337
|
+
const minItems = "minCount" in element2 ? element2.minCount ?? 0 : 0;
|
|
2338
|
+
const maxItems = "maxCount" in element2 ? element2.maxCount ?? Infinity : Infinity;
|
|
2339
|
+
if (element2.required && items.length === 0) {
|
|
2340
|
+
errors.push(`${key2}: required`);
|
|
2341
|
+
}
|
|
2342
|
+
if (items.length < minItems) {
|
|
2343
|
+
errors.push(`${key2}: minimum ${minItems} items required`);
|
|
2344
|
+
}
|
|
2345
|
+
if (items.length > maxItems) {
|
|
2346
|
+
errors.push(`${key2}: maximum ${maxItems} items allowed`);
|
|
2347
|
+
}
|
|
2348
|
+
};
|
|
2349
|
+
if ("multiple" in element && element.multiple) {
|
|
2350
|
+
const items = [];
|
|
2351
|
+
const allContainerWrappers = scopeRoot.querySelectorAll(
|
|
2352
|
+
"[data-container-item]"
|
|
2353
|
+
);
|
|
2354
|
+
const containerWrappers = Array.from(allContainerWrappers).filter((el) => {
|
|
2355
|
+
const attr = el.getAttribute("data-container-item");
|
|
2356
|
+
return attr?.startsWith(`${key}[`);
|
|
2357
|
+
});
|
|
2358
|
+
const itemCount = containerWrappers.length;
|
|
2359
|
+
for (let i = 0; i < itemCount; i++) {
|
|
2360
|
+
const itemData = {};
|
|
2361
|
+
const itemContainer = scopeRoot.querySelector(
|
|
2362
|
+
`[data-container-item="${key}[${i}]"]`
|
|
2363
|
+
) || scopeRoot;
|
|
2364
|
+
element.elements.forEach((child) => {
|
|
2365
|
+
if (child.hidden || child.type === "hidden") {
|
|
2366
|
+
itemData[child.key] = child.default !== void 0 ? child.default : null;
|
|
2367
|
+
} else {
|
|
2368
|
+
const childKey = `${key}[${i}].${child.key}`;
|
|
2369
|
+
itemData[child.key] = validateElement(
|
|
2370
|
+
{ ...child, key: childKey },
|
|
2371
|
+
{ path },
|
|
2372
|
+
itemContainer
|
|
2373
|
+
);
|
|
2374
|
+
}
|
|
2375
|
+
});
|
|
2376
|
+
items.push(itemData);
|
|
2377
|
+
}
|
|
2378
|
+
validateContainerCount(key, items, element);
|
|
2379
|
+
return { value: items, errors };
|
|
2380
|
+
} else {
|
|
2381
|
+
const containerData = {};
|
|
2382
|
+
const containerContainer = scopeRoot.querySelector(`[data-container="${key}"]`) || scopeRoot;
|
|
2383
|
+
element.elements.forEach((child) => {
|
|
2384
|
+
if (child.hidden || child.type === "hidden") {
|
|
2385
|
+
containerData[child.key] = child.default !== void 0 ? child.default : null;
|
|
2386
|
+
} else {
|
|
2387
|
+
const childKey = `${key}.${child.key}`;
|
|
2388
|
+
containerData[child.key] = validateElement(
|
|
2389
|
+
{ ...child, key: childKey },
|
|
2390
|
+
{ path },
|
|
2391
|
+
containerContainer
|
|
2392
|
+
);
|
|
2393
|
+
}
|
|
2394
|
+
});
|
|
2395
|
+
return { value: containerData, errors };
|
|
2396
|
+
}
|
|
2397
|
+
}
|
|
2398
|
+
function updateContainerField(element, fieldPath, value, context) {
|
|
2399
|
+
const { instance, scopeRoot } = context;
|
|
2400
|
+
if (!("elements" in element)) {
|
|
2401
|
+
return;
|
|
2402
|
+
}
|
|
2403
|
+
if ("multiple" in element && element.multiple) {
|
|
2404
|
+
if (!Array.isArray(value)) {
|
|
2405
|
+
console.warn(
|
|
2406
|
+
`updateContainerField: Expected array for multiple container field "${fieldPath}", got ${typeof value}`
|
|
2407
|
+
);
|
|
2408
|
+
return;
|
|
2409
|
+
}
|
|
2410
|
+
value.forEach((itemValue, index) => {
|
|
2411
|
+
if (isPlainObject(itemValue)) {
|
|
2412
|
+
element.elements.forEach((childElement) => {
|
|
2413
|
+
const childKey = childElement.key;
|
|
2414
|
+
const childPath = `${fieldPath}[${index}].${childKey}`;
|
|
2415
|
+
const childValue = itemValue[childKey];
|
|
2416
|
+
if (childValue !== void 0) {
|
|
2417
|
+
instance.updateField(childPath, childValue);
|
|
2418
|
+
}
|
|
2419
|
+
});
|
|
2420
|
+
}
|
|
2421
|
+
});
|
|
2422
|
+
const existingContainers = scopeRoot.querySelectorAll(
|
|
2423
|
+
`[data-container-item^="${fieldPath}["]`
|
|
2424
|
+
);
|
|
2425
|
+
if (value.length !== existingContainers.length) {
|
|
2426
|
+
console.warn(
|
|
2427
|
+
`updateContainerField: Multiple container field "${fieldPath}" item count mismatch. Consider re-rendering for add/remove.`
|
|
2428
|
+
);
|
|
2429
|
+
}
|
|
2430
|
+
} else {
|
|
2431
|
+
if (!isPlainObject(value)) {
|
|
2432
|
+
console.warn(
|
|
2433
|
+
`updateContainerField: Expected object for container field "${fieldPath}", got ${typeof value}`
|
|
2434
|
+
);
|
|
2435
|
+
return;
|
|
2436
|
+
}
|
|
2437
|
+
element.elements.forEach((childElement) => {
|
|
2438
|
+
const childKey = childElement.key;
|
|
2439
|
+
const childPath = `${fieldPath}.${childKey}`;
|
|
2440
|
+
const childValue = value[childKey];
|
|
2441
|
+
if (childValue !== void 0) {
|
|
2442
|
+
instance.updateField(childPath, childValue);
|
|
2443
|
+
}
|
|
2444
|
+
});
|
|
2445
|
+
}
|
|
2446
|
+
}
|
|
2447
|
+
function renderGroupElement(element, ctx, wrapper, pathKey) {
|
|
2448
|
+
if (typeof console !== "undefined" && console.warn) {
|
|
2449
|
+
console.warn(
|
|
2450
|
+
`[Form Builder] The "group" field type is deprecated and will be removed in a future version. Please use type: "container" with multiple: true instead. Field key: "${element.key}"`
|
|
2451
|
+
);
|
|
2452
|
+
}
|
|
2453
|
+
const containerElement = {
|
|
2454
|
+
key: element.key,
|
|
2455
|
+
label: element.label,
|
|
2456
|
+
description: element.description,
|
|
2457
|
+
hint: element.hint,
|
|
2458
|
+
required: element.required,
|
|
2459
|
+
hidden: element.hidden,
|
|
2460
|
+
default: element.default,
|
|
2461
|
+
actions: element.actions,
|
|
2462
|
+
elements: element.elements,
|
|
2463
|
+
// Translate repeat pattern to multiple pattern
|
|
2464
|
+
multiple: !!(element.repeat && isPlainObject(element.repeat)),
|
|
2465
|
+
minCount: element.repeat?.min,
|
|
2466
|
+
maxCount: element.repeat?.max
|
|
2467
|
+
};
|
|
2468
|
+
if (containerElement.multiple) {
|
|
2469
|
+
renderMultipleContainerElement(containerElement, ctx, wrapper);
|
|
2470
|
+
} else {
|
|
2471
|
+
renderSingleContainerElement(containerElement, ctx, wrapper, pathKey);
|
|
2472
|
+
}
|
|
2473
|
+
}
|
|
2474
|
+
function translateGroupToContainer(element) {
|
|
2475
|
+
const groupElement = element;
|
|
2476
|
+
return {
|
|
2477
|
+
type: "container",
|
|
2478
|
+
key: groupElement.key,
|
|
2479
|
+
label: groupElement.label,
|
|
2480
|
+
description: groupElement.description,
|
|
2481
|
+
hint: groupElement.hint,
|
|
2482
|
+
required: groupElement.required,
|
|
2483
|
+
hidden: groupElement.hidden,
|
|
2484
|
+
default: groupElement.default,
|
|
2485
|
+
actions: groupElement.actions,
|
|
2486
|
+
elements: groupElement.elements,
|
|
2487
|
+
// Translate repeat pattern to multiple pattern
|
|
2488
|
+
multiple: !!(groupElement.repeat && isPlainObject(groupElement.repeat)),
|
|
2489
|
+
minCount: groupElement.repeat?.min,
|
|
2490
|
+
maxCount: groupElement.repeat?.max
|
|
2491
|
+
};
|
|
2492
|
+
}
|
|
2493
|
+
function validateGroupElement(element, key, context) {
|
|
2494
|
+
if (typeof console !== "undefined" && console.warn) {
|
|
2495
|
+
console.warn(
|
|
2496
|
+
`[Form Builder] The "group" field type is deprecated. Please use type: "container" instead. Field key: "${key}"`
|
|
2497
|
+
);
|
|
2498
|
+
}
|
|
2499
|
+
const containerElement = translateGroupToContainer(element);
|
|
2500
|
+
return validateContainerElement(containerElement, key, context);
|
|
2501
|
+
}
|
|
2502
|
+
function updateGroupField(element, fieldPath, value, context) {
|
|
2503
|
+
if (typeof console !== "undefined" && console.warn) {
|
|
2504
|
+
console.warn(
|
|
2505
|
+
`[Form Builder] The "group" field type is deprecated. Please use type: "container" instead. Field path: "${fieldPath}"`
|
|
2506
|
+
);
|
|
2507
|
+
}
|
|
2508
|
+
const containerElement = translateGroupToContainer(element);
|
|
2509
|
+
return updateContainerField(containerElement, fieldPath, value, context);
|
|
2510
|
+
}
|
|
2511
|
+
|
|
2512
|
+
// src/components/index.ts
|
|
2513
|
+
function showTooltip(tooltipId, button) {
|
|
2514
|
+
const tooltip = document.getElementById(tooltipId);
|
|
2515
|
+
if (!tooltip) return;
|
|
2516
|
+
const isCurrentlyVisible = !tooltip.classList.contains("hidden");
|
|
2517
|
+
document.querySelectorAll('[id^="tooltip-"]').forEach((t2) => {
|
|
2518
|
+
t2.classList.add("hidden");
|
|
2519
|
+
});
|
|
2520
|
+
if (isCurrentlyVisible) {
|
|
2521
|
+
return;
|
|
2522
|
+
}
|
|
2523
|
+
const rect = button.getBoundingClientRect();
|
|
2524
|
+
const viewportWidth = window.innerWidth;
|
|
2525
|
+
const viewportHeight = window.innerHeight;
|
|
2526
|
+
if (tooltip && tooltip.parentElement !== document.body) {
|
|
2527
|
+
document.body.appendChild(tooltip);
|
|
2528
|
+
}
|
|
2529
|
+
tooltip.style.visibility = "hidden";
|
|
2530
|
+
tooltip.style.position = "fixed";
|
|
2531
|
+
tooltip.classList.remove("hidden");
|
|
2532
|
+
const tooltipRect = tooltip.getBoundingClientRect();
|
|
2533
|
+
tooltip.classList.add("hidden");
|
|
2534
|
+
tooltip.style.visibility = "visible";
|
|
2535
|
+
let left = rect.left;
|
|
2536
|
+
let top = rect.bottom + 5;
|
|
2537
|
+
if (left + tooltipRect.width > viewportWidth) {
|
|
2538
|
+
left = rect.right - tooltipRect.width;
|
|
2539
|
+
}
|
|
2540
|
+
if (top + tooltipRect.height > viewportHeight) {
|
|
2541
|
+
top = rect.top - tooltipRect.height - 5;
|
|
2542
|
+
}
|
|
2543
|
+
if (left < 10) {
|
|
2544
|
+
left = 10;
|
|
2545
|
+
}
|
|
2546
|
+
if (top < 10) {
|
|
2547
|
+
top = rect.bottom + 5;
|
|
2548
|
+
}
|
|
2549
|
+
tooltip.style.left = `${left}px`;
|
|
2550
|
+
tooltip.style.top = `${top}px`;
|
|
2551
|
+
tooltip.classList.remove("hidden");
|
|
2552
|
+
setTimeout(() => {
|
|
2553
|
+
tooltip.classList.add("hidden");
|
|
2554
|
+
}, 25e3);
|
|
2555
|
+
}
|
|
2556
|
+
if (typeof document !== "undefined") {
|
|
2557
|
+
document.addEventListener("click", (e) => {
|
|
2558
|
+
const target = e.target;
|
|
2559
|
+
const isInfoButton = target.closest("button") && target.closest("button").onclick;
|
|
2560
|
+
const isTooltip = target.closest('[id^="tooltip-"]');
|
|
2561
|
+
if (!isInfoButton && !isTooltip) {
|
|
2562
|
+
document.querySelectorAll('[id^="tooltip-"]').forEach((tooltip) => {
|
|
2563
|
+
tooltip.classList.add("hidden");
|
|
2564
|
+
});
|
|
2565
|
+
}
|
|
2566
|
+
});
|
|
2567
|
+
}
|
|
2568
|
+
function renderElement2(element, ctx) {
|
|
2569
|
+
const wrapper = document.createElement("div");
|
|
2570
|
+
wrapper.className = "mb-6 fb-field-wrapper";
|
|
2571
|
+
const label = document.createElement("div");
|
|
2572
|
+
label.className = "flex items-center mb-2";
|
|
2573
|
+
const title = document.createElement("label");
|
|
2574
|
+
title.className = "text-sm font-medium text-gray-900";
|
|
2575
|
+
title.textContent = element.label || element.key;
|
|
2576
|
+
if (element.required) {
|
|
2577
|
+
const req = document.createElement("span");
|
|
2578
|
+
req.className = "text-red-500 ml-1";
|
|
2579
|
+
req.textContent = "*";
|
|
2580
|
+
title.appendChild(req);
|
|
2581
|
+
}
|
|
2582
|
+
label.appendChild(title);
|
|
2583
|
+
if (element.description || element.hint) {
|
|
2584
|
+
const infoBtn = document.createElement("button");
|
|
2585
|
+
infoBtn.type = "button";
|
|
2586
|
+
infoBtn.className = "ml-2 text-gray-400 hover:text-gray-600";
|
|
2587
|
+
infoBtn.innerHTML = '<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"/></svg>';
|
|
2588
|
+
const tooltipId = `tooltip-${element.key}-${Math.random().toString(36).substr(2, 9)}`;
|
|
2589
|
+
const tooltip = document.createElement("div");
|
|
2590
|
+
tooltip.id = tooltipId;
|
|
2591
|
+
tooltip.className = "hidden absolute z-50 bg-gray-200 text-gray-900 text-sm rounded-lg p-3 max-w-sm border border-gray-300 shadow-lg";
|
|
2592
|
+
tooltip.style.position = "fixed";
|
|
2593
|
+
tooltip.textContent = element.description || element.hint || "Field information";
|
|
2594
|
+
document.body.appendChild(tooltip);
|
|
2595
|
+
infoBtn.onclick = (e) => {
|
|
2596
|
+
e.preventDefault();
|
|
2597
|
+
e.stopPropagation();
|
|
2598
|
+
showTooltip(tooltipId, infoBtn);
|
|
2599
|
+
};
|
|
2600
|
+
label.appendChild(infoBtn);
|
|
2601
|
+
}
|
|
2602
|
+
wrapper.appendChild(label);
|
|
2603
|
+
const pathKey = pathJoin(ctx.path, element.key);
|
|
2604
|
+
switch (element.type) {
|
|
2605
|
+
case "text":
|
|
2606
|
+
if ("multiple" in element && element.multiple) {
|
|
2607
|
+
renderMultipleTextElement(element, ctx, wrapper, pathKey);
|
|
2608
|
+
} else {
|
|
2609
|
+
renderTextElement(element, ctx, wrapper, pathKey);
|
|
2610
|
+
}
|
|
2611
|
+
break;
|
|
2612
|
+
case "textarea":
|
|
2613
|
+
if ("multiple" in element && element.multiple) {
|
|
2614
|
+
renderMultipleTextareaElement(element, ctx, wrapper, pathKey);
|
|
2615
|
+
} else {
|
|
2616
|
+
renderTextareaElement(element, ctx, wrapper, pathKey);
|
|
2617
|
+
}
|
|
2618
|
+
break;
|
|
2619
|
+
case "number":
|
|
2620
|
+
if ("multiple" in element && element.multiple) {
|
|
2621
|
+
renderMultipleNumberElement(element, ctx, wrapper, pathKey);
|
|
2622
|
+
} else {
|
|
2623
|
+
renderNumberElement(element, ctx, wrapper, pathKey);
|
|
2624
|
+
}
|
|
2625
|
+
break;
|
|
2626
|
+
case "select":
|
|
2627
|
+
if ("multiple" in element && element.multiple) {
|
|
2628
|
+
renderMultipleSelectElement(element, ctx, wrapper, pathKey);
|
|
2629
|
+
} else {
|
|
2630
|
+
renderSelectElement(element, ctx, wrapper, pathKey);
|
|
2631
|
+
}
|
|
2632
|
+
break;
|
|
2633
|
+
case "file":
|
|
2634
|
+
if ("multiple" in element && element.multiple) {
|
|
2635
|
+
renderMultipleFileElement(element, ctx, wrapper, pathKey);
|
|
2636
|
+
} else {
|
|
2637
|
+
renderFileElement(element, ctx, wrapper, pathKey);
|
|
2638
|
+
}
|
|
2639
|
+
break;
|
|
2640
|
+
case "files":
|
|
2641
|
+
renderFilesElement(element, ctx, wrapper, pathKey);
|
|
2642
|
+
break;
|
|
2643
|
+
case "group":
|
|
2644
|
+
renderGroupElement(element, ctx, wrapper, pathKey);
|
|
2645
|
+
break;
|
|
2646
|
+
case "container":
|
|
2647
|
+
if ("multiple" in element && element.multiple) {
|
|
2648
|
+
renderMultipleContainerElement(element, ctx, wrapper);
|
|
2649
|
+
} else {
|
|
2650
|
+
renderSingleContainerElement(element, ctx, wrapper, pathKey);
|
|
2651
|
+
}
|
|
2652
|
+
break;
|
|
2653
|
+
default: {
|
|
2654
|
+
const unsupported = document.createElement("div");
|
|
2655
|
+
unsupported.className = "text-red-500 text-sm";
|
|
2656
|
+
unsupported.textContent = `Unsupported field type: ${element.type}`;
|
|
2657
|
+
wrapper.appendChild(unsupported);
|
|
2658
|
+
}
|
|
2659
|
+
}
|
|
2660
|
+
return wrapper;
|
|
2661
|
+
}
|
|
2662
|
+
setRenderElement(renderElement2);
|
|
2663
|
+
|
|
2664
|
+
// src/instance/state.ts
|
|
2665
|
+
var defaultConfig = {
|
|
2666
|
+
uploadFile: null,
|
|
2667
|
+
downloadFile: null,
|
|
2668
|
+
getThumbnail: null,
|
|
2669
|
+
getDownloadUrl: null,
|
|
2670
|
+
actionHandler: null,
|
|
2671
|
+
onChange: null,
|
|
2672
|
+
onFieldChange: null,
|
|
2673
|
+
debounceMs: 300,
|
|
2674
|
+
enableFilePreview: true,
|
|
2675
|
+
maxPreviewSize: "200px",
|
|
2676
|
+
readonly: false,
|
|
2677
|
+
locale: "en",
|
|
2678
|
+
translations: {
|
|
2679
|
+
en: {
|
|
2680
|
+
addElement: "Add Element",
|
|
2681
|
+
removeElement: "Remove",
|
|
2682
|
+
uploadText: "Upload",
|
|
2683
|
+
dragDropText: "or drag and drop files",
|
|
2684
|
+
dragDropTextSingle: "or drag and drop file",
|
|
2685
|
+
clickDragText: "Click or drag file",
|
|
2686
|
+
noFileSelected: "No file selected",
|
|
2687
|
+
noFilesSelected: "No files selected",
|
|
2688
|
+
downloadButton: "Download"
|
|
2689
|
+
},
|
|
2690
|
+
ru: {
|
|
2691
|
+
addElement: "\u0414\u043E\u0431\u0430\u0432\u0438\u0442\u044C \u044D\u043B\u0435\u043C\u0435\u043D\u0442",
|
|
2692
|
+
removeElement: "\u0423\u0434\u0430\u043B\u0438\u0442\u044C",
|
|
2693
|
+
uploadText: "\u0417\u0430\u0433\u0440\u0443\u0437\u0438\u0442\u0435",
|
|
2694
|
+
dragDropText: "\u0438\u043B\u0438 \u043F\u0435\u0440\u0435\u0442\u0430\u0449\u0438\u0442\u0435 \u0444\u0430\u0439\u043B\u044B",
|
|
2695
|
+
dragDropTextSingle: "\u0438\u043B\u0438 \u043F\u0435\u0440\u0435\u0442\u0430\u0449\u0438\u0442\u0435 \u0444\u0430\u0439\u043B",
|
|
2696
|
+
clickDragText: "\u041D\u0430\u0436\u043C\u0438\u0442\u0435 \u0438\u043B\u0438 \u043F\u0435\u0440\u0435\u0442\u0430\u0449\u0438\u0442\u0435 \u0444\u0430\u0439\u043B",
|
|
2697
|
+
noFileSelected: "\u0424\u0430\u0439\u043B \u043D\u0435 \u0432\u044B\u0431\u0440\u0430\u043D",
|
|
2698
|
+
noFilesSelected: "\u041D\u0435\u0442 \u0444\u0430\u0439\u043B\u043E\u0432",
|
|
2699
|
+
downloadButton: "\u0421\u043A\u0430\u0447\u0430\u0442\u044C"
|
|
2700
|
+
}
|
|
2701
|
+
},
|
|
2702
|
+
theme: {}
|
|
2703
|
+
};
|
|
2704
|
+
function createInstanceState(config) {
|
|
2705
|
+
return {
|
|
2706
|
+
schema: null,
|
|
2707
|
+
formRoot: null,
|
|
2708
|
+
resourceIndex: /* @__PURE__ */ new Map(),
|
|
2709
|
+
externalActions: null,
|
|
2710
|
+
version: "1.0.0",
|
|
2711
|
+
config: {
|
|
2712
|
+
...defaultConfig,
|
|
2713
|
+
...config
|
|
2714
|
+
},
|
|
2715
|
+
debounceTimer: null
|
|
2716
|
+
};
|
|
2717
|
+
}
|
|
2718
|
+
function generateInstanceId() {
|
|
2719
|
+
const timestamp = Date.now().toString(36);
|
|
2720
|
+
const random = Math.random().toString(36).substring(2, 9);
|
|
2721
|
+
return `inst-${timestamp}-${random}`;
|
|
2722
|
+
}
|
|
2723
|
+
|
|
2724
|
+
// src/styles/theme.ts
|
|
2725
|
+
var defaultTheme = {
|
|
2726
|
+
// Colors - matching Tailwind defaults
|
|
2727
|
+
primaryColor: "#3b82f6",
|
|
2728
|
+
// blue-500
|
|
2729
|
+
primaryHoverColor: "#2563eb",
|
|
2730
|
+
// blue-600
|
|
2731
|
+
errorColor: "#ef4444",
|
|
2732
|
+
// red-500
|
|
2733
|
+
errorHoverColor: "#dc2626",
|
|
2734
|
+
// red-600
|
|
2735
|
+
successColor: "#10b981",
|
|
2736
|
+
// green-500
|
|
2737
|
+
borderColor: "#d1d5db",
|
|
2738
|
+
// gray-300
|
|
2739
|
+
borderHoverColor: "#9ca3af",
|
|
2740
|
+
// gray-400
|
|
2741
|
+
borderFocusColor: "#3b82f6",
|
|
2742
|
+
// blue-500
|
|
2743
|
+
backgroundColor: "#ffffff",
|
|
2744
|
+
// white
|
|
2745
|
+
backgroundHoverColor: "#f9fafb",
|
|
2746
|
+
// gray-50
|
|
2747
|
+
backgroundReadonlyColor: "#f3f4f6",
|
|
2748
|
+
// gray-100
|
|
2749
|
+
textColor: "#1f2937",
|
|
2750
|
+
// gray-800
|
|
2751
|
+
textSecondaryColor: "#6b7280",
|
|
2752
|
+
// gray-500
|
|
2753
|
+
textPlaceholderColor: "#9ca3af",
|
|
2754
|
+
// gray-400
|
|
2755
|
+
textDisabledColor: "#d1d5db",
|
|
2756
|
+
// gray-300
|
|
2757
|
+
// Button colors
|
|
2758
|
+
buttonBgColor: "#3b82f6",
|
|
2759
|
+
// blue-500
|
|
2760
|
+
buttonTextColor: "#ffffff",
|
|
2761
|
+
// white
|
|
2762
|
+
buttonBorderColor: "#2563eb",
|
|
2763
|
+
// blue-600
|
|
2764
|
+
buttonHoverBgColor: "#2563eb",
|
|
2765
|
+
// blue-600
|
|
2766
|
+
buttonHoverBorderColor: "#1d4ed8",
|
|
2767
|
+
// blue-700
|
|
2768
|
+
// Action button colors
|
|
2769
|
+
actionBgColor: "#ffffff",
|
|
2770
|
+
// white
|
|
2771
|
+
actionTextColor: "#374151",
|
|
2772
|
+
// gray-700
|
|
2773
|
+
actionBorderColor: "#e5e7eb",
|
|
2774
|
+
// gray-200
|
|
2775
|
+
actionHoverBgColor: "#f9fafb",
|
|
2776
|
+
// gray-50
|
|
2777
|
+
actionHoverBorderColor: "#d1d5db",
|
|
2778
|
+
// gray-300
|
|
2779
|
+
// File upload colors
|
|
2780
|
+
fileUploadBgColor: "#f3f4f6",
|
|
2781
|
+
// gray-100
|
|
2782
|
+
fileUploadBorderColor: "#d1d5db",
|
|
2783
|
+
// gray-300
|
|
2784
|
+
fileUploadTextColor: "#9ca3af",
|
|
2785
|
+
// gray-400
|
|
2786
|
+
fileUploadHoverBorderColor: "#3b82f6",
|
|
2787
|
+
// blue-500
|
|
2788
|
+
// Spacing
|
|
2789
|
+
inputPaddingX: "0.75rem",
|
|
2790
|
+
// 3 (12px)
|
|
2791
|
+
inputPaddingY: "0.5rem",
|
|
2792
|
+
// 2 (8px)
|
|
2793
|
+
borderRadius: "0.5rem",
|
|
2794
|
+
// rounded-lg (8px)
|
|
2795
|
+
borderWidth: "1px",
|
|
2796
|
+
// Typography
|
|
2797
|
+
fontSize: "0.875rem",
|
|
2798
|
+
// text-sm (14px)
|
|
2799
|
+
fontSizeSmall: "0.75rem",
|
|
2800
|
+
// text-xs (12px)
|
|
2801
|
+
fontSizeExtraSmall: "0.625rem",
|
|
2802
|
+
// 10px
|
|
2803
|
+
fontFamily: 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
|
|
2804
|
+
fontWeightNormal: "400",
|
|
2805
|
+
fontWeightMedium: "500",
|
|
2806
|
+
// Focus ring
|
|
2807
|
+
focusRingWidth: "2px",
|
|
2808
|
+
focusRingColor: "#3b82f6",
|
|
2809
|
+
// blue-500
|
|
2810
|
+
focusRingOpacity: "0.5",
|
|
2811
|
+
// Transitions
|
|
2812
|
+
transitionDuration: "200ms"
|
|
2813
|
+
};
|
|
2814
|
+
function generateCSSVariables(theme) {
|
|
2815
|
+
const mergedTheme = { ...defaultTheme, ...theme };
|
|
2816
|
+
const cssVars = [];
|
|
2817
|
+
Object.entries(mergedTheme).forEach(([key, value]) => {
|
|
2818
|
+
const kebabKey = key.replace(/([A-Z])/g, "-$1").toLowerCase();
|
|
2819
|
+
cssVars.push(` --fb-${kebabKey}: ${value};`);
|
|
2820
|
+
});
|
|
2821
|
+
return cssVars.join("\n");
|
|
2822
|
+
}
|
|
2823
|
+
function injectThemeVariables(container, theme) {
|
|
2824
|
+
const cssVariables = generateCSSVariables(theme);
|
|
2825
|
+
let styleTag = container.querySelector(
|
|
2826
|
+
"style[data-fb-theme]"
|
|
2827
|
+
);
|
|
2828
|
+
if (!styleTag) {
|
|
2829
|
+
styleTag = document.createElement("style");
|
|
2830
|
+
styleTag.setAttribute("data-fb-theme", "true");
|
|
2831
|
+
container.appendChild(styleTag);
|
|
2832
|
+
}
|
|
2833
|
+
styleTag.textContent = `
|
|
2834
|
+
[data-fb-root="true"] {
|
|
2835
|
+
${cssVariables}
|
|
2836
|
+
}
|
|
2837
|
+
`;
|
|
2838
|
+
}
|
|
2839
|
+
var exampleThemes = {
|
|
2840
|
+
default: defaultTheme,
|
|
2841
|
+
dark: {
|
|
2842
|
+
...defaultTheme,
|
|
2843
|
+
primaryColor: "#60a5fa",
|
|
2844
|
+
// blue-400
|
|
2845
|
+
primaryHoverColor: "#3b82f6",
|
|
2846
|
+
// blue-500
|
|
2847
|
+
borderColor: "#4b5563",
|
|
2848
|
+
// gray-600
|
|
2849
|
+
borderHoverColor: "#6b7280",
|
|
2850
|
+
// gray-500
|
|
2851
|
+
borderFocusColor: "#60a5fa",
|
|
2852
|
+
// blue-400
|
|
2853
|
+
backgroundColor: "#1f2937",
|
|
2854
|
+
// gray-800
|
|
2855
|
+
backgroundHoverColor: "#374151",
|
|
2856
|
+
// gray-700
|
|
2857
|
+
backgroundReadonlyColor: "#111827",
|
|
2858
|
+
// gray-900
|
|
2859
|
+
textColor: "#f9fafb",
|
|
2860
|
+
// gray-50
|
|
2861
|
+
textSecondaryColor: "#9ca3af",
|
|
2862
|
+
// gray-400
|
|
2863
|
+
textPlaceholderColor: "#6b7280",
|
|
2864
|
+
// gray-500
|
|
2865
|
+
fileUploadBgColor: "#374151",
|
|
2866
|
+
// gray-700
|
|
2867
|
+
fileUploadBorderColor: "#4b5563",
|
|
2868
|
+
// gray-600
|
|
2869
|
+
fileUploadTextColor: "#9ca3af"
|
|
2870
|
+
// gray-400
|
|
2871
|
+
},
|
|
2872
|
+
klein: {
|
|
2873
|
+
...defaultTheme,
|
|
2874
|
+
primaryColor: "#0066cc",
|
|
2875
|
+
primaryHoverColor: "#0052a3",
|
|
2876
|
+
errorColor: "#d32f2f",
|
|
2877
|
+
errorHoverColor: "#c62828",
|
|
2878
|
+
successColor: "#388e3c",
|
|
2879
|
+
borderColor: "#e0e0e0",
|
|
2880
|
+
borderHoverColor: "#bdbdbd",
|
|
2881
|
+
borderFocusColor: "#0066cc",
|
|
2882
|
+
borderRadius: "4px",
|
|
2883
|
+
fontSize: "16px",
|
|
2884
|
+
fontSizeSmall: "14px",
|
|
2885
|
+
fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif'
|
|
2886
|
+
}
|
|
2887
|
+
};
|
|
2888
|
+
|
|
2889
|
+
// src/utils/styles.ts
|
|
2890
|
+
function applyActionButtonStyles(button, isFormLevel = false) {
|
|
2891
|
+
button.style.cssText = `
|
|
2892
|
+
background-color: var(--fb-action-bg-color);
|
|
2893
|
+
color: var(--fb-action-text-color);
|
|
2894
|
+
border: var(--fb-border-width) solid var(--fb-action-border-color);
|
|
2895
|
+
padding: ${isFormLevel ? "0.5rem 1rem" : "0.5rem 0.75rem"};
|
|
2896
|
+
font-size: var(--fb-font-size);
|
|
2897
|
+
font-weight: var(--fb-font-weight-medium);
|
|
2898
|
+
border-radius: var(--fb-border-radius);
|
|
2899
|
+
transition: all var(--fb-transition-duration);
|
|
2900
|
+
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
|
2901
|
+
`;
|
|
2902
|
+
button.addEventListener("mouseenter", () => {
|
|
2903
|
+
button.style.backgroundColor = "var(--fb-action-hover-bg-color)";
|
|
2904
|
+
button.style.borderColor = "var(--fb-action-hover-border-color)";
|
|
2905
|
+
});
|
|
2906
|
+
button.addEventListener("mouseleave", () => {
|
|
2907
|
+
button.style.backgroundColor = "var(--fb-action-bg-color)";
|
|
2908
|
+
button.style.borderColor = "var(--fb-action-border-color)";
|
|
2909
|
+
});
|
|
2910
|
+
}
|
|
2911
|
+
|
|
2912
|
+
// src/components/registry.ts
|
|
2913
|
+
var componentRegistry = {
|
|
2914
|
+
text: {
|
|
2915
|
+
validate: validateTextElement,
|
|
2916
|
+
update: updateTextField
|
|
2917
|
+
},
|
|
2918
|
+
textarea: {
|
|
2919
|
+
validate: validateTextareaElement,
|
|
2920
|
+
update: updateTextareaField
|
|
2921
|
+
},
|
|
2922
|
+
number: {
|
|
2923
|
+
validate: validateNumberElement,
|
|
2924
|
+
update: updateNumberField
|
|
2925
|
+
},
|
|
2926
|
+
select: {
|
|
2927
|
+
validate: validateSelectElement,
|
|
2928
|
+
update: updateSelectField
|
|
2929
|
+
},
|
|
2930
|
+
file: {
|
|
2931
|
+
validate: validateFileElement,
|
|
2932
|
+
update: updateFileField
|
|
2933
|
+
},
|
|
2934
|
+
files: {
|
|
2935
|
+
// Legacy type - delegates to file
|
|
2936
|
+
validate: validateFileElement,
|
|
2937
|
+
update: updateFileField
|
|
2938
|
+
},
|
|
2939
|
+
container: {
|
|
2940
|
+
validate: validateContainerElement,
|
|
2941
|
+
update: updateContainerField
|
|
2942
|
+
},
|
|
2943
|
+
group: {
|
|
2944
|
+
// Deprecated type - delegates to container
|
|
2945
|
+
validate: validateGroupElement,
|
|
2946
|
+
update: updateGroupField
|
|
2947
|
+
}
|
|
2948
|
+
};
|
|
2949
|
+
function getComponentOperations(elementType) {
|
|
2950
|
+
return componentRegistry[elementType] || null;
|
|
2951
|
+
}
|
|
2952
|
+
function validateElementWithComponent(element, key, context) {
|
|
2953
|
+
const ops = getComponentOperations(element.type);
|
|
2954
|
+
if (ops && ops.validate) {
|
|
2955
|
+
return ops.validate(element, key, context);
|
|
2956
|
+
}
|
|
2957
|
+
return null;
|
|
2958
|
+
}
|
|
2959
|
+
function updateElementWithComponent(element, fieldPath, value, context) {
|
|
2960
|
+
const ops = getComponentOperations(element.type);
|
|
2961
|
+
if (ops && ops.update) {
|
|
2962
|
+
ops.update(element, fieldPath, value, context);
|
|
2963
|
+
return true;
|
|
2964
|
+
}
|
|
2965
|
+
return false;
|
|
2966
|
+
}
|
|
2967
|
+
|
|
2968
|
+
// src/instance/FormBuilderInstance.ts
|
|
2969
|
+
var FormBuilderInstance = class {
|
|
2970
|
+
constructor(config) {
|
|
2971
|
+
this.instanceId = generateInstanceId();
|
|
2972
|
+
this.state = createInstanceState(config);
|
|
2973
|
+
}
|
|
2974
|
+
/**
|
|
2975
|
+
* Get instance ID (useful for debugging and resource prefixing)
|
|
2976
|
+
*/
|
|
2977
|
+
getInstanceId() {
|
|
2978
|
+
return this.instanceId;
|
|
2979
|
+
}
|
|
2980
|
+
/**
|
|
2981
|
+
* Get current state (for advanced use cases)
|
|
2982
|
+
*/
|
|
2983
|
+
getState() {
|
|
2984
|
+
return this.state;
|
|
2985
|
+
}
|
|
2986
|
+
/**
|
|
2987
|
+
* Set the form root element
|
|
2988
|
+
*/
|
|
2989
|
+
setFormRoot(element) {
|
|
2990
|
+
this.state.formRoot = element;
|
|
2991
|
+
}
|
|
2992
|
+
/**
|
|
2993
|
+
* Configure the form builder
|
|
2994
|
+
*/
|
|
2995
|
+
configure(config) {
|
|
2996
|
+
Object.assign(this.state.config, config);
|
|
2997
|
+
}
|
|
2998
|
+
/**
|
|
2999
|
+
* Set file upload handler
|
|
3000
|
+
*/
|
|
3001
|
+
setUploadHandler(uploadFn) {
|
|
3002
|
+
this.state.config.uploadFile = uploadFn;
|
|
3003
|
+
}
|
|
3004
|
+
/**
|
|
3005
|
+
* Set file download handler
|
|
3006
|
+
*/
|
|
3007
|
+
setDownloadHandler(downloadFn) {
|
|
3008
|
+
this.state.config.downloadFile = downloadFn;
|
|
3009
|
+
}
|
|
3010
|
+
/**
|
|
3011
|
+
* Set thumbnail generation handler
|
|
3012
|
+
*/
|
|
3013
|
+
setThumbnailHandler(thumbnailFn) {
|
|
3014
|
+
this.state.config.getThumbnail = thumbnailFn;
|
|
3015
|
+
}
|
|
3016
|
+
/**
|
|
3017
|
+
* Set action handler
|
|
3018
|
+
*/
|
|
3019
|
+
setActionHandler(actionFn) {
|
|
3020
|
+
this.state.config.actionHandler = actionFn;
|
|
3021
|
+
}
|
|
3022
|
+
/**
|
|
3023
|
+
* Set mode (edit or readonly)
|
|
3024
|
+
*/
|
|
3025
|
+
setMode(mode) {
|
|
3026
|
+
this.state.config.readonly = mode === "readonly";
|
|
3027
|
+
}
|
|
3028
|
+
/**
|
|
3029
|
+
* Set locale
|
|
3030
|
+
*/
|
|
3031
|
+
setLocale(locale) {
|
|
3032
|
+
if (this.state.config.translations[locale]) {
|
|
3033
|
+
this.state.config.locale = locale;
|
|
3034
|
+
}
|
|
3035
|
+
}
|
|
3036
|
+
/**
|
|
3037
|
+
* Trigger onChange callbacks with debouncing
|
|
3038
|
+
* @param fieldPath - Optional field path for field-specific change events
|
|
3039
|
+
* @param fieldValue - Optional field value for field-specific change events
|
|
3040
|
+
*/
|
|
3041
|
+
triggerOnChange(fieldPath, fieldValue) {
|
|
3042
|
+
if (this.state.config.readonly) return;
|
|
3043
|
+
if (this.state.debounceTimer !== null) {
|
|
3044
|
+
clearTimeout(this.state.debounceTimer);
|
|
3045
|
+
}
|
|
3046
|
+
this.state.debounceTimer = setTimeout(() => {
|
|
3047
|
+
const formData = this.validateForm(true);
|
|
3048
|
+
if (this.state.config.onChange) {
|
|
3049
|
+
this.state.config.onChange(formData);
|
|
3050
|
+
}
|
|
3051
|
+
if (this.state.config.onFieldChange && fieldPath !== void 0 && fieldValue !== void 0) {
|
|
3052
|
+
this.state.config.onFieldChange(fieldPath, fieldValue, formData);
|
|
3053
|
+
}
|
|
3054
|
+
this.state.debounceTimer = null;
|
|
3055
|
+
}, this.state.config.debounceMs);
|
|
3056
|
+
}
|
|
3057
|
+
/**
|
|
3058
|
+
* Register an external action that will be displayed as a button
|
|
3059
|
+
* External actions can be form-level (no related_field) or field-level (with related_field)
|
|
3060
|
+
* @param action - External action definition
|
|
3061
|
+
*/
|
|
3062
|
+
registerAction(action) {
|
|
3063
|
+
if (!action || !action.value) {
|
|
3064
|
+
throw new Error("Action must have a value property");
|
|
3065
|
+
}
|
|
3066
|
+
if (!this.state.externalActions) {
|
|
3067
|
+
this.state.externalActions = [];
|
|
3068
|
+
}
|
|
3069
|
+
const existingIndex = this.state.externalActions.findIndex(
|
|
3070
|
+
(a) => a.value === action.value && a.related_field === action.related_field
|
|
3071
|
+
);
|
|
3072
|
+
if (existingIndex >= 0) {
|
|
3073
|
+
this.state.externalActions[existingIndex] = action;
|
|
3074
|
+
} else {
|
|
3075
|
+
this.state.externalActions.push(action);
|
|
3076
|
+
}
|
|
3077
|
+
}
|
|
3078
|
+
/**
|
|
3079
|
+
* Find the DOM element corresponding to a field path (instance-scoped)
|
|
3080
|
+
*/
|
|
3081
|
+
findFormElementByFieldPath(fieldPath) {
|
|
3082
|
+
if (!this.state.formRoot) return null;
|
|
3083
|
+
if (!this.state.config.readonly) {
|
|
3084
|
+
let element = this.state.formRoot.querySelector(
|
|
3085
|
+
`[name="${fieldPath}"]`
|
|
3086
|
+
);
|
|
3087
|
+
if (element) return element;
|
|
3088
|
+
const variations = [
|
|
3089
|
+
fieldPath,
|
|
3090
|
+
fieldPath.replace(/\[(\d+)\]/g, "[$1]"),
|
|
3091
|
+
fieldPath.replace(/\./g, "[") + "]".repeat((fieldPath.match(/\./g) || []).length)
|
|
3092
|
+
];
|
|
3093
|
+
for (const variation of variations) {
|
|
3094
|
+
element = this.state.formRoot.querySelector(
|
|
3095
|
+
`[name="${variation}"]`
|
|
3096
|
+
);
|
|
3097
|
+
if (element) return element;
|
|
3098
|
+
}
|
|
3099
|
+
}
|
|
3100
|
+
const schemaElement = this.findSchemaElement(fieldPath);
|
|
3101
|
+
if (!schemaElement) return null;
|
|
3102
|
+
const fieldWrappers = this.state.formRoot.querySelectorAll(".fb-field-wrapper");
|
|
3103
|
+
for (const wrapper of fieldWrappers) {
|
|
3104
|
+
const labelText = schemaElement.label || schemaElement.key;
|
|
3105
|
+
const labelElement = wrapper.querySelector("label");
|
|
3106
|
+
if (labelElement && (labelElement.textContent === labelText || labelElement.textContent === `${labelText}*`)) {
|
|
3107
|
+
let fieldElement = wrapper.querySelector(".field-placeholder");
|
|
3108
|
+
if (!fieldElement) {
|
|
3109
|
+
fieldElement = document.createElement("div");
|
|
3110
|
+
fieldElement.className = "field-placeholder";
|
|
3111
|
+
fieldElement.style.display = "none";
|
|
3112
|
+
wrapper.appendChild(fieldElement);
|
|
3113
|
+
}
|
|
3114
|
+
return fieldElement;
|
|
3115
|
+
}
|
|
3116
|
+
}
|
|
3117
|
+
return null;
|
|
3118
|
+
}
|
|
3119
|
+
/**
|
|
3120
|
+
* Find schema element by field path
|
|
3121
|
+
*/
|
|
3122
|
+
findSchemaElement(fieldPath) {
|
|
3123
|
+
if (!this.state.schema || !this.state.schema.elements) return null;
|
|
3124
|
+
let currentElements = this.state.schema.elements;
|
|
3125
|
+
let foundElement = null;
|
|
3126
|
+
const keys = fieldPath.replace(/\[\d+\]/g, "").split(".").filter(Boolean);
|
|
3127
|
+
for (const key of keys) {
|
|
3128
|
+
foundElement = currentElements.find((el) => el.key === key) || null;
|
|
3129
|
+
if (!foundElement) {
|
|
3130
|
+
return null;
|
|
3131
|
+
}
|
|
3132
|
+
if ("elements" in foundElement && foundElement.elements) {
|
|
3133
|
+
currentElements = foundElement.elements;
|
|
3134
|
+
}
|
|
3135
|
+
}
|
|
3136
|
+
return foundElement;
|
|
3137
|
+
}
|
|
3138
|
+
/**
|
|
3139
|
+
* Resolve action label from schema or external action
|
|
3140
|
+
*/
|
|
3141
|
+
resolveActionLabel(actionKey, externalLabel, schemaElement, isTrueFormLevelAction = false) {
|
|
3142
|
+
if (schemaElement && "actions" in schemaElement && schemaElement.actions) {
|
|
3143
|
+
const predefinedAction = schemaElement.actions.find(
|
|
3144
|
+
(a) => a.key === actionKey
|
|
3145
|
+
);
|
|
3146
|
+
if (predefinedAction && predefinedAction.label) {
|
|
3147
|
+
return predefinedAction.label;
|
|
3148
|
+
}
|
|
3149
|
+
}
|
|
3150
|
+
if (isTrueFormLevelAction && this.state.schema && "actions" in this.state.schema && this.state.schema.actions) {
|
|
3151
|
+
const rootAction = this.state.schema.actions.find(
|
|
3152
|
+
(a) => a.key === actionKey
|
|
3153
|
+
);
|
|
3154
|
+
if (rootAction && rootAction.label) {
|
|
3155
|
+
return rootAction.label;
|
|
3156
|
+
}
|
|
3157
|
+
}
|
|
3158
|
+
if (externalLabel) {
|
|
3159
|
+
return externalLabel;
|
|
3160
|
+
}
|
|
3161
|
+
return actionKey;
|
|
3162
|
+
}
|
|
3163
|
+
/**
|
|
3164
|
+
* Render form-level action buttons at the bottom of the form
|
|
3165
|
+
*/
|
|
3166
|
+
renderFormLevelActions(actions, trueFormLevelActions = []) {
|
|
3167
|
+
if (!this.state.formRoot) return;
|
|
3168
|
+
const existingContainer = this.state.formRoot.querySelector(
|
|
3169
|
+
".form-level-actions-container"
|
|
3170
|
+
);
|
|
3171
|
+
if (existingContainer) {
|
|
3172
|
+
existingContainer.remove();
|
|
3173
|
+
}
|
|
3174
|
+
const actionsContainer = document.createElement("div");
|
|
3175
|
+
actionsContainer.className = "form-level-actions-container mt-6 pt-4 flex flex-wrap gap-3 justify-center";
|
|
3176
|
+
actionsContainer.style.cssText = `
|
|
3177
|
+
border-top: var(--fb-border-width) solid var(--fb-border-color);
|
|
3178
|
+
`;
|
|
3179
|
+
actions.forEach((action) => {
|
|
3180
|
+
const actionBtn = document.createElement("button");
|
|
3181
|
+
actionBtn.type = "button";
|
|
3182
|
+
applyActionButtonStyles(actionBtn, true);
|
|
3183
|
+
const isTrueFormLevelAction = trueFormLevelActions.includes(action);
|
|
3184
|
+
const resolvedLabel = this.resolveActionLabel(
|
|
3185
|
+
action.key,
|
|
3186
|
+
action.label,
|
|
3187
|
+
null,
|
|
3188
|
+
isTrueFormLevelAction
|
|
3189
|
+
);
|
|
3190
|
+
actionBtn.textContent = resolvedLabel;
|
|
3191
|
+
actionBtn.addEventListener("click", (e) => {
|
|
3192
|
+
e.preventDefault();
|
|
3193
|
+
e.stopPropagation();
|
|
3194
|
+
if (this.state.config.actionHandler && typeof this.state.config.actionHandler === "function") {
|
|
3195
|
+
this.state.config.actionHandler(action.value, action.key, null);
|
|
3196
|
+
}
|
|
3197
|
+
});
|
|
3198
|
+
actionsContainer.appendChild(actionBtn);
|
|
3199
|
+
});
|
|
3200
|
+
this.state.formRoot.appendChild(actionsContainer);
|
|
3201
|
+
}
|
|
3202
|
+
/**
|
|
3203
|
+
* Render external action buttons for fields
|
|
3204
|
+
*/
|
|
3205
|
+
renderExternalActions() {
|
|
3206
|
+
if (!this.state.externalActions || !Array.isArray(this.state.externalActions))
|
|
3207
|
+
return;
|
|
3208
|
+
const actionsByField = /* @__PURE__ */ new Map();
|
|
3209
|
+
const trueFormLevelActions = [];
|
|
3210
|
+
const movedFormLevelActions = [];
|
|
3211
|
+
this.state.externalActions.forEach((action) => {
|
|
3212
|
+
if (!action.key || !action.value) return;
|
|
3213
|
+
if (!action.related_field) {
|
|
3214
|
+
trueFormLevelActions.push(action);
|
|
3215
|
+
} else {
|
|
3216
|
+
if (!actionsByField.has(action.related_field)) {
|
|
3217
|
+
actionsByField.set(action.related_field, []);
|
|
3218
|
+
}
|
|
3219
|
+
actionsByField.get(action.related_field).push(action);
|
|
3220
|
+
}
|
|
3221
|
+
});
|
|
3222
|
+
actionsByField.forEach((actions, fieldPath) => {
|
|
3223
|
+
const fieldElement = this.findFormElementByFieldPath(fieldPath);
|
|
3224
|
+
if (!fieldElement) {
|
|
3225
|
+
console.warn(
|
|
3226
|
+
`External action: Could not find form element for field "${fieldPath}", treating as form-level actions`
|
|
3227
|
+
);
|
|
3228
|
+
movedFormLevelActions.push(...actions);
|
|
3229
|
+
return;
|
|
3230
|
+
}
|
|
3231
|
+
let wrapper = fieldElement.closest(".fb-field-wrapper");
|
|
3232
|
+
if (!wrapper) {
|
|
3233
|
+
wrapper = fieldElement.parentElement;
|
|
3234
|
+
}
|
|
3235
|
+
if (!wrapper) {
|
|
3236
|
+
console.warn(
|
|
3237
|
+
`External action: Could not find wrapper for field "${fieldPath}"`
|
|
3238
|
+
);
|
|
3239
|
+
return;
|
|
3240
|
+
}
|
|
3241
|
+
const existingContainer = wrapper.querySelector(
|
|
3242
|
+
".external-actions-container"
|
|
3243
|
+
);
|
|
3244
|
+
if (existingContainer) {
|
|
3245
|
+
existingContainer.remove();
|
|
3246
|
+
}
|
|
3247
|
+
const actionsContainer = document.createElement("div");
|
|
3248
|
+
actionsContainer.className = "external-actions-container mt-3 flex flex-wrap gap-2";
|
|
3249
|
+
const schemaElement = this.findSchemaElement(fieldPath);
|
|
3250
|
+
actions.forEach((action) => {
|
|
3251
|
+
const actionBtn = document.createElement("button");
|
|
3252
|
+
actionBtn.type = "button";
|
|
3253
|
+
applyActionButtonStyles(actionBtn, false);
|
|
3254
|
+
const resolvedLabel = this.resolveActionLabel(
|
|
3255
|
+
action.key,
|
|
3256
|
+
action.label,
|
|
3257
|
+
schemaElement
|
|
3258
|
+
);
|
|
3259
|
+
actionBtn.textContent = resolvedLabel;
|
|
3260
|
+
actionBtn.addEventListener("click", (e) => {
|
|
3261
|
+
e.preventDefault();
|
|
3262
|
+
e.stopPropagation();
|
|
3263
|
+
if (this.state.config.actionHandler && typeof this.state.config.actionHandler === "function") {
|
|
3264
|
+
this.state.config.actionHandler(
|
|
3265
|
+
action.value,
|
|
3266
|
+
action.key,
|
|
3267
|
+
action.related_field
|
|
3268
|
+
);
|
|
3269
|
+
}
|
|
3270
|
+
});
|
|
3271
|
+
actionsContainer.appendChild(actionBtn);
|
|
3272
|
+
});
|
|
3273
|
+
wrapper.appendChild(actionsContainer);
|
|
3274
|
+
});
|
|
3275
|
+
const allFormLevelActions = [
|
|
3276
|
+
...trueFormLevelActions,
|
|
3277
|
+
...movedFormLevelActions
|
|
3278
|
+
];
|
|
3279
|
+
if (allFormLevelActions.length > 0) {
|
|
3280
|
+
this.renderFormLevelActions(allFormLevelActions, trueFormLevelActions);
|
|
3281
|
+
}
|
|
3282
|
+
}
|
|
3283
|
+
/**
|
|
3284
|
+
* Render form from schema
|
|
3285
|
+
*/
|
|
3286
|
+
renderForm(root, schema, prefill, actions) {
|
|
3287
|
+
const errors = validateSchema(schema);
|
|
3288
|
+
if (errors.length > 0) {
|
|
3289
|
+
console.error("Schema validation errors:", errors);
|
|
3290
|
+
return;
|
|
3291
|
+
}
|
|
3292
|
+
this.state.formRoot = root;
|
|
3293
|
+
this.state.schema = schema;
|
|
3294
|
+
this.state.externalActions = actions || null;
|
|
3295
|
+
clear(root);
|
|
3296
|
+
root.setAttribute("data-fb-root", "true");
|
|
3297
|
+
injectThemeVariables(root, this.state.config.theme);
|
|
3298
|
+
const formEl = document.createElement("div");
|
|
3299
|
+
formEl.className = "space-y-6";
|
|
3300
|
+
schema.elements.forEach((element) => {
|
|
3301
|
+
if (element.hidden) {
|
|
3302
|
+
return;
|
|
3303
|
+
}
|
|
3304
|
+
const block = renderElement2(element, {
|
|
3305
|
+
path: "",
|
|
3306
|
+
prefill: prefill || {},
|
|
3307
|
+
state: this.state,
|
|
3308
|
+
instance: this
|
|
3309
|
+
});
|
|
3310
|
+
formEl.appendChild(block);
|
|
3311
|
+
});
|
|
3312
|
+
root.appendChild(formEl);
|
|
3313
|
+
if (this.state.config.readonly && this.state.externalActions && Array.isArray(this.state.externalActions)) {
|
|
3314
|
+
this.renderExternalActions();
|
|
3315
|
+
}
|
|
3316
|
+
}
|
|
3317
|
+
/**
|
|
3318
|
+
* Validate form and extract data
|
|
3319
|
+
* This is a complete copy of the validateForm logic from form-builder.ts
|
|
3320
|
+
* but uses instance state instead of global state
|
|
3321
|
+
*/
|
|
3322
|
+
validateForm(skipValidation = false) {
|
|
3323
|
+
if (!this.state.schema || !this.state.formRoot)
|
|
3324
|
+
return { valid: true, errors: [], data: {} };
|
|
3325
|
+
const errors = [];
|
|
3326
|
+
const data = {};
|
|
3327
|
+
const validateElement2 = (element, ctx, customScopeRoot = null) => {
|
|
3328
|
+
const key = element.key;
|
|
3329
|
+
const scopeRoot = customScopeRoot || this.state.formRoot;
|
|
3330
|
+
const componentContext = {
|
|
3331
|
+
scopeRoot,
|
|
3332
|
+
state: this.state,
|
|
3333
|
+
instance: this,
|
|
3334
|
+
path: ctx.path,
|
|
3335
|
+
skipValidation
|
|
3336
|
+
};
|
|
3337
|
+
const componentResult = validateElementWithComponent(
|
|
3338
|
+
element,
|
|
3339
|
+
key,
|
|
3340
|
+
componentContext
|
|
3341
|
+
);
|
|
3342
|
+
if (componentResult !== null) {
|
|
3343
|
+
errors.push(...componentResult.errors);
|
|
3344
|
+
return componentResult.value;
|
|
3345
|
+
}
|
|
3346
|
+
console.warn(`Unknown field type "${element.type}" for key "${key}"`);
|
|
3347
|
+
return null;
|
|
3348
|
+
};
|
|
3349
|
+
setValidateElement(validateElement2);
|
|
3350
|
+
this.state.schema.elements.forEach((element) => {
|
|
3351
|
+
if (element.hidden) {
|
|
3352
|
+
data[element.key] = element.default !== void 0 ? element.default : null;
|
|
3353
|
+
} else {
|
|
3354
|
+
data[element.key] = validateElement2(element, { path: "" });
|
|
3355
|
+
}
|
|
3356
|
+
});
|
|
3357
|
+
return {
|
|
3358
|
+
valid: errors.length === 0,
|
|
3359
|
+
errors,
|
|
3360
|
+
data
|
|
3361
|
+
};
|
|
3362
|
+
}
|
|
3363
|
+
/**
|
|
3364
|
+
* Get form data
|
|
3365
|
+
*/
|
|
3366
|
+
getFormData() {
|
|
3367
|
+
return this.validateForm(false);
|
|
3368
|
+
}
|
|
3369
|
+
/**
|
|
3370
|
+
* Submit form with validation
|
|
3371
|
+
*/
|
|
3372
|
+
submitForm() {
|
|
3373
|
+
const result = this.validateForm(false);
|
|
3374
|
+
if (result.valid) {
|
|
3375
|
+
if (typeof window !== "undefined" && window.parent) {
|
|
3376
|
+
window.parent.postMessage(
|
|
3377
|
+
{
|
|
3378
|
+
type: "formSubmit",
|
|
3379
|
+
data: result.data,
|
|
3380
|
+
schema: this.state.schema
|
|
3381
|
+
},
|
|
3382
|
+
"*"
|
|
3383
|
+
);
|
|
3384
|
+
}
|
|
3385
|
+
}
|
|
3386
|
+
return result;
|
|
3387
|
+
}
|
|
3388
|
+
/**
|
|
3389
|
+
* Save draft without validation
|
|
3390
|
+
*/
|
|
3391
|
+
saveDraft() {
|
|
3392
|
+
const result = this.validateForm(true);
|
|
3393
|
+
if (typeof window !== "undefined" && window.parent) {
|
|
3394
|
+
window.parent.postMessage(
|
|
3395
|
+
{
|
|
3396
|
+
type: "formDraft",
|
|
3397
|
+
data: result.data,
|
|
3398
|
+
schema: this.state.schema
|
|
3399
|
+
},
|
|
3400
|
+
"*"
|
|
3401
|
+
);
|
|
3402
|
+
}
|
|
3403
|
+
return result;
|
|
3404
|
+
}
|
|
3405
|
+
/**
|
|
3406
|
+
* Clear the form - reset all field values to empty while preserving form structure
|
|
3407
|
+
* This is done by re-rendering the form with empty data
|
|
3408
|
+
*/
|
|
3409
|
+
clearForm() {
|
|
3410
|
+
if (!this.state.schema || !this.state.formRoot) {
|
|
3411
|
+
console.warn("clearForm: Form not initialized. Call renderForm() first.");
|
|
3412
|
+
return;
|
|
3413
|
+
}
|
|
3414
|
+
const schema = this.state.schema;
|
|
3415
|
+
const formRoot = this.state.formRoot;
|
|
3416
|
+
const emptyPrefill = this.buildHiddenFieldsData(schema.elements);
|
|
3417
|
+
this.renderForm(formRoot, schema, emptyPrefill);
|
|
3418
|
+
}
|
|
3419
|
+
/**
|
|
3420
|
+
* Build prefill data for hidden fields only
|
|
3421
|
+
* Hidden fields should retain their values when clearing
|
|
3422
|
+
*/
|
|
3423
|
+
buildHiddenFieldsData(elements, parentPath = "") {
|
|
3424
|
+
const data = {};
|
|
3425
|
+
for (const element of elements) {
|
|
3426
|
+
const key = element.key;
|
|
3427
|
+
const fieldPath = parentPath ? `${parentPath}.${key}` : key;
|
|
3428
|
+
if (element.hidden && element.default !== void 0) {
|
|
3429
|
+
data[fieldPath] = element.default;
|
|
3430
|
+
}
|
|
3431
|
+
if (element.type === "container" || element.type === "group") {
|
|
3432
|
+
const containerElement = element;
|
|
3433
|
+
const nestedData = this.buildHiddenFieldsData(
|
|
3434
|
+
containerElement.elements,
|
|
3435
|
+
fieldPath
|
|
3436
|
+
);
|
|
3437
|
+
Object.assign(data, nestedData);
|
|
3438
|
+
}
|
|
3439
|
+
}
|
|
3440
|
+
return data;
|
|
3441
|
+
}
|
|
3442
|
+
/**
|
|
3443
|
+
* Set form data - update multiple fields without full re-render
|
|
3444
|
+
* @param data - Object with field paths and their values
|
|
3445
|
+
*/
|
|
3446
|
+
setFormData(data) {
|
|
3447
|
+
if (!this.state.schema || !this.state.formRoot) {
|
|
3448
|
+
console.warn("setFormData: Form not initialized. Call renderForm() first.");
|
|
3449
|
+
return;
|
|
3450
|
+
}
|
|
3451
|
+
for (const fieldPath in data) {
|
|
3452
|
+
this.updateField(fieldPath, data[fieldPath]);
|
|
3453
|
+
}
|
|
3454
|
+
}
|
|
3455
|
+
/**
|
|
3456
|
+
* Update a single field by path without full re-render
|
|
3457
|
+
* @param fieldPath - Field path (e.g., "email", "address.city", "items[0].name")
|
|
3458
|
+
* @param value - New value for the field
|
|
3459
|
+
*/
|
|
3460
|
+
updateField(fieldPath, value) {
|
|
3461
|
+
if (!this.state.schema || !this.state.formRoot) {
|
|
3462
|
+
console.warn("updateField: Form not initialized. Call renderForm() first.");
|
|
3463
|
+
return;
|
|
3464
|
+
}
|
|
3465
|
+
const schemaElement = this.findSchemaElement(fieldPath);
|
|
3466
|
+
if (!schemaElement) {
|
|
3467
|
+
console.warn(`updateField: Schema element not found for path "${fieldPath}"`);
|
|
3468
|
+
return;
|
|
3469
|
+
}
|
|
3470
|
+
const domElement = this.findFormElementByFieldPath(fieldPath);
|
|
3471
|
+
if (!domElement) {
|
|
3472
|
+
console.warn(`updateField: DOM element not found for path "${fieldPath}"`);
|
|
3473
|
+
return;
|
|
3474
|
+
}
|
|
3475
|
+
this.updateFieldValue(domElement, schemaElement, fieldPath, value);
|
|
3476
|
+
if (this.state.config.onChange || this.state.config.onFieldChange) {
|
|
3477
|
+
this.triggerOnChange(fieldPath, value);
|
|
3478
|
+
}
|
|
3479
|
+
}
|
|
3480
|
+
/**
|
|
3481
|
+
* Update field value in DOM based on field type
|
|
3482
|
+
* Delegates to component-specific updaters via registry
|
|
3483
|
+
*/
|
|
3484
|
+
updateFieldValue(domElement, schemaElement, fieldPath, value) {
|
|
3485
|
+
const componentContext = {
|
|
3486
|
+
scopeRoot: this.state.formRoot,
|
|
3487
|
+
state: this.state,
|
|
3488
|
+
instance: this,
|
|
3489
|
+
path: ""
|
|
3490
|
+
};
|
|
3491
|
+
const handled = updateElementWithComponent(
|
|
3492
|
+
schemaElement,
|
|
3493
|
+
fieldPath,
|
|
3494
|
+
value,
|
|
3495
|
+
componentContext
|
|
3496
|
+
);
|
|
3497
|
+
if (!handled) {
|
|
3498
|
+
console.warn(
|
|
3499
|
+
`updateField: No updater found for field type "${schemaElement.type}" at path "${fieldPath}"`
|
|
3500
|
+
);
|
|
3501
|
+
}
|
|
3502
|
+
}
|
|
3503
|
+
/**
|
|
3504
|
+
* Destroy instance and clean up resources
|
|
3505
|
+
*/
|
|
3506
|
+
destroy() {
|
|
3507
|
+
if (this.state.debounceTimer !== null) {
|
|
3508
|
+
clearTimeout(this.state.debounceTimer);
|
|
3509
|
+
this.state.debounceTimer = null;
|
|
3510
|
+
}
|
|
3511
|
+
this.state.resourceIndex.clear();
|
|
3512
|
+
if (this.state.formRoot) {
|
|
3513
|
+
clear(this.state.formRoot);
|
|
3514
|
+
}
|
|
3515
|
+
this.state.formRoot = null;
|
|
3516
|
+
this.state.schema = null;
|
|
3517
|
+
this.state.externalActions = null;
|
|
3518
|
+
}
|
|
3519
|
+
};
|
|
3520
|
+
|
|
3521
|
+
// src/index.ts
|
|
3522
|
+
function createFormBuilder(config) {
|
|
3523
|
+
return new FormBuilderInstance(config);
|
|
3524
|
+
}
|
|
3525
|
+
var index_default = FormBuilderInstance;
|
|
3526
|
+
if (typeof window !== "undefined") {
|
|
3527
|
+
window.FormBuilder = FormBuilderInstance;
|
|
3528
|
+
window.createFormBuilder = createFormBuilder;
|
|
3529
|
+
window.validateSchema = validateSchema;
|
|
3530
|
+
}
|
|
3531
|
+
|
|
3532
|
+
export { FormBuilderInstance, createFormBuilder, index_default as default, defaultTheme, exampleThemes, validateSchema };
|
|
3533
|
+
//# sourceMappingURL=index.js.map
|
|
3534
|
+
//# sourceMappingURL=index.js.map
|