@dmitryvim/form-builder 0.1.4 → 0.1.6

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.
@@ -0,0 +1,1040 @@
1
+ /**
2
+ * Form Builder Library v0.1.4
3
+ * A reusable JSON schema form builder library
4
+ * https://github.com/picazru/form-builder
5
+ */
6
+
7
+ (function (global, factory) {
8
+ typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
9
+ typeof define === 'function' && define.amd ? define(['exports'], factory) :
10
+ (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.FormBuilder = {}));
11
+ })(this, (function (exports) {
12
+ 'use strict';
13
+
14
+ // Utility functions
15
+ const sleep = (ms) => new Promise(r => setTimeout(r, ms));
16
+ const pretty = (obj) => JSON.stringify(obj, null, 2);
17
+ const deepClone = (obj) => structuredClone ? structuredClone(obj) : JSON.parse(JSON.stringify(obj));
18
+ const isPlainObject = (v) => Object.prototype.toString.call(v) === '[object Object]';
19
+ const setText = (node, text) => { node.textContent = text || ''; };
20
+ const pathJoin = (base, key) => base ? `${base}.${key}` : key;
21
+ const assert = (c, m) => { if(!c) throw new Error(m); };
22
+ const warn = (m) => console.warn('[FormBuilder]', m);
23
+
24
+ function clear(node) {
25
+ while (node.firstChild) node.removeChild(node.firstChild);
26
+ }
27
+
28
+ function parseJSONSafe(text, fallback = null) {
29
+ try {
30
+ return JSON.parse(text);
31
+ } catch {
32
+ return fallback;
33
+ }
34
+ }
35
+
36
+ async function makeResourceIdFromFile(file) {
37
+ try {
38
+ const buf = await file.arrayBuffer();
39
+ if (crypto?.subtle?.digest) {
40
+ const hash = await crypto.subtle.digest('SHA-256', buf);
41
+ const hex = [...new Uint8Array(hash)].map(b => b.toString(16).padStart(2, '0')).join('');
42
+ return `res_${hex.slice(0, 24)}`;
43
+ }
44
+ } catch(_) {}
45
+ const rnd = Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2);
46
+ return `res_${rnd.slice(0, 24)}`;
47
+ }
48
+
49
+ function formatFileSize(bytes) {
50
+ if (bytes === 0) return '0 B';
51
+ const k = 1024;
52
+ const sizes = ['B', 'KB', 'MB', 'GB'];
53
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
54
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
55
+ }
56
+
57
+ // Schema validation
58
+ function validateSchema(schema) {
59
+ const errors = [];
60
+ try { assert(schema && schema.version === '0.3', 'schema.version must be "0.3"'); } catch(e) { errors.push(e.message); }
61
+ try { assert(Array.isArray(schema.elements), 'schema.elements must be an array'); } catch(e) { errors.push(e.message); }
62
+
63
+ function validateElements(elements, path) {
64
+ const seen = new Set();
65
+ elements.forEach((el, idx) => {
66
+ const here = `${path}[${idx}]`;
67
+ if (!el || typeof el !== 'object') { errors.push(`${here}: element must be object`); return; }
68
+ if (!el.type) errors.push(`${here}: missing "type"`);
69
+ if (!el.key) errors.push(`${here}: missing "key"`);
70
+ if (el.key) {
71
+ if (seen.has(el.key)) errors.push(`${path}: duplicate key "${el.key}"`);
72
+ seen.add(el.key);
73
+ }
74
+ if (el.default !== undefined && (el.type === 'file' || el.type === 'files')) {
75
+ errors.push(`${here}: default forbidden for "${el.type}"`);
76
+ }
77
+
78
+ // Type-specific validation
79
+ if (el.type === 'text' || el.type === 'textarea') {
80
+ if (el.minLength != null && el.maxLength != null && el.minLength > el.maxLength) {
81
+ errors.push(`${here}: minLength > maxLength`);
82
+ }
83
+ if (el.pattern != null) {
84
+ try { new RegExp(el.pattern); } catch { errors.push(`${here}: invalid pattern regex`); }
85
+ }
86
+ }
87
+ if (el.type === 'number') {
88
+ if (typeof el.min === 'number' && typeof el.max === 'number' && el.min > el.max) {
89
+ errors.push(`${here}: min > max`);
90
+ }
91
+ if (el.decimals != null && (!Number.isInteger(el.decimals) || el.decimals < 0 || el.decimals > 8)) {
92
+ errors.push(`${here}: decimals must be 0..8`);
93
+ }
94
+ }
95
+ if (el.type === 'select') {
96
+ if (!Array.isArray(el.options) || el.options.length === 0) {
97
+ errors.push(`${here}: select.options must be non-empty array`);
98
+ } else {
99
+ const values = new Set(el.options.map(o => o.value));
100
+ if (el.default != null && !values.has(el.default)) {
101
+ errors.push(`${here}: default "${el.default}" not in options`);
102
+ }
103
+ }
104
+ }
105
+ if (el.type === 'file') {
106
+ if (el.maxSizeMB != null && el.maxSizeMB <= 0) {
107
+ errors.push(`${here}: maxSizeMB must be > 0`);
108
+ }
109
+ }
110
+ if (el.type === 'files') {
111
+ if (el.minCount != null && el.maxCount != null && el.minCount > el.maxCount) {
112
+ errors.push(`${here}: minCount > maxCount`);
113
+ }
114
+ }
115
+ if (el.type === 'group') {
116
+ if (!Array.isArray(el.elements)) errors.push(`${here}: group.elements must be array`);
117
+ if (el.repeat) {
118
+ if (el.repeat.min != null && el.repeat.max != null && el.repeat.min > el.repeat.max) {
119
+ errors.push(`${here}: repeat.min > repeat.max`);
120
+ }
121
+ }
122
+ // Validate element_label if provided (optional)
123
+ if (el.element_label != null && typeof el.element_label !== 'string') {
124
+ errors.push(`${here}: element_label must be a string`);
125
+ }
126
+ if (Array.isArray(el.elements)) validateElements(el.elements, pathJoin(path, el.key));
127
+ }
128
+ });
129
+ }
130
+
131
+ if (Array.isArray(schema.elements)) validateElements(schema.elements, 'elements');
132
+ return errors;
133
+ }
134
+
135
+ // Form rendering state
136
+ let resourceIndex = new Map();
137
+ let config = {
138
+ uploadFile: null,
139
+ downloadFile: null,
140
+ getThumbnail: null,
141
+ enableFilePreview: true,
142
+ maxPreviewSize: '200px'
143
+ };
144
+
145
+ function setTextValueFromPrefill(input, element, prefillObj, key) {
146
+ let v = undefined;
147
+ if (prefillObj && Object.prototype.hasOwnProperty.call(prefillObj, key)) v = prefillObj[key];
148
+ else if (element.default !== undefined) v = element.default;
149
+ if (v !== undefined) input.value = String(v);
150
+ }
151
+
152
+ function setNumberFromPrefill(input, element, prefillObj, key) {
153
+ let v = undefined;
154
+ if (prefillObj && Object.prototype.hasOwnProperty.call(prefillObj, key)) v = prefillObj[key];
155
+ else if (element.default !== undefined) v = element.default;
156
+ if (v !== undefined && v !== null && v !== '') input.value = String(v);
157
+ }
158
+
159
+ function setSelectFromPrefill(select, element, prefillObj, key) {
160
+ const values = new Set(element.options.map(o => String(o.value)));
161
+ let v = undefined;
162
+ if (prefillObj && Object.prototype.hasOwnProperty.call(prefillObj, key)) v = prefillObj[key];
163
+ else if (element.default !== undefined) v = element.default;
164
+ if (v !== undefined && values.has(String(v))) select.value = String(v);
165
+ else if (!element.required) select.value = '';
166
+ }
167
+
168
+ function fileValidationError(element, file) {
169
+ if (!file) return 'no file';
170
+ if (element.maxSizeMB != null && file.size > element.maxSizeMB * 1024 * 1024) {
171
+ return `file too large > ${element.maxSizeMB}MB`;
172
+ }
173
+ if (element.accept) {
174
+ const { extensions, mime } = element.accept;
175
+ if (mime && Array.isArray(mime) && mime.length && !mime.includes(file.type)) {
176
+ return `mime not allowed: ${file.type}`;
177
+ }
178
+ if (extensions && Array.isArray(extensions) && extensions.length) {
179
+ const ext = (file.name.split('.').pop() || '').toLowerCase();
180
+ if (!extensions.includes(ext)) return `extension .${ext} not allowed`;
181
+ }
182
+ }
183
+ return null;
184
+ }
185
+
186
+ function markValidity(input, msg) {
187
+ const prev = input?.parentElement?.querySelector?.('.error-message');
188
+ if (prev) prev.remove();
189
+
190
+ if (input) {
191
+ input.classList.toggle('border-red-500', !!msg);
192
+ input.classList.toggle('border-gray-300', !msg);
193
+ }
194
+
195
+ if (msg && input?.parentElement) {
196
+ const m = document.createElement('div');
197
+ m.className = 'error-message text-red-500 text-xs mt-1';
198
+ m.textContent = msg;
199
+ input.parentElement.appendChild(m);
200
+ }
201
+ }
202
+
203
+ function makeFieldHint(element, extra = '') {
204
+ const hint = document.createElement('div');
205
+ hint.className = 'text-gray-500 text-xs mt-1';
206
+ const bits = [];
207
+
208
+ if (element.required) bits.push('required');
209
+
210
+ if (element.type === 'text' || element.type === 'textarea') {
211
+ if (element.minLength != null) bits.push(`minLength=${element.minLength}`);
212
+ if (element.maxLength != null) bits.push(`maxLength=${element.maxLength}`);
213
+ if (element.pattern) bits.push(`pattern=/${element.pattern}/`);
214
+ }
215
+ if (element.type === 'number') {
216
+ if (element.min != null) bits.push(`min=${element.min}`);
217
+ if (element.max != null) bits.push(`max=${element.max}`);
218
+ if (element.decimals != null) bits.push(`decimals=${element.decimals}`);
219
+ }
220
+ if (element.type === 'select') {
221
+ bits.push(`${element.options.length} options`);
222
+ }
223
+ if (element.type === 'files') {
224
+ if (element.minCount != null) bits.push(`minCount=${element.minCount}`);
225
+ if (element.maxCount != null) bits.push(`maxCount=${element.maxCount}`);
226
+ }
227
+
228
+ hint.textContent = [bits.join(' • '), extra].filter(Boolean).join(' | ');
229
+ return hint;
230
+ }
231
+
232
+ async function renderFilePreview(container, resourceId, fileName, fileType) {
233
+ container.innerHTML = '';
234
+
235
+ const preview = document.createElement('div');
236
+ preview.className = 'flex items-center gap-3 p-2';
237
+
238
+ // File icon/thumbnail
239
+ const iconContainer = document.createElement('div');
240
+ iconContainer.className = 'w-12 h-12 rounded-lg flex items-center justify-center bg-blue-600 text-white text-xl flex-shrink-0';
241
+
242
+ if (fileType.startsWith('image/')) {
243
+ const img = document.createElement('img');
244
+ img.className = 'w-12 h-12 object-cover rounded-lg';
245
+
246
+ // Try to get thumbnail using custom function or fallback
247
+ if (config.getThumbnail && typeof config.getThumbnail === 'function') {
248
+ try {
249
+ const thumbnailUrl = await config.getThumbnail(resourceId);
250
+ img.src = thumbnailUrl;
251
+ img.onerror = () => { iconContainer.textContent = '🖼️'; };
252
+ iconContainer.innerHTML = '';
253
+ iconContainer.appendChild(img);
254
+ } catch {
255
+ iconContainer.textContent = '🖼️';
256
+ }
257
+ } else {
258
+ iconContainer.textContent = '🖼️';
259
+ }
260
+ } else if (fileType.startsWith('video/')) {
261
+ iconContainer.textContent = '🎥';
262
+ } else if (fileType.includes('pdf')) {
263
+ iconContainer.textContent = '📄';
264
+ } else {
265
+ iconContainer.textContent = '📎';
266
+ }
267
+
268
+ preview.appendChild(iconContainer);
269
+
270
+ // File info
271
+ const info = document.createElement('div');
272
+ info.className = 'flex-1';
273
+
274
+ const name = document.createElement('div');
275
+ name.className = 'font-medium text-sm text-gray-900';
276
+ name.textContent = fileName;
277
+
278
+ const details = document.createElement('div');
279
+ details.className = 'text-xs text-gray-500 mt-1';
280
+ details.textContent = `${fileType} • ${resourceId.slice(0, 12)}...`;
281
+
282
+ info.appendChild(name);
283
+ info.appendChild(details);
284
+ preview.appendChild(info);
285
+
286
+ // Action buttons
287
+ const actions = document.createElement('div');
288
+ actions.className = 'flex gap-2';
289
+
290
+ // Download button
291
+ const downloadBtn = document.createElement('button');
292
+ downloadBtn.className = 'px-2 py-1 text-xs bg-gray-100 hover:bg-gray-200 rounded transition-colors';
293
+ downloadBtn.textContent = '⬇️';
294
+ downloadBtn.title = 'Download';
295
+ downloadBtn.addEventListener('click', async () => {
296
+ if (config.downloadFile && typeof config.downloadFile === 'function') {
297
+ try {
298
+ await config.downloadFile(resourceId, fileName);
299
+ } catch (error) {
300
+ console.error('Download failed:', error);
301
+ }
302
+ } else {
303
+ console.log('Download simulated:', resourceId, fileName);
304
+ }
305
+ });
306
+
307
+ // Remove button
308
+ const removeBtn = document.createElement('button');
309
+ removeBtn.className = 'px-2 py-1 text-xs bg-red-500 hover:bg-red-600 text-white rounded transition-colors';
310
+ removeBtn.textContent = '✕';
311
+ removeBtn.title = 'Remove';
312
+ removeBtn.addEventListener('click', () => {
313
+ const hiddenInput = container.closest('.file-container')?.querySelector('input[type="hidden"]');
314
+ if (hiddenInput) {
315
+ hiddenInput.value = '';
316
+ }
317
+ container.innerHTML = '<div class="flex flex-col items-center justify-center h-full text-gray-400"><div class="text-2xl mb-2">📁</div><div class="text-sm">Click to upload</div></div>';
318
+ });
319
+
320
+ actions.appendChild(downloadBtn);
321
+ actions.appendChild(removeBtn);
322
+ preview.appendChild(actions);
323
+
324
+ container.appendChild(preview);
325
+ }
326
+
327
+ function renderResourcePills(container, rids, onRemove) {
328
+ clear(container);
329
+ container.className = 'flex flex-wrap gap-1.5 mt-2';
330
+
331
+ rids.forEach(rid => {
332
+ const meta = resourceIndex.get(rid);
333
+ const pill = document.createElement('span');
334
+ pill.className = 'inline-flex items-center gap-1.5 bg-blue-50 border border-gray-300 rounded-full px-2.5 py-1 font-mono text-xs m-0.5';
335
+ pill.textContent = rid;
336
+
337
+ if (meta) {
338
+ const small = document.createElement('span');
339
+ small.className = 'text-gray-500';
340
+ small.textContent = ` (${meta.name ?? 'file'}, ${formatFileSize(meta.size ?? 0)})`;
341
+ pill.appendChild(small);
342
+ }
343
+
344
+ if (onRemove) {
345
+ const x = document.createElement('button');
346
+ x.type = 'button';
347
+ x.className = 'bg-red-500 hover:bg-red-600 text-white text-xs px-1.5 py-0.5 rounded ml-1.5';
348
+ x.textContent = '×';
349
+ x.addEventListener('click', () => onRemove(rid));
350
+ pill.appendChild(x);
351
+ }
352
+
353
+ container.appendChild(pill);
354
+ });
355
+ }
356
+
357
+ function renderElement(element, ctx, options = {}) {
358
+ const wrapper = document.createElement('div');
359
+ wrapper.className = 'mb-6';
360
+
361
+ const label = document.createElement('div');
362
+ label.className = 'flex items-center mb-2';
363
+ const title = document.createElement('label');
364
+ title.className = 'text-sm font-medium text-gray-900';
365
+ title.textContent = element.label || element.key;
366
+ if (element.required) {
367
+ const req = document.createElement('span');
368
+ req.className = 'text-red-500 ml-1';
369
+ req.textContent = '*';
370
+ title.appendChild(req);
371
+ }
372
+ label.appendChild(title);
373
+ wrapper.appendChild(label);
374
+
375
+ const pathKey = pathJoin(ctx.path, element.key);
376
+
377
+ switch (element.type) {
378
+ case 'text': {
379
+ const input = document.createElement('input');
380
+ input.type = 'text';
381
+ input.name = pathKey;
382
+ input.dataset.type = 'text';
383
+ input.className = 'w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500';
384
+ setTextValueFromPrefill(input, element, ctx.prefill, element.key);
385
+ input.addEventListener('input', () => markValidity(input, null));
386
+ wrapper.appendChild(input);
387
+ wrapper.appendChild(makeFieldHint(element));
388
+ break;
389
+ }
390
+ case 'textarea': {
391
+ const ta = document.createElement('textarea');
392
+ ta.name = pathKey;
393
+ ta.rows = 4;
394
+ ta.dataset.type = 'textarea';
395
+ ta.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';
396
+ setTextValueFromPrefill(ta, element, ctx.prefill, element.key);
397
+ ta.addEventListener('input', () => markValidity(ta, null));
398
+ wrapper.appendChild(ta);
399
+ wrapper.appendChild(makeFieldHint(element));
400
+ break;
401
+ }
402
+ case 'number': {
403
+ const input = document.createElement('input');
404
+ input.type = 'number';
405
+ input.name = pathKey;
406
+ input.dataset.type = 'number';
407
+ input.className = 'w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500';
408
+ if (element.step != null) input.step = String(element.step);
409
+ if (element.min != null) input.min = String(element.min);
410
+ if (element.max != null) input.max = String(element.max);
411
+ setNumberFromPrefill(input, element, ctx.prefill, element.key);
412
+ input.addEventListener('blur', () => {
413
+ if (input.value === '') return;
414
+ const v = parseFloat(input.value);
415
+ if (Number.isFinite(v) && Number.isInteger(element.decimals ?? 0)) {
416
+ input.value = String(Number(v.toFixed(element.decimals)));
417
+ }
418
+ });
419
+ input.addEventListener('input', () => markValidity(input, null));
420
+ wrapper.appendChild(input);
421
+ wrapper.appendChild(makeFieldHint(element, `decimals=${element.decimals ?? 0}`));
422
+ break;
423
+ }
424
+ case 'select': {
425
+ const sel = document.createElement('select');
426
+ sel.name = pathKey;
427
+ sel.dataset.type = 'select';
428
+ sel.className = 'w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500';
429
+
430
+ if (!element.required) {
431
+ const opt = document.createElement('option');
432
+ opt.value = '';
433
+ opt.textContent = '—';
434
+ sel.appendChild(opt);
435
+ }
436
+
437
+ element.options.forEach(o => {
438
+ const opt = document.createElement('option');
439
+ opt.value = String(o.value);
440
+ opt.textContent = o.label ?? String(o.value);
441
+ sel.appendChild(opt);
442
+ });
443
+
444
+ setSelectFromPrefill(sel, element, ctx.prefill, element.key);
445
+ sel.addEventListener('input', () => markValidity(sel, null));
446
+ wrapper.appendChild(sel);
447
+ break;
448
+ }
449
+ case 'file': {
450
+ const hid = document.createElement('input');
451
+ hid.type = 'hidden';
452
+ hid.name = pathKey;
453
+ hid.dataset.type = 'file';
454
+
455
+ const container = document.createElement('div');
456
+ container.className = 'file-container';
457
+
458
+ // Preview container
459
+ const previewContainer = document.createElement('div');
460
+ previewContainer.className = 'aspect-square w-full max-w-xs bg-gray-100 rounded-lg overflow-hidden border-2 border-dashed border-gray-300 hover:border-gray-400 transition-colors relative group cursor-pointer mb-3';
461
+ previewContainer.onclick = () => picker.click();
462
+
463
+ const picker = document.createElement('input');
464
+ picker.type = 'file';
465
+ if (element.accept?.extensions) {
466
+ picker.accept = element.accept.extensions.map(ext => `.${ext}`).join(',');
467
+ }
468
+
469
+ const handleFileSelect = async (file) => {
470
+ const err = fileValidationError(element, file);
471
+ if (err) {
472
+ markValidity(picker, err);
473
+ return;
474
+ }
475
+
476
+ try {
477
+ let resourceId;
478
+
479
+ // Use custom upload function if provided
480
+ if (config.uploadFile && typeof config.uploadFile === 'function') {
481
+ resourceId = await config.uploadFile(file);
482
+ } else {
483
+ // Fallback to simulated resource ID
484
+ resourceId = await makeResourceIdFromFile(file);
485
+ resourceIndex.set(resourceId, { name: file.name, type: file.type, size: file.size });
486
+ }
487
+
488
+ hid.value = resourceId;
489
+ await renderFilePreview(previewContainer, resourceId, file.name, file.type);
490
+ markValidity(picker, null);
491
+ } catch (error) {
492
+ markValidity(picker, `Upload failed: ${error.message}`);
493
+ }
494
+ };
495
+
496
+ picker.addEventListener('change', async () => {
497
+ if (picker.files && picker.files[0]) {
498
+ await handleFileSelect(picker.files[0]);
499
+ }
500
+ });
501
+
502
+ // Handle prefilled values
503
+ const pv = ctx.prefill && ctx.prefill[element.key];
504
+ if (typeof pv === 'string' && pv) {
505
+ hid.value = pv;
506
+ // Try to render preview for existing resource
507
+ const fileName = `file_${pv.slice(-8)}`;
508
+ renderFilePreview(previewContainer, pv, fileName, 'application/octet-stream');
509
+ } else {
510
+ // Show upload prompt
511
+ previewContainer.innerHTML = '<div class="flex flex-col items-center justify-center h-full text-gray-400"><div class="text-2xl mb-2">📁</div><div class="text-sm">Click to upload</div></div>';
512
+ }
513
+
514
+ const helpText = document.createElement('p');
515
+ helpText.className = 'text-xs text-gray-600 mt-2 text-center';
516
+ helpText.innerHTML = '<span class="underline cursor-pointer">Upload</span> or drag and drop file';
517
+ helpText.onclick = () => picker.click();
518
+
519
+ container.appendChild(previewContainer);
520
+ container.appendChild(helpText);
521
+ container.appendChild(picker);
522
+ container.appendChild(hid);
523
+
524
+ wrapper.appendChild(container);
525
+ wrapper.appendChild(makeFieldHint(element, 'Returns resource ID for download/submission'));
526
+ break;
527
+ }
528
+ case 'files': {
529
+ const hid = document.createElement('input');
530
+ hid.type = 'hidden';
531
+ hid.name = pathKey;
532
+ hid.dataset.type = 'files';
533
+
534
+ const list = document.createElement('div');
535
+ list.className = 'flex flex-wrap gap-1.5 mt-2';
536
+
537
+ const picker = document.createElement('input');
538
+ picker.type = 'file';
539
+ picker.multiple = true;
540
+ if (element.accept?.extensions) {
541
+ picker.accept = element.accept.extensions.map(ext => `.${ext}`).join(',');
542
+ }
543
+
544
+ picker.addEventListener('change', async () => {
545
+ let arr = parseJSONSafe(hid.value, []);
546
+ if (!Array.isArray(arr)) arr = [];
547
+
548
+ if (picker.files && picker.files.length) {
549
+ for (const file of picker.files) {
550
+ const err = fileValidationError(element, file);
551
+ if (err) {
552
+ markValidity(picker, err);
553
+ return;
554
+ }
555
+ }
556
+
557
+ for (const file of picker.files) {
558
+ const rid = await makeResourceIdFromFile(file);
559
+ resourceIndex.set(rid, { name: file.name, type: file.type, size: file.size });
560
+ arr.push(rid);
561
+ }
562
+
563
+ hid.value = JSON.stringify(arr);
564
+ renderResourcePills(list, arr, (ridToRemove) => {
565
+ const next = arr.filter(x => x !== ridToRemove);
566
+ hid.value = JSON.stringify(next);
567
+ arr = next;
568
+ renderResourcePills(list, next, arguments.callee);
569
+ });
570
+ markValidity(picker, null);
571
+ }
572
+ });
573
+
574
+ const pv = ctx.prefill && ctx.prefill[element.key];
575
+ let initial = Array.isArray(pv) ? pv.filter(Boolean) : [];
576
+ if (initial.length) {
577
+ hid.value = JSON.stringify(initial);
578
+ renderResourcePills(list, initial, (ridToRemove) => {
579
+ const next = initial.filter(x => x !== ridToRemove);
580
+ hid.value = JSON.stringify(next);
581
+ initial = next;
582
+ renderResourcePills(list, next, arguments.callee);
583
+ });
584
+ }
585
+
586
+ wrapper.appendChild(picker);
587
+ wrapper.appendChild(list);
588
+ wrapper.appendChild(hid);
589
+ wrapper.appendChild(makeFieldHint(element, 'Multiple files return resource ID array'));
590
+ break;
591
+ }
592
+ case 'group': {
593
+ wrapper.dataset.group = element.key;
594
+ wrapper.dataset.groupPath = pathKey;
595
+
596
+ const groupWrap = document.createElement('div');
597
+
598
+ // Group title (above the whole group)
599
+ const groupTitle = document.createElement('div');
600
+ groupTitle.className = 'text-lg font-semibold text-gray-900 mb-4';
601
+ groupTitle.textContent = element.label || element.key;
602
+ groupWrap.appendChild(groupTitle);
603
+
604
+ const header = document.createElement('div');
605
+ header.className = 'flex items-center justify-between my-2 pb-2 border-b border-gray-200';
606
+
607
+ const left = document.createElement('div');
608
+ header.appendChild(left);
609
+
610
+ const right = document.createElement('div');
611
+ groupWrap.appendChild(header);
612
+
613
+ const itemsWrap = document.createElement('div');
614
+ itemsWrap.dataset.itemsFor = element.key;
615
+
616
+ if (element.repeat && isPlainObject(element.repeat)) {
617
+ const min = element.repeat.min ?? 0;
618
+ const max = element.repeat.max ?? Infinity;
619
+ const pre = Array.isArray(ctx.prefill?.[element.key]) ? ctx.prefill[element.key] : null;
620
+
621
+ const addBtn = document.createElement('button');
622
+ addBtn.type = 'button';
623
+ addBtn.className = 'bg-blue-600 hover:bg-blue-700 text-white px-3 py-1.5 rounded-md text-sm font-medium transition-colors';
624
+ addBtn.textContent = 'Add';
625
+ right.appendChild(addBtn);
626
+ header.appendChild(right);
627
+
628
+ const countItems = () => itemsWrap.querySelectorAll(':scope > .groupItem').length;
629
+ const refreshControls = () => {
630
+ const n = countItems();
631
+ addBtn.disabled = n >= max;
632
+ left.innerHTML = `<span class="text-sm text-gray-600">Items: ${n} / ${max === Infinity ? '∞' : max} (min: ${min})</span>`;
633
+ };
634
+
635
+ const updateItemIndexes = () => {
636
+ const items = itemsWrap.querySelectorAll(':scope > .groupItem');
637
+ items.forEach((item, index) => {
638
+ const titleElement = item.querySelector('h4');
639
+ if (titleElement) {
640
+ let labelText;
641
+ if (element.element_label) {
642
+ labelText = element.element_label.replace('$index', index + 1);
643
+ } else {
644
+ labelText = `${element.label || element.key} #${index + 1}`;
645
+ }
646
+ titleElement.textContent = labelText;
647
+ }
648
+ });
649
+ };
650
+
651
+ const addItem = (prefillObj) => {
652
+ const itemIndex = countItems() + 1;
653
+ const item = document.createElement('div');
654
+ item.className = 'groupItem border border-dashed border-gray-300 rounded-lg p-4 mb-3 bg-blue-50/30';
655
+
656
+ // Individual item title with index
657
+ const itemTitle = document.createElement('div');
658
+ itemTitle.className = 'flex items-center justify-between mb-4 pb-2 border-b border-gray-300';
659
+
660
+ const itemLabel = document.createElement('h4');
661
+ itemLabel.className = 'text-md font-medium text-gray-800';
662
+
663
+ // Use element_label if provided, with $index placeholder support
664
+ let labelText;
665
+ if (element.element_label) {
666
+ labelText = element.element_label.replace('$index', itemIndex);
667
+ } else {
668
+ labelText = `${element.label || element.key} #${itemIndex}`;
669
+ }
670
+ itemLabel.textContent = labelText;
671
+ itemTitle.appendChild(itemLabel);
672
+
673
+ // Add remove button to title
674
+ const rem = document.createElement('button');
675
+ rem.type = 'button';
676
+ rem.className = 'bg-red-500 hover:bg-red-600 text-white px-2 py-1 rounded text-xs font-medium transition-colors';
677
+ rem.textContent = 'Remove';
678
+ rem.addEventListener('click', () => {
679
+ if (countItems() <= (element.repeat.min ?? 0)) return;
680
+ itemsWrap.removeChild(item);
681
+ refreshControls();
682
+ // Re-index remaining items
683
+ updateItemIndexes();
684
+ });
685
+ itemTitle.appendChild(rem);
686
+
687
+ const subCtx = {
688
+ path: pathJoin(ctx.path, element.key + `[${countItems()}]`),
689
+ prefill: prefillObj || {}
690
+ };
691
+
692
+ item.appendChild(itemTitle);
693
+ element.elements.forEach(child => item.appendChild(renderElement(child, subCtx, options)));
694
+ itemsWrap.appendChild(item);
695
+ refreshControls();
696
+ };
697
+
698
+ groupWrap.appendChild(itemsWrap);
699
+
700
+ if (pre && pre.length) {
701
+ const n = Math.min(max, Math.max(min, pre.length));
702
+ for (let i = 0; i < n; i++) addItem(pre[i]);
703
+ } else {
704
+ const n = Math.max(min, 0);
705
+ for (let i = 0; i < n; i++) addItem(null);
706
+ }
707
+
708
+ addBtn.addEventListener('click', () => addItem(null));
709
+ } else {
710
+ // Single object group
711
+ const subCtx = {
712
+ path: pathJoin(ctx.path, element.key),
713
+ prefill: ctx.prefill?.[element.key] || {}
714
+ };
715
+ element.elements.forEach(child => itemsWrap.appendChild(renderElement(child, subCtx, options)));
716
+ groupWrap.appendChild(itemsWrap);
717
+ }
718
+
719
+ wrapper.innerHTML = '';
720
+ wrapper.appendChild(groupWrap);
721
+ break;
722
+ }
723
+ default:
724
+ wrapper.appendChild(document.createTextNode(`Unsupported type: ${element.type}`));
725
+ }
726
+
727
+ return wrapper;
728
+ }
729
+
730
+ // Form data collection and validation
731
+ function collectAndValidate(schema, formRoot, skipValidation = false) {
732
+ const errors = [];
733
+
734
+ function collectElement(element, scopeRoot, elementPath = '') {
735
+ const key = element.key;
736
+ const fullPath = elementPath ? `${elementPath}.${key}` : key;
737
+
738
+ switch (element.type) {
739
+ case 'text':
740
+ case 'textarea': {
741
+ const input = scopeRoot.querySelector(`[name$="${key}"]`);
742
+ const val = (input?.value ?? '').trim();
743
+ if (!skipValidation && element.required && val === '') {
744
+ errors.push(`Field "${fullPath}" is required`);
745
+ markValidity(input, 'required');
746
+ } else if (!skipValidation && val !== '') {
747
+ if (element.minLength != null && val.length < element.minLength) {
748
+ errors.push(`Field "${fullPath}" must be at least ${element.minLength} characters`);
749
+ markValidity(input, `minLength=${element.minLength}`);
750
+ }
751
+ if (element.maxLength != null && val.length > element.maxLength) {
752
+ errors.push(`Field "${fullPath}" must be at most ${element.maxLength} characters`);
753
+ markValidity(input, `maxLength=${element.maxLength}`);
754
+ }
755
+ if (element.pattern) {
756
+ try {
757
+ const re = new RegExp(element.pattern);
758
+ if (!re.test(val)) {
759
+ errors.push(`Field "${fullPath}" does not match required format`);
760
+ markValidity(input, 'pattern mismatch');
761
+ }
762
+ } catch {
763
+ errors.push(`Field "${fullPath}" has invalid validation pattern`);
764
+ markValidity(input, 'invalid pattern');
765
+ }
766
+ }
767
+ } else if (skipValidation) {
768
+ markValidity(input, null);
769
+ } else {
770
+ markValidity(input, null);
771
+ }
772
+ return val;
773
+ }
774
+ case 'number': {
775
+ const input = scopeRoot.querySelector(`[name$="${key}"]`);
776
+ const raw = input?.value ?? '';
777
+ if (!skipValidation && element.required && raw === '') {
778
+ errors.push(`${key}: required`);
779
+ markValidity(input, 'required');
780
+ return null;
781
+ }
782
+ if (raw === '') {
783
+ markValidity(input, null);
784
+ return null;
785
+ }
786
+ const v = parseFloat(raw);
787
+ if (!skipValidation && !Number.isFinite(v)) {
788
+ errors.push(`${key}: not a number`);
789
+ markValidity(input, 'not a number');
790
+ return null;
791
+ }
792
+ if (!skipValidation && element.min != null && v < element.min) {
793
+ errors.push(`${key}: < min=${element.min}`);
794
+ markValidity(input, `< min=${element.min}`);
795
+ }
796
+ if (!skipValidation && element.max != null && v > element.max) {
797
+ errors.push(`${key}: > max=${element.max}`);
798
+ markValidity(input, `> max=${element.max}`);
799
+ }
800
+ const d = Number.isInteger(element.decimals ?? 0) ? element.decimals : 0;
801
+ const r = Number(v.toFixed(d));
802
+ input.value = String(r);
803
+ markValidity(input, null);
804
+ return r;
805
+ }
806
+ case 'select': {
807
+ const sel = scopeRoot.querySelector(`select[name$="${key}"]`);
808
+ const val = sel?.value ?? '';
809
+ const values = new Set(element.options.map(o => String(o.value)));
810
+ if (!skipValidation && element.required && val === '') {
811
+ errors.push(`${key}: required`);
812
+ markValidity(sel, 'required');
813
+ return '';
814
+ }
815
+ if (!skipValidation && val !== '' && !values.has(String(val))) {
816
+ errors.push(`${key}: value not in options`);
817
+ markValidity(sel, 'not in options');
818
+ } else {
819
+ markValidity(sel, null);
820
+ }
821
+ return val === '' ? null : val;
822
+ }
823
+ case 'file': {
824
+ const hid = scopeRoot.querySelector(`input[type="hidden"][name$="${key}"]`);
825
+ const rid = hid?.value ?? '';
826
+ if (!skipValidation && element.required && !rid) {
827
+ errors.push(`${key}: required (file missing)`);
828
+ const picker = hid?.previousElementSibling;
829
+ if (picker) markValidity(picker, 'required');
830
+ } else {
831
+ if (hid?.previousElementSibling) markValidity(hid.previousElementSibling, null);
832
+ }
833
+ return rid || null;
834
+ }
835
+ case 'files': {
836
+ const hid = scopeRoot.querySelector(`input[type="hidden"][name$="${key}"]`);
837
+ const arr = parseJSONSafe(hid?.value ?? '[]', []);
838
+ const count = Array.isArray(arr) ? arr.length : 0;
839
+ if (!skipValidation && !Array.isArray(arr)) errors.push(`${key}: internal value corrupted`);
840
+ if (!skipValidation && element.minCount != null && count < element.minCount) {
841
+ errors.push(`${key}: < minCount=${element.minCount}`);
842
+ }
843
+ if (!skipValidation && element.maxCount != null && count > element.maxCount) {
844
+ errors.push(`${key}: > maxCount=${element.maxCount}`);
845
+ }
846
+ if (hid?.previousElementSibling) markValidity(hid.previousElementSibling, null);
847
+ return Array.isArray(arr) ? arr : [];
848
+ }
849
+ case 'group': {
850
+ const groupWrapper = scopeRoot.querySelector(`[data-group="${key}"]`);
851
+ if (!groupWrapper) {
852
+ errors.push(`${key}: internal group wrapper not found`);
853
+ return element.repeat ? [] : {};
854
+ }
855
+ const itemsWrap = groupWrapper.querySelector(`[data-items-for="${key}"]`);
856
+ if (!itemsWrap) {
857
+ errors.push(`${key}: internal items container not found`);
858
+ return element.repeat ? [] : {};
859
+ }
860
+
861
+ if (element.repeat && isPlainObject(element.repeat)) {
862
+ const items = itemsWrap.querySelectorAll(':scope > .groupItem');
863
+ const out = [];
864
+ const n = items.length;
865
+ const min = element.repeat.min ?? 0;
866
+ const max = element.repeat.max ?? Infinity;
867
+ if (!skipValidation && n < min) errors.push(`${key}: count < min=${min}`);
868
+ if (!skipValidation && n > max) errors.push(`${key}: count > max=${max}`);
869
+ items.forEach((item, index) => {
870
+ const obj = {};
871
+ element.elements.forEach(child => {
872
+ obj[child.key] = collectElement(child, item, `${fullPath}[${index}]`);
873
+ });
874
+ out.push(obj);
875
+ });
876
+ return out;
877
+ } else {
878
+ const obj = {};
879
+ element.elements.forEach(child => {
880
+ obj[child.key] = collectElement(child, itemsWrap, fullPath);
881
+ });
882
+ return obj;
883
+ }
884
+ }
885
+ default:
886
+ errors.push(`${key}: unsupported type ${element.type}`);
887
+ return null;
888
+ }
889
+ }
890
+
891
+ const result = {};
892
+ schema.elements.forEach(element => {
893
+ result[element.key] = collectElement(element, formRoot, '');
894
+ });
895
+
896
+ return { result, errors };
897
+ }
898
+
899
+ // Main form rendering function
900
+ function renderForm(schema, container, options = {}) {
901
+ const {
902
+ prefill = {},
903
+ readonly = false,
904
+ debug = false,
905
+ onSubmit,
906
+ onDraft,
907
+ onError,
908
+ buttons = {}
909
+ } = options;
910
+
911
+ if (debug) {
912
+ console.log('[FormBuilder Debug] Rendering form with schema:', schema);
913
+ console.log('[FormBuilder Debug] Options:', options);
914
+ }
915
+
916
+ // Validate schema first
917
+ const schemaErrors = validateSchema(schema);
918
+ if (schemaErrors.length > 0) {
919
+ if (onError) onError(schemaErrors);
920
+ throw new Error('Schema validation failed: ' + schemaErrors.join(', '));
921
+ }
922
+
923
+ // Clear container
924
+ clear(container);
925
+
926
+ // Create form element
927
+ const formEl = document.createElement('form');
928
+ formEl.addEventListener('submit', (e) => e.preventDefault());
929
+
930
+ const ctx = { path: '', prefill: prefill || {} };
931
+ schema.elements.forEach(element => {
932
+ const block = renderElement(element, ctx, { readonly });
933
+ formEl.appendChild(block);
934
+ });
935
+
936
+ // Add action buttons if not readonly
937
+ if (!readonly) {
938
+ const buttonContainer = document.createElement('div');
939
+ buttonContainer.className = 'flex gap-2 pt-4 border-t border-gray-200 mt-6';
940
+
941
+ const submitBtn = document.createElement('button');
942
+ submitBtn.type = 'button';
943
+ submitBtn.className = 'bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors';
944
+ submitBtn.textContent = buttons.submit || 'Submit Form';
945
+ submitBtn.addEventListener('click', () => {
946
+ if (debug) console.log('[FormBuilder Debug] Submit button clicked');
947
+ const { result, errors } = collectAndValidate(schema, formEl, false);
948
+ if (debug) console.log('[FormBuilder Debug] Validation result:', { result, errors });
949
+ if (errors.length > 0) {
950
+ if (onError) onError(errors);
951
+ } else {
952
+ if (onSubmit) onSubmit(result);
953
+ }
954
+ });
955
+
956
+ const draftBtn = document.createElement('button');
957
+ draftBtn.type = 'button';
958
+ draftBtn.className = 'bg-gray-600 text-white px-4 py-2 rounded-lg hover:bg-gray-700 transition-colors';
959
+ draftBtn.textContent = buttons.draft || 'Save Draft';
960
+ draftBtn.addEventListener('click', () => {
961
+ if (debug) console.log('[FormBuilder Debug] Draft button clicked');
962
+ const { result } = collectAndValidate(schema, formEl, true); // Skip validation for drafts
963
+ if (debug) console.log('[FormBuilder Debug] Draft result:', result);
964
+ if (onDraft) onDraft(result);
965
+ });
966
+
967
+ buttonContainer.appendChild(submitBtn);
968
+ buttonContainer.appendChild(draftBtn);
969
+ formEl.appendChild(buttonContainer);
970
+ }
971
+
972
+ container.appendChild(formEl);
973
+ return formEl;
974
+ }
975
+
976
+ // Configuration functions
977
+ function setConfig(newConfig) {
978
+ Object.assign(config, newConfig);
979
+ }
980
+
981
+ function setUploadHandler(uploadFn) {
982
+ config.uploadFile = uploadFn;
983
+ }
984
+
985
+ function setDownloadHandler(downloadFn) {
986
+ config.downloadFile = downloadFn;
987
+ }
988
+
989
+ function setThumbnailHandler(thumbnailFn) {
990
+ config.getThumbnail = thumbnailFn;
991
+ }
992
+
993
+ // Generate prefill template
994
+ function generatePrefillTemplate(schema) {
995
+ function walk(elements) {
996
+ const obj = {};
997
+ for (const el of elements) {
998
+ switch (el.type) {
999
+ case 'text':
1000
+ case 'textarea':
1001
+ case 'select':
1002
+ case 'number':
1003
+ obj[el.key] = el.default ?? null;
1004
+ break;
1005
+ case 'file':
1006
+ obj[el.key] = null;
1007
+ break;
1008
+ case 'files':
1009
+ obj[el.key] = [];
1010
+ break;
1011
+ case 'group':
1012
+ if (el.repeat && isPlainObject(el.repeat)) {
1013
+ const sample = walk(el.elements);
1014
+ const n = Math.max(el.repeat.min ?? 0, 1);
1015
+ obj[el.key] = Array.from({ length: n }, () => deepClone(sample));
1016
+ } else {
1017
+ obj[el.key] = walk(el.elements);
1018
+ }
1019
+ break;
1020
+ default:
1021
+ obj[el.key] = null;
1022
+ }
1023
+ }
1024
+ return obj;
1025
+ }
1026
+ return walk(schema.elements);
1027
+ }
1028
+
1029
+ // Public API
1030
+ exports.renderForm = renderForm;
1031
+ exports.validateSchema = validateSchema;
1032
+ exports.collectAndValidate = collectAndValidate;
1033
+ exports.generatePrefillTemplate = generatePrefillTemplate;
1034
+ exports.setConfig = setConfig;
1035
+ exports.setUploadHandler = setUploadHandler;
1036
+ exports.setDownloadHandler = setDownloadHandler;
1037
+ exports.setThumbnailHandler = setThumbnailHandler;
1038
+ exports.version = '0.1.5';
1039
+
1040
+ }));