@doyosi/laraisy 1.0.2 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/LICENSE +1 -1
  2. package/package.json +1 -1
  3. package/src/CodeInput.js +48 -48
  4. package/src/DSAlert.js +352 -352
  5. package/src/DSAvatar.js +207 -207
  6. package/src/DSDelete.js +274 -274
  7. package/src/DSForm.js +568 -568
  8. package/src/DSGridOrTable.js +453 -453
  9. package/src/DSLocaleSwitcher.js +239 -239
  10. package/src/DSLogout.js +293 -293
  11. package/src/DSNotifications.js +365 -365
  12. package/src/DSRestore.js +181 -181
  13. package/src/DSSelect.js +1071 -1071
  14. package/src/DSSelectBox.js +563 -563
  15. package/src/DSSimpleSlider.js +517 -517
  16. package/src/DSSvgFetch.js +69 -69
  17. package/src/DSTable/DSTableExport.js +68 -68
  18. package/src/DSTable/DSTableFilter.js +224 -224
  19. package/src/DSTable/DSTablePagination.js +136 -136
  20. package/src/DSTable/DSTableSearch.js +40 -40
  21. package/src/DSTable/DSTableSelection.js +192 -192
  22. package/src/DSTable/DSTableSort.js +58 -58
  23. package/src/DSTable.js +353 -353
  24. package/src/DSTabs.js +488 -488
  25. package/src/DSUpload.js +887 -887
  26. package/dist/CodeInput.d.ts +0 -10
  27. package/dist/DSAlert.d.ts +0 -112
  28. package/dist/DSAvatar.d.ts +0 -45
  29. package/dist/DSDelete.d.ts +0 -61
  30. package/dist/DSForm.d.ts +0 -151
  31. package/dist/DSGridOrTable/DSGOTRenderer.d.ts +0 -60
  32. package/dist/DSGridOrTable/DSGOTViewToggle.d.ts +0 -26
  33. package/dist/DSGridOrTable.d.ts +0 -296
  34. package/dist/DSLocaleSwitcher.d.ts +0 -71
  35. package/dist/DSLogout.d.ts +0 -76
  36. package/dist/DSNotifications.d.ts +0 -54
  37. package/dist/DSRestore.d.ts +0 -56
  38. package/dist/DSSelect.d.ts +0 -221
  39. package/dist/DSSelectBox.d.ts +0 -123
  40. package/dist/DSSimpleSlider.d.ts +0 -136
  41. package/dist/DSSvgFetch.d.ts +0 -17
  42. package/dist/DSTable/DSTableExport.d.ts +0 -11
  43. package/dist/DSTable/DSTableFilter.d.ts +0 -40
  44. package/dist/DSTable/DSTablePagination.d.ts +0 -12
  45. package/dist/DSTable/DSTableSearch.d.ts +0 -8
  46. package/dist/DSTable/DSTableSelection.d.ts +0 -46
  47. package/dist/DSTable/DSTableSort.d.ts +0 -8
  48. package/dist/DSTable.d.ts +0 -116
  49. package/dist/DSTabs.d.ts +0 -156
  50. package/dist/DSUpload.d.ts +0 -220
  51. package/dist/index.d.ts +0 -17
package/src/DSForm.js CHANGED
@@ -1,569 +1,569 @@
1
- /**
2
- * DSForm (with DSAlert integration)
3
- *
4
- * A modern form handler that includes:
5
- * - Integration with DSAlert for Toasts and Modals.
6
- * - AJAX submission via 'fetch' | 'axios' | 'xhr'.
7
- * - Laravel-style error mapping (dot/bracket notation).
8
- * - Lifecycle hooks (onBeforeSubmit, onSubmit, onSuccess, onError, onComplete).
9
- * - Automatic disable/loading states.
10
- * - Data injection before submit.
11
- */
12
- import { DSAlert } from './DSAlert.js';
13
-
14
- export class DSForm {
15
- /**
16
- * @param {Object} config - Configuration object.
17
- * @param {HTMLFormElement|string} config.form - Form element or selector.
18
- * @param {string} [config.url] - Submit URL (defaults to form.action or current url).
19
- * @param {'post'|'put'} [config.method] - HTTP method.
20
- * @param {Array<string|HTMLElement>} [config.triggers] - External triggers (selectors/elements).
21
- * @param {'fetch'|'axios'|'xhr'} [config.requestLib] - Preferred request lib; gracefully falls back.
22
- * @param {Object} [config.headers] - Extra headers.
23
- * @param {Object|Function} [config.additionalData] - Extra data merged before submit.
24
- * @param {Array<string>} [config.disableSelectors] - Extra selectors to disable during submit.
25
- * @param {Array<string>} [config.excludeEnableSelectors] - Keep these disabled after complete.
26
- * @param {string} [config.primaryButtonSelector='button[type="submit"]'] - For default loading target.
27
- * @param {(Function|string)} [config.loadingTemplate] - Function/Selector/Raw HTML for loading state.
28
- * @param {Object} [config.translations] - i18n map for user texts.
29
- * @param {Object} [config.toast] - Toast options { enabled, position, timer, timerProgressBar }.
30
- * @param {boolean} [config.autoRedirect=true] - Follow payload.redirect if present.
31
- * @param {boolean} [config.disableOnSuccess=false] - Keep all controls disabled if the request succeeds.
32
- * @param {Function} [config.onBeforeSubmit]
33
- * @param {Function} [config.onSubmit]
34
- * @param {Function} [config.onSuccess]
35
- * @param {Function} [config.onError]
36
- * @param {Function} [config.onComplete]
37
- * @param {string} [config.successTitle] - Title for success toast (overrides payload.message).
38
- * @param {string} [config.errorTitle] - Title for error toast (overrides payload.message).
39
- */
40
- constructor(config = {}) {
41
- const defaults = {
42
- requestLib: 'fetch',
43
- primaryButtonSelector: 'button[type="submit"]',
44
- translations: {
45
- loading: 'Loading...',
46
- networkError: 'Network error.',
47
- unknownError: 'Something went wrong.',
48
- success: 'Success!',
49
- error: 'There was a problem.'
50
- },
51
- // Removed useSwal, added standard defaults compatible with DSAlert
52
- toast: { enabled: false, position: 'top-end', timer: 3000, timerProgressBar: true },
53
- autoRedirect: true,
54
- disableOnSuccess: false,
55
- successTitle: null,
56
- errorTitle: null,
57
- };
58
-
59
- this.cfg = { ...defaults, ...config };
60
-
61
- this.form = typeof this.cfg.form === 'string' ? document.querySelector(this.cfg.form) : this.cfg.form;
62
- if (!this.form) throw new Error('DSForm: form not found.');
63
-
64
- this.url = this.cfg.url || this.form.getAttribute('action') || window.location.href;
65
- this.method = (this.cfg.method || this.form.getAttribute('method') || 'post').toLowerCase();
66
-
67
- this.triggers = (this.cfg.triggers || [])
68
- .map(t => (typeof t === 'string' ? document.querySelector(t) : t))
69
- .filter(Boolean);
70
-
71
- this._activeTrigger = null;
72
- this._disabledEls = new Set();
73
-
74
- // Mini event emitter
75
- this._listeners = {};
76
-
77
- // Bind handlers once (per instance)
78
- this._onFormSubmit = this._onFormSubmit.bind(this);
79
- this._onExternalTrigger = this._onExternalTrigger.bind(this);
80
- }
81
-
82
- /** Attach listeners to form and external triggers */
83
- bind() {
84
- this.form.addEventListener('submit', this._onFormSubmit);
85
- for (const el of this.triggers) el.addEventListener('click', this._onExternalTrigger);
86
- return this;
87
- }
88
-
89
- /** Detach listeners */
90
- unbind() {
91
- this.form.removeEventListener('submit', this._onFormSubmit);
92
- for (const el of this.triggers) el.removeEventListener('click', this._onExternalTrigger);
93
- return this;
94
- }
95
-
96
- /** Emitter: subscribe to internal events (and mirrored DOM CustomEvents) */
97
- on(event, handler) {
98
- if (!this._listeners[event]) this._listeners[event] = new Set();
99
- this._listeners[event].add(handler);
100
- return this;
101
- }
102
-
103
- /** Emitter: unsubscribe */
104
- off(event, handler) {
105
- if (this._listeners[event]) this._listeners[event].delete(handler);
106
- return this;
107
- }
108
-
109
- /** Programmatically submit (optional). Runs native validation via requestSubmit if supported. */
110
- submit({ trigger = null } = {}) {
111
- this._activeTrigger = trigger instanceof HTMLElement ? trigger : null;
112
- if (typeof this.form.requestSubmit === 'function') {
113
- const btn = this._activeTrigger?.type === 'submit' ? this._activeTrigger : this.form.querySelector(this.cfg.primaryButtonSelector);
114
- btn ? this.form.requestSubmit(btn) : this.form.requestSubmit();
115
- } else {
116
- this.form.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true }));
117
- }
118
- }
119
-
120
- /** Public helper to merge extra data into request payload creation pipeline */
121
- addData(extra = {}) {
122
- if (typeof this.cfg.additionalData === 'function') {
123
- const prev = this.cfg.additionalData;
124
- this.cfg.additionalData = (data) => ({ ...prev(data), ...extra });
125
- } else if (this.cfg.additionalData && typeof this.cfg.additionalData === 'object') {
126
- this.cfg.additionalData = { ...this.cfg.additionalData, ...extra };
127
- } else {
128
- this.cfg.additionalData = { ...extra };
129
- }
130
- }
131
-
132
- // ================= Internal =================
133
-
134
- _onExternalTrigger(e) {
135
- e.preventDefault();
136
- const trigger = e.currentTarget;
137
- this.submit({ trigger });
138
- }
139
-
140
- async _onFormSubmit(e) {
141
- e.preventDefault();
142
-
143
- // Reset field errors each submit
144
- this._resetErrors();
145
-
146
- const { body, isFormData, jsonSnapshot } = this._buildBody();
147
- const headers = this._buildHeaders(isFormData);
148
- const controller = new AbortController();
149
-
150
- // Hook: before
151
- try { this.cfg.onBeforeSubmit?.({ formData: isFormData ? body : null, json: isFormData ? null : jsonSnapshot }); } catch (err) { console.warn('onBeforeSubmit error:', err); }
152
-
153
- // Disable UI + loading
154
- const loadingText = this._t('loading');
155
- this._disableAll(loadingText);
156
-
157
- this._emit('submit:start', { controller });
158
- try { this.cfg.onSubmit?.({ controller }); } catch (err) { console.warn('onSubmit error:', err); }
159
-
160
- let ok = false; let response, data;
161
-
162
- try {
163
- ({ response, data } = await this._sendRequest({ body, headers, signal: controller.signal, isFormData }));
164
- ok = response.ok;
165
-
166
- if (ok) {
167
- this._emit('success', { response, data });
168
- try { this.cfg.onSuccess?.({ response, data }); } catch (err) { console.warn('onSuccess error:', err); }
169
- this._toast(this.cfg.successTitle || data?.message || this._t('success'), 'success');
170
- if (this.cfg.autoRedirect) {
171
- const payload = data?.data ?? data; // Laravel style
172
- const url = payload?.redirect || payload?.url;
173
- if (url) window.location.assign(url);
174
- }
175
- } else {
176
- this._renderErrors(data);
177
- this._emit('error', { response, data });
178
- try { this.cfg.onError?.({ response, data }); } catch (err) { console.warn('onError error:', err); }
179
- this._toast(this.cfg.errorTitle || data?.message || this._t('unknownError'), 'error');
180
- }
181
- } catch (err) {
182
- console.error(err);
183
- this._renderGeneralError(this._t('networkError'));
184
- this._emit('error', { response: null, data: null, error: err });
185
- try { this.cfg.onError?.({ response: null, data: null, error: err }); } catch (e2) { console.warn('onError error:', e2); }
186
- this._toast(this._t('networkError'), 'error');
187
- } finally {
188
- // Only re-enable if not configured to keep disabled on success
189
- if (!(this.cfg.disableOnSuccess && ok)) {
190
- this._enableAll();
191
- }
192
- this._emit('submit:complete', { ok });
193
- try { this.cfg.onComplete?.({ ok }); } catch (err) { console.warn('onComplete error:', err); }
194
- }
195
- }
196
-
197
- _buildBody() {
198
- const formData = new FormData(this.form);
199
-
200
- // Merge additional data
201
- let extra = this.cfg.additionalData || null;
202
- if (typeof extra === 'function') extra = extra(Object.fromEntries(formData.entries())) || null;
203
- if (extra && typeof extra === 'object') {
204
- for (const [k, v] of Object.entries(extra)) this._appendToFormData(formData, k, v);
205
- }
206
-
207
- const hasFile = Array.from(this.form.querySelectorAll('input[type="file"]')).some(i => i.files && i.files.length > 0);
208
- const enctype = (this.form.getAttribute('enctype') || '').toLowerCase();
209
-
210
- if (hasFile || enctype.includes('multipart')) {
211
- return { body: formData, isFormData: true, jsonSnapshot: null };
212
- }
213
-
214
- const json = this._formDataToJSON(formData);
215
- return { body: JSON.stringify(json), isFormData: false, jsonSnapshot: json };
216
- }
217
-
218
- _appendToFormData(fd, key, val) {
219
- if (val === undefined) return;
220
- if (val === null) { fd.append(key, ''); return; }
221
- if (Array.isArray(val)) { for (const item of val) fd.append(this._ensureArrayKey(key), item); return; }
222
- if (typeof val === 'object') { for (const [k, v] of Object.entries(val)) this._appendToFormData(fd, `${key}[${k}]`, v); return; }
223
- fd.append(key, String(val));
224
- }
225
-
226
- _ensureArrayKey(key) { return key.endsWith('[]') ? key : `${key}[]`; }
227
-
228
- _formDataToJSON(fd) {
229
- const obj = {};
230
- for (const [k, v] of fd.entries()) this._assignDeep(obj, k, v);
231
- return obj;
232
- }
233
-
234
- _assignDeep(obj, path, value) {
235
- const parts = this._parsePath(path);
236
- let cur = obj;
237
- for (let i = 0; i < parts.length; i++) {
238
- const p = parts[i];
239
- const last = i === parts.length - 1;
240
- if (last) {
241
- if (p === '[]') {
242
- if (!Array.isArray(cur)) throw new Error(`Path misuse at ${path}`);
243
- cur.push(value);
244
- } else if (p.endsWith('[]')) {
245
- const key = p.slice(0, -2);
246
- if (!Array.isArray(cur[key])) cur[key] = [];
247
- cur[key].push(value);
248
- } else {
249
- cur[p] = value;
250
- }
251
- } else {
252
- if (p.endsWith('[]')) {
253
- const key = p.slice(0, -2);
254
- if (!Array.isArray(cur[key])) cur[key] = [];
255
- if (cur[key].length === 0 || typeof cur[key][cur[key].length - 1] !== 'object') cur[key].push({});
256
- cur = cur[key][cur.length - 1];
257
- } else {
258
- if (!cur[p] || typeof cur[p] !== 'object') cur[p] = {};
259
- cur = cur[p];
260
- }
261
- }
262
- }
263
- }
264
-
265
- _parsePath(path) {
266
- const parts = [];
267
-
268
- // Check if path ends with [] (array notation like tags[], categories[])
269
- const isArrayField = path.endsWith('[]');
270
-
271
- // Remove trailing [] temporarily for parsing
272
- let cleanPath = isArrayField ? path.slice(0, -2) : path;
273
-
274
- // Parse the path: field[nested][key] -> [field, nested, key]
275
- cleanPath.replace(/\]/g, '').split('[').forEach(p => {
276
- if (p) parts.push(p);
277
- });
278
-
279
- // If it was an array field, add [] to the last part
280
- if (isArrayField && parts.length > 0) {
281
- parts[parts.length - 1] = parts[parts.length - 1] + '[]';
282
- }
283
-
284
- return parts;
285
- }
286
-
287
- _buildHeaders(isFormData) {
288
- const headers = { ...(this.cfg.headers || {}) };
289
-
290
- // CSRF from meta or hidden input
291
- const csrf =
292
- document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') ||
293
- this.form.querySelector('input[name="_token"]')?.value ||
294
- null;
295
-
296
- if (csrf) headers['X-CSRF-TOKEN'] = csrf;
297
- if (!isFormData) headers['Content-Type'] = headers['Content-Type'] || 'application/json';
298
- headers['Accept'] = headers['Accept'] || 'application/json';
299
- return headers;
300
- }
301
-
302
- async _sendRequest({ body, headers, signal, isFormData }) {
303
- const url = this.url;
304
- const method = this.method;
305
- const lib = (this.cfg.requestLib || 'fetch').toLowerCase();
306
-
307
- // axios path
308
- if (lib === 'axios' && window.axios) {
309
- const resp = await window.axios({
310
- url,
311
- method,
312
- data: isFormData ? body : JSON.parse(body || '{}'),
313
- headers,
314
- signal,
315
- }).catch(err => (err.response ? err.response : Promise.reject(err)));
316
-
317
- const ok = (resp.status >= 200 && resp.status < 300);
318
- const data = resp.data && typeof resp.data === 'object' ? resp.data : {};
319
- return { response: { ok, status: resp.status }, data };
320
- }
321
-
322
- // fetch path (default & axios fallback)
323
- if (window.fetch) {
324
- const resp = await fetch(url, { method, headers, body, signal, credentials: 'same-origin' });
325
- let data = null; try { data = await resp.json(); } catch { data = {}; }
326
- return { response: resp, data };
327
- }
328
-
329
- // xhr fallback
330
- const { response, data } = await this._xhr({ url, method, headers, body, isFormData, signal });
331
- return { response, data };
332
- }
333
-
334
- _xhr({ url, method, headers, body, isFormData, signal }) {
335
- return new Promise((resolve, reject) => {
336
- const xhr = new XMLHttpRequest();
337
- xhr.open(method.toUpperCase(), url, true);
338
- xhr.responseType = 'json';
339
- for (const [k, v] of Object.entries(headers || {})) xhr.setRequestHeader(k, v);
340
- xhr.onload = () => {
341
- const ok = xhr.status >= 200 && xhr.status < 300;
342
- resolve({ response: { ok, status: xhr.status }, data: xhr.response || {} });
343
- };
344
- xhr.onerror = () => reject(new Error('XHR network error'));
345
- if (signal) signal.addEventListener('abort', () => { try { xhr.abort(); } catch { } reject(new Error('Aborted')); });
346
- isFormData ? xhr.send(body) : xhr.send(body || null);
347
- });
348
- }
349
-
350
- _disableAll(loadingText) {
351
- this._disabledEls.clear();
352
-
353
- // Disable all form elements
354
- const elements = Array.from(this.form.querySelectorAll('input, select, textarea, button, a, [tabindex]'));
355
- const extra = (this.cfg.disableSelectors || []).flatMap(sel => Array.from(document.querySelectorAll(sel)));
356
-
357
- // Include the active trigger if present
358
- const setToDisable = new Set([...elements, ...extra, ...(this._activeTrigger ? [this._activeTrigger] : [])]);
359
-
360
- for (const el of setToDisable) {
361
- if (!(el instanceof HTMLElement)) continue;
362
-
363
- // Store previous state for restore
364
- if (!el.dataset._dsfDisabled) el.dataset._dsfDisabled = el.hasAttribute('disabled') ? '1' : '0';
365
- if (!el.dataset._dsfHTML) el.dataset._dsfHTML = el.innerHTML || '';
366
-
367
- // Disable
368
- if (el.tagName === 'A') {
369
- el.dataset._dsfHref = el.getAttribute('href') || '';
370
- el.setAttribute('href', 'javascript:void(0)');
371
- el.classList.add('pointer-events-none');
372
- }
373
- el.setAttribute('disabled', 'disabled');
374
- el.classList.add('disabled');
375
-
376
- this._disabledEls.add(el);
377
- }
378
-
379
- // Loading UI on the most relevant button/trigger
380
- const targetBtn =
381
- this._activeTrigger && this._isButtonLike(this._activeTrigger)
382
- ? this._activeTrigger
383
- : this.form.querySelector(this.cfg.primaryButtonSelector);
384
-
385
- if (targetBtn && targetBtn instanceof HTMLElement) {
386
- targetBtn.innerHTML = this._resolveTemplate(this.cfg.loadingTemplate, loadingText) ||
387
- `<span class="loading loading-bars loading-md"></span><span>${loadingText}</span>`;
388
- }
389
- }
390
-
391
- _enableAll() {
392
- const exclude = new Set((this.cfg.excludeEnableSelectors || [])
393
- .flatMap(sel => Array.from(document.querySelectorAll(sel))));
394
-
395
- for (const el of this._disabledEls) {
396
- if (!(el instanceof HTMLElement)) continue;
397
- if (exclude.has(el)) continue;
398
-
399
- // Restore href for <a>
400
- if (el.tagName === 'A') {
401
- if (el.dataset._dsfHref !== undefined) el.setAttribute('href', el.dataset._dsfHref);
402
- el.classList.remove('pointer-events-none');
403
- }
404
-
405
- // Restore disabled state only if we disabled it
406
- if (el.dataset._dsfDisabled === '0') {
407
- el.removeAttribute('disabled');
408
- el.classList.remove('disabled');
409
- } else {
410
- // It was already disabled before; keep it disabled
411
- el.classList.add('disabled');
412
- }
413
-
414
- // Restore innerHTML if we changed it
415
- if (el.dataset._dsfHTML !== undefined && this._isButtonLike(el)) {
416
- el.innerHTML = el.dataset._dsfHTML;
417
- }
418
-
419
- // Cleanup datasets
420
- delete el.dataset._dsfDisabled;
421
- delete el.dataset._dsfHTML;
422
- delete el.dataset._dsfHref;
423
- }
424
-
425
- this._disabledEls.clear();
426
- this._activeTrigger = null;
427
- }
428
-
429
- _isButtonLike(el) {
430
- if (!(el instanceof HTMLElement)) return false;
431
- const tag = el.tagName.toLowerCase();
432
- if (tag === 'button') return true;
433
- if (tag === 'a') return true;
434
- if (el.getAttribute('role') === 'button') return true;
435
- return false;
436
- }
437
-
438
- _resolveTemplate(tpl, text) {
439
- if (!tpl) return '';
440
- if (typeof tpl === 'function') return tpl(text);
441
- if (typeof tpl === 'string') {
442
- if (tpl.trim().startsWith('#')) {
443
- const node = document.querySelector(tpl);
444
- return node ? node.innerHTML : '';
445
- }
446
- return tpl; // raw HTML
447
- }
448
- return '';
449
- }
450
-
451
- _resetErrors() {
452
- const spans = this.form.querySelectorAll('.form-error[data-input]');
453
- for (const s of spans) {
454
- s.textContent = '';
455
- s.classList.add('hidden');
456
- }
457
- const summary = this.form.querySelector('.form-error-summary');
458
- if (summary) {
459
- summary.textContent = '';
460
- summary.classList.add('hidden');
461
- }
462
- }
463
-
464
- _renderErrors(payload) {
465
- if (!payload || typeof payload !== 'object') return;
466
- const errors = payload.errors || {};
467
- const message = payload.message || this.cfg.errorTitle || this._t('unknownError');
468
-
469
- // Show per-field
470
- for (const [key, arr] of Object.entries(errors)) {
471
- const text = Array.isArray(arr) ? arr[0] : String(arr);
472
- this._showErrorForKey(key, text);
473
- }
474
-
475
- // If no per-field target(s) found, show a summary (optional)
476
- if (Object.keys(errors).length === 0) {
477
- this._renderGeneralError(message);
478
- }
479
- }
480
-
481
- _showErrorForKey(key, text) {
482
- // Accept both dot and bracket notations
483
- const variants = new Set([
484
- key,
485
- key.replace(/\./g, '[') + ']',
486
- key.replace(/\[(\w+)\]/g, '.$1').replace(/^\./, ''),
487
- ]);
488
- let shown = false;
489
- for (const v of variants) {
490
- const span = this.form.querySelector(`.form-error[data-input="${CSS.escape(v)}"]`);
491
- if (span) {
492
- span.textContent = text;
493
- span.classList.remove('hidden');
494
- shown = true;
495
- }
496
- }
497
- if (!shown) {
498
- const control = this.form.querySelector(`[name="${CSS.escape(key)}"]`) ||
499
- this.form.querySelector(`[name="${CSS.escape(key.replace(/\./g, '[') + ']')}"]`);
500
- if (control) {
501
- let span = control.closest('label, .form-control, .form-group, div')?.querySelector('.form-error');
502
- if (!span) {
503
- span = document.createElement('span');
504
- span.className = 'form-error text-error text-sm';
505
- control.insertAdjacentElement('afterend', span);
506
- }
507
- span.textContent = text;
508
- span.classList.remove('hidden');
509
- }
510
- }
511
- }
512
-
513
- _renderGeneralError(msg) {
514
- const summary = this.form.querySelector('.form-error-summary');
515
- if (summary) {
516
- summary.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0 stroke-current" fill="none" viewBox="0 0 24 24">\n' +
517
- '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />\n' +
518
- '</svg>' + msg;
519
- summary.classList.remove('hidden');
520
- } else {
521
- // Updated to use DSAlert instead of native alert
522
- DSAlert.fire({
523
- title: this._t('error'),
524
- text: msg,
525
- icon: 'error',
526
- confirmButtonText: 'OK'
527
- });
528
- }
529
- }
530
-
531
- _emit(event, detail = {}) {
532
- // Call registered handlers
533
- (this._listeners[event] || new Set()).forEach(fn => { try { fn(detail); } catch (err) { console.warn(err); } });
534
- // Mirror as DOM CustomEvent on the form element
535
- this.form.dispatchEvent(new CustomEvent(`smartform:${event}`, { bubbles: true, detail }));
536
- }
537
-
538
- _t(key) {
539
- return (this.cfg.translations && this.cfg.translations[key]) || DSForm._defaults.translations[key] || key;
540
- }
541
-
542
- /** Toast helper using DSAlert */
543
- _toast(message, type = 'info') {
544
- if (!this.cfg.toast?.enabled) return;
545
-
546
- const msg = message || (type === 'success' ? this._t('success') : this._t('error'));
547
-
548
- // Map DSForm toast config to DSAlert config
549
- DSAlert.fire({
550
- toast: true,
551
- icon: type,
552
- title: msg,
553
- position: this.cfg.toast.position || 'top-end',
554
- showConfirmButton: false,
555
- timer: this.cfg.toast.timer || 3000,
556
- timerProgressBar: this.cfg.toast.timerProgressBar ?? true,
557
- });
558
- }
559
-
560
- static _defaults = {
561
- translations: {
562
- loading: 'Loading...',
563
- networkError: 'Network error.',
564
- unknownError: 'Something went wrong.',
565
- success: 'Success!',
566
- error: 'There was a problem.',
567
- },
568
- };
1
+ /**
2
+ * DSForm (with DSAlert integration)
3
+ *
4
+ * A modern form handler that includes:
5
+ * - Integration with DSAlert for Toasts and Modals.
6
+ * - AJAX submission via 'fetch' | 'axios' | 'xhr'.
7
+ * - Laravel-style error mapping (dot/bracket notation).
8
+ * - Lifecycle hooks (onBeforeSubmit, onSubmit, onSuccess, onError, onComplete).
9
+ * - Automatic disable/loading states.
10
+ * - Data injection before submit.
11
+ */
12
+ import { DSAlert } from './DSAlert.js';
13
+
14
+ export class DSForm {
15
+ /**
16
+ * @param {Object} config - Configuration object.
17
+ * @param {HTMLFormElement|string} config.form - Form element or selector.
18
+ * @param {string} [config.url] - Submit URL (defaults to form.action or current url).
19
+ * @param {'post'|'put'} [config.method] - HTTP method.
20
+ * @param {Array<string|HTMLElement>} [config.triggers] - External triggers (selectors/elements).
21
+ * @param {'fetch'|'axios'|'xhr'} [config.requestLib] - Preferred request lib; gracefully falls back.
22
+ * @param {Object} [config.headers] - Extra headers.
23
+ * @param {Object|Function} [config.additionalData] - Extra data merged before submit.
24
+ * @param {Array<string>} [config.disableSelectors] - Extra selectors to disable during submit.
25
+ * @param {Array<string>} [config.excludeEnableSelectors] - Keep these disabled after complete.
26
+ * @param {string} [config.primaryButtonSelector='button[type="submit"]'] - For default loading target.
27
+ * @param {(Function|string)} [config.loadingTemplate] - Function/Selector/Raw HTML for loading state.
28
+ * @param {Object} [config.translations] - i18n map for user texts.
29
+ * @param {Object} [config.toast] - Toast options { enabled, position, timer, timerProgressBar }.
30
+ * @param {boolean} [config.autoRedirect=true] - Follow payload.redirect if present.
31
+ * @param {boolean} [config.disableOnSuccess=false] - Keep all controls disabled if the request succeeds.
32
+ * @param {Function} [config.onBeforeSubmit]
33
+ * @param {Function} [config.onSubmit]
34
+ * @param {Function} [config.onSuccess]
35
+ * @param {Function} [config.onError]
36
+ * @param {Function} [config.onComplete]
37
+ * @param {string} [config.successTitle] - Title for success toast (overrides payload.message).
38
+ * @param {string} [config.errorTitle] - Title for error toast (overrides payload.message).
39
+ */
40
+ constructor(config = {}) {
41
+ const defaults = {
42
+ requestLib: 'fetch',
43
+ primaryButtonSelector: 'button[type="submit"]',
44
+ translations: {
45
+ loading: 'Loading...',
46
+ networkError: 'Network error.',
47
+ unknownError: 'Something went wrong.',
48
+ success: 'Success!',
49
+ error: 'There was a problem.'
50
+ },
51
+ // Removed useSwal, added standard defaults compatible with DSAlert
52
+ toast: { enabled: false, position: 'top-end', timer: 3000, timerProgressBar: true },
53
+ autoRedirect: true,
54
+ disableOnSuccess: false,
55
+ successTitle: null,
56
+ errorTitle: null,
57
+ };
58
+
59
+ this.cfg = { ...defaults, ...config };
60
+
61
+ this.form = typeof this.cfg.form === 'string' ? document.querySelector(this.cfg.form) : this.cfg.form;
62
+ if (!this.form) throw new Error('DSForm: form not found.');
63
+
64
+ this.url = this.cfg.url || this.form.getAttribute('action') || window.location.href;
65
+ this.method = (this.cfg.method || this.form.getAttribute('method') || 'post').toLowerCase();
66
+
67
+ this.triggers = (this.cfg.triggers || [])
68
+ .map(t => (typeof t === 'string' ? document.querySelector(t) : t))
69
+ .filter(Boolean);
70
+
71
+ this._activeTrigger = null;
72
+ this._disabledEls = new Set();
73
+
74
+ // Mini event emitter
75
+ this._listeners = {};
76
+
77
+ // Bind handlers once (per instance)
78
+ this._onFormSubmit = this._onFormSubmit.bind(this);
79
+ this._onExternalTrigger = this._onExternalTrigger.bind(this);
80
+ }
81
+
82
+ /** Attach listeners to form and external triggers */
83
+ bind() {
84
+ this.form.addEventListener('submit', this._onFormSubmit);
85
+ for (const el of this.triggers) el.addEventListener('click', this._onExternalTrigger);
86
+ return this;
87
+ }
88
+
89
+ /** Detach listeners */
90
+ unbind() {
91
+ this.form.removeEventListener('submit', this._onFormSubmit);
92
+ for (const el of this.triggers) el.removeEventListener('click', this._onExternalTrigger);
93
+ return this;
94
+ }
95
+
96
+ /** Emitter: subscribe to internal events (and mirrored DOM CustomEvents) */
97
+ on(event, handler) {
98
+ if (!this._listeners[event]) this._listeners[event] = new Set();
99
+ this._listeners[event].add(handler);
100
+ return this;
101
+ }
102
+
103
+ /** Emitter: unsubscribe */
104
+ off(event, handler) {
105
+ if (this._listeners[event]) this._listeners[event].delete(handler);
106
+ return this;
107
+ }
108
+
109
+ /** Programmatically submit (optional). Runs native validation via requestSubmit if supported. */
110
+ submit({ trigger = null } = {}) {
111
+ this._activeTrigger = trigger instanceof HTMLElement ? trigger : null;
112
+ if (typeof this.form.requestSubmit === 'function') {
113
+ const btn = this._activeTrigger?.type === 'submit' ? this._activeTrigger : this.form.querySelector(this.cfg.primaryButtonSelector);
114
+ btn ? this.form.requestSubmit(btn) : this.form.requestSubmit();
115
+ } else {
116
+ this.form.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true }));
117
+ }
118
+ }
119
+
120
+ /** Public helper to merge extra data into request payload creation pipeline */
121
+ addData(extra = {}) {
122
+ if (typeof this.cfg.additionalData === 'function') {
123
+ const prev = this.cfg.additionalData;
124
+ this.cfg.additionalData = (data) => ({ ...prev(data), ...extra });
125
+ } else if (this.cfg.additionalData && typeof this.cfg.additionalData === 'object') {
126
+ this.cfg.additionalData = { ...this.cfg.additionalData, ...extra };
127
+ } else {
128
+ this.cfg.additionalData = { ...extra };
129
+ }
130
+ }
131
+
132
+ // ================= Internal =================
133
+
134
+ _onExternalTrigger(e) {
135
+ e.preventDefault();
136
+ const trigger = e.currentTarget;
137
+ this.submit({ trigger });
138
+ }
139
+
140
+ async _onFormSubmit(e) {
141
+ e.preventDefault();
142
+
143
+ // Reset field errors each submit
144
+ this._resetErrors();
145
+
146
+ const { body, isFormData, jsonSnapshot } = this._buildBody();
147
+ const headers = this._buildHeaders(isFormData);
148
+ const controller = new AbortController();
149
+
150
+ // Hook: before
151
+ try { this.cfg.onBeforeSubmit?.({ formData: isFormData ? body : null, json: isFormData ? null : jsonSnapshot }); } catch (err) { console.warn('onBeforeSubmit error:', err); }
152
+
153
+ // Disable UI + loading
154
+ const loadingText = this._t('loading');
155
+ this._disableAll(loadingText);
156
+
157
+ this._emit('submit:start', { controller });
158
+ try { this.cfg.onSubmit?.({ controller }); } catch (err) { console.warn('onSubmit error:', err); }
159
+
160
+ let ok = false; let response, data;
161
+
162
+ try {
163
+ ({ response, data } = await this._sendRequest({ body, headers, signal: controller.signal, isFormData }));
164
+ ok = response.ok;
165
+
166
+ if (ok) {
167
+ this._emit('success', { response, data });
168
+ try { this.cfg.onSuccess?.({ response, data }); } catch (err) { console.warn('onSuccess error:', err); }
169
+ this._toast(this.cfg.successTitle || data?.message || this._t('success'), 'success');
170
+ if (this.cfg.autoRedirect) {
171
+ const payload = data?.data ?? data; // Laravel style
172
+ const url = payload?.redirect || payload?.url;
173
+ if (url) window.location.assign(url);
174
+ }
175
+ } else {
176
+ this._renderErrors(data);
177
+ this._emit('error', { response, data });
178
+ try { this.cfg.onError?.({ response, data }); } catch (err) { console.warn('onError error:', err); }
179
+ this._toast(this.cfg.errorTitle || data?.message || this._t('unknownError'), 'error');
180
+ }
181
+ } catch (err) {
182
+ console.error(err);
183
+ this._renderGeneralError(this._t('networkError'));
184
+ this._emit('error', { response: null, data: null, error: err });
185
+ try { this.cfg.onError?.({ response: null, data: null, error: err }); } catch (e2) { console.warn('onError error:', e2); }
186
+ this._toast(this._t('networkError'), 'error');
187
+ } finally {
188
+ // Only re-enable if not configured to keep disabled on success
189
+ if (!(this.cfg.disableOnSuccess && ok)) {
190
+ this._enableAll();
191
+ }
192
+ this._emit('submit:complete', { ok });
193
+ try { this.cfg.onComplete?.({ ok }); } catch (err) { console.warn('onComplete error:', err); }
194
+ }
195
+ }
196
+
197
+ _buildBody() {
198
+ const formData = new FormData(this.form);
199
+
200
+ // Merge additional data
201
+ let extra = this.cfg.additionalData || null;
202
+ if (typeof extra === 'function') extra = extra(Object.fromEntries(formData.entries())) || null;
203
+ if (extra && typeof extra === 'object') {
204
+ for (const [k, v] of Object.entries(extra)) this._appendToFormData(formData, k, v);
205
+ }
206
+
207
+ const hasFile = Array.from(this.form.querySelectorAll('input[type="file"]')).some(i => i.files && i.files.length > 0);
208
+ const enctype = (this.form.getAttribute('enctype') || '').toLowerCase();
209
+
210
+ if (hasFile || enctype.includes('multipart')) {
211
+ return { body: formData, isFormData: true, jsonSnapshot: null };
212
+ }
213
+
214
+ const json = this._formDataToJSON(formData);
215
+ return { body: JSON.stringify(json), isFormData: false, jsonSnapshot: json };
216
+ }
217
+
218
+ _appendToFormData(fd, key, val) {
219
+ if (val === undefined) return;
220
+ if (val === null) { fd.append(key, ''); return; }
221
+ if (Array.isArray(val)) { for (const item of val) fd.append(this._ensureArrayKey(key), item); return; }
222
+ if (typeof val === 'object') { for (const [k, v] of Object.entries(val)) this._appendToFormData(fd, `${key}[${k}]`, v); return; }
223
+ fd.append(key, String(val));
224
+ }
225
+
226
+ _ensureArrayKey(key) { return key.endsWith('[]') ? key : `${key}[]`; }
227
+
228
+ _formDataToJSON(fd) {
229
+ const obj = {};
230
+ for (const [k, v] of fd.entries()) this._assignDeep(obj, k, v);
231
+ return obj;
232
+ }
233
+
234
+ _assignDeep(obj, path, value) {
235
+ const parts = this._parsePath(path);
236
+ let cur = obj;
237
+ for (let i = 0; i < parts.length; i++) {
238
+ const p = parts[i];
239
+ const last = i === parts.length - 1;
240
+ if (last) {
241
+ if (p === '[]') {
242
+ if (!Array.isArray(cur)) throw new Error(`Path misuse at ${path}`);
243
+ cur.push(value);
244
+ } else if (p.endsWith('[]')) {
245
+ const key = p.slice(0, -2);
246
+ if (!Array.isArray(cur[key])) cur[key] = [];
247
+ cur[key].push(value);
248
+ } else {
249
+ cur[p] = value;
250
+ }
251
+ } else {
252
+ if (p.endsWith('[]')) {
253
+ const key = p.slice(0, -2);
254
+ if (!Array.isArray(cur[key])) cur[key] = [];
255
+ if (cur[key].length === 0 || typeof cur[key][cur[key].length - 1] !== 'object') cur[key].push({});
256
+ cur = cur[key][cur.length - 1];
257
+ } else {
258
+ if (!cur[p] || typeof cur[p] !== 'object') cur[p] = {};
259
+ cur = cur[p];
260
+ }
261
+ }
262
+ }
263
+ }
264
+
265
+ _parsePath(path) {
266
+ const parts = [];
267
+
268
+ // Check if path ends with [] (array notation like tags[], categories[])
269
+ const isArrayField = path.endsWith('[]');
270
+
271
+ // Remove trailing [] temporarily for parsing
272
+ let cleanPath = isArrayField ? path.slice(0, -2) : path;
273
+
274
+ // Parse the path: field[nested][key] -> [field, nested, key]
275
+ cleanPath.replace(/\]/g, '').split('[').forEach(p => {
276
+ if (p) parts.push(p);
277
+ });
278
+
279
+ // If it was an array field, add [] to the last part
280
+ if (isArrayField && parts.length > 0) {
281
+ parts[parts.length - 1] = parts[parts.length - 1] + '[]';
282
+ }
283
+
284
+ return parts;
285
+ }
286
+
287
+ _buildHeaders(isFormData) {
288
+ const headers = { ...(this.cfg.headers || {}) };
289
+
290
+ // CSRF from meta or hidden input
291
+ const csrf =
292
+ document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') ||
293
+ this.form.querySelector('input[name="_token"]')?.value ||
294
+ null;
295
+
296
+ if (csrf) headers['X-CSRF-TOKEN'] = csrf;
297
+ if (!isFormData) headers['Content-Type'] = headers['Content-Type'] || 'application/json';
298
+ headers['Accept'] = headers['Accept'] || 'application/json';
299
+ return headers;
300
+ }
301
+
302
+ async _sendRequest({ body, headers, signal, isFormData }) {
303
+ const url = this.url;
304
+ const method = this.method;
305
+ const lib = (this.cfg.requestLib || 'fetch').toLowerCase();
306
+
307
+ // axios path
308
+ if (lib === 'axios' && window.axios) {
309
+ const resp = await window.axios({
310
+ url,
311
+ method,
312
+ data: isFormData ? body : JSON.parse(body || '{}'),
313
+ headers,
314
+ signal,
315
+ }).catch(err => (err.response ? err.response : Promise.reject(err)));
316
+
317
+ const ok = (resp.status >= 200 && resp.status < 300);
318
+ const data = resp.data && typeof resp.data === 'object' ? resp.data : {};
319
+ return { response: { ok, status: resp.status }, data };
320
+ }
321
+
322
+ // fetch path (default & axios fallback)
323
+ if (window.fetch) {
324
+ const resp = await fetch(url, { method, headers, body, signal, credentials: 'same-origin' });
325
+ let data = null; try { data = await resp.json(); } catch { data = {}; }
326
+ return { response: resp, data };
327
+ }
328
+
329
+ // xhr fallback
330
+ const { response, data } = await this._xhr({ url, method, headers, body, isFormData, signal });
331
+ return { response, data };
332
+ }
333
+
334
+ _xhr({ url, method, headers, body, isFormData, signal }) {
335
+ return new Promise((resolve, reject) => {
336
+ const xhr = new XMLHttpRequest();
337
+ xhr.open(method.toUpperCase(), url, true);
338
+ xhr.responseType = 'json';
339
+ for (const [k, v] of Object.entries(headers || {})) xhr.setRequestHeader(k, v);
340
+ xhr.onload = () => {
341
+ const ok = xhr.status >= 200 && xhr.status < 300;
342
+ resolve({ response: { ok, status: xhr.status }, data: xhr.response || {} });
343
+ };
344
+ xhr.onerror = () => reject(new Error('XHR network error'));
345
+ if (signal) signal.addEventListener('abort', () => { try { xhr.abort(); } catch { } reject(new Error('Aborted')); });
346
+ isFormData ? xhr.send(body) : xhr.send(body || null);
347
+ });
348
+ }
349
+
350
+ _disableAll(loadingText) {
351
+ this._disabledEls.clear();
352
+
353
+ // Disable all form elements
354
+ const elements = Array.from(this.form.querySelectorAll('input, select, textarea, button, a, [tabindex]'));
355
+ const extra = (this.cfg.disableSelectors || []).flatMap(sel => Array.from(document.querySelectorAll(sel)));
356
+
357
+ // Include the active trigger if present
358
+ const setToDisable = new Set([...elements, ...extra, ...(this._activeTrigger ? [this._activeTrigger] : [])]);
359
+
360
+ for (const el of setToDisable) {
361
+ if (!(el instanceof HTMLElement)) continue;
362
+
363
+ // Store previous state for restore
364
+ if (!el.dataset._dsfDisabled) el.dataset._dsfDisabled = el.hasAttribute('disabled') ? '1' : '0';
365
+ if (!el.dataset._dsfHTML) el.dataset._dsfHTML = el.innerHTML || '';
366
+
367
+ // Disable
368
+ if (el.tagName === 'A') {
369
+ el.dataset._dsfHref = el.getAttribute('href') || '';
370
+ el.setAttribute('href', 'javascript:void(0)');
371
+ el.classList.add('pointer-events-none');
372
+ }
373
+ el.setAttribute('disabled', 'disabled');
374
+ el.classList.add('disabled');
375
+
376
+ this._disabledEls.add(el);
377
+ }
378
+
379
+ // Loading UI on the most relevant button/trigger
380
+ const targetBtn =
381
+ this._activeTrigger && this._isButtonLike(this._activeTrigger)
382
+ ? this._activeTrigger
383
+ : this.form.querySelector(this.cfg.primaryButtonSelector);
384
+
385
+ if (targetBtn && targetBtn instanceof HTMLElement) {
386
+ targetBtn.innerHTML = this._resolveTemplate(this.cfg.loadingTemplate, loadingText) ||
387
+ `<span class="loading loading-bars loading-md"></span><span>${loadingText}</span>`;
388
+ }
389
+ }
390
+
391
+ _enableAll() {
392
+ const exclude = new Set((this.cfg.excludeEnableSelectors || [])
393
+ .flatMap(sel => Array.from(document.querySelectorAll(sel))));
394
+
395
+ for (const el of this._disabledEls) {
396
+ if (!(el instanceof HTMLElement)) continue;
397
+ if (exclude.has(el)) continue;
398
+
399
+ // Restore href for <a>
400
+ if (el.tagName === 'A') {
401
+ if (el.dataset._dsfHref !== undefined) el.setAttribute('href', el.dataset._dsfHref);
402
+ el.classList.remove('pointer-events-none');
403
+ }
404
+
405
+ // Restore disabled state only if we disabled it
406
+ if (el.dataset._dsfDisabled === '0') {
407
+ el.removeAttribute('disabled');
408
+ el.classList.remove('disabled');
409
+ } else {
410
+ // It was already disabled before; keep it disabled
411
+ el.classList.add('disabled');
412
+ }
413
+
414
+ // Restore innerHTML if we changed it
415
+ if (el.dataset._dsfHTML !== undefined && this._isButtonLike(el)) {
416
+ el.innerHTML = el.dataset._dsfHTML;
417
+ }
418
+
419
+ // Cleanup datasets
420
+ delete el.dataset._dsfDisabled;
421
+ delete el.dataset._dsfHTML;
422
+ delete el.dataset._dsfHref;
423
+ }
424
+
425
+ this._disabledEls.clear();
426
+ this._activeTrigger = null;
427
+ }
428
+
429
+ _isButtonLike(el) {
430
+ if (!(el instanceof HTMLElement)) return false;
431
+ const tag = el.tagName.toLowerCase();
432
+ if (tag === 'button') return true;
433
+ if (tag === 'a') return true;
434
+ if (el.getAttribute('role') === 'button') return true;
435
+ return false;
436
+ }
437
+
438
+ _resolveTemplate(tpl, text) {
439
+ if (!tpl) return '';
440
+ if (typeof tpl === 'function') return tpl(text);
441
+ if (typeof tpl === 'string') {
442
+ if (tpl.trim().startsWith('#')) {
443
+ const node = document.querySelector(tpl);
444
+ return node ? node.innerHTML : '';
445
+ }
446
+ return tpl; // raw HTML
447
+ }
448
+ return '';
449
+ }
450
+
451
+ _resetErrors() {
452
+ const spans = this.form.querySelectorAll('.form-error[data-input]');
453
+ for (const s of spans) {
454
+ s.textContent = '';
455
+ s.classList.add('hidden');
456
+ }
457
+ const summary = this.form.querySelector('.form-error-summary');
458
+ if (summary) {
459
+ summary.textContent = '';
460
+ summary.classList.add('hidden');
461
+ }
462
+ }
463
+
464
+ _renderErrors(payload) {
465
+ if (!payload || typeof payload !== 'object') return;
466
+ const errors = payload.errors || {};
467
+ const message = payload.message || this.cfg.errorTitle || this._t('unknownError');
468
+
469
+ // Show per-field
470
+ for (const [key, arr] of Object.entries(errors)) {
471
+ const text = Array.isArray(arr) ? arr[0] : String(arr);
472
+ this._showErrorForKey(key, text);
473
+ }
474
+
475
+ // If no per-field target(s) found, show a summary (optional)
476
+ if (Object.keys(errors).length === 0) {
477
+ this._renderGeneralError(message);
478
+ }
479
+ }
480
+
481
+ _showErrorForKey(key, text) {
482
+ // Accept both dot and bracket notations
483
+ const variants = new Set([
484
+ key,
485
+ key.replace(/\./g, '[') + ']',
486
+ key.replace(/\[(\w+)\]/g, '.$1').replace(/^\./, ''),
487
+ ]);
488
+ let shown = false;
489
+ for (const v of variants) {
490
+ const span = this.form.querySelector(`.form-error[data-input="${CSS.escape(v)}"]`);
491
+ if (span) {
492
+ span.textContent = text;
493
+ span.classList.remove('hidden');
494
+ shown = true;
495
+ }
496
+ }
497
+ if (!shown) {
498
+ const control = this.form.querySelector(`[name="${CSS.escape(key)}"]`) ||
499
+ this.form.querySelector(`[name="${CSS.escape(key.replace(/\./g, '[') + ']')}"]`);
500
+ if (control) {
501
+ let span = control.closest('label, .form-control, .form-group, div')?.querySelector('.form-error');
502
+ if (!span) {
503
+ span = document.createElement('span');
504
+ span.className = 'form-error text-error text-sm';
505
+ control.insertAdjacentElement('afterend', span);
506
+ }
507
+ span.textContent = text;
508
+ span.classList.remove('hidden');
509
+ }
510
+ }
511
+ }
512
+
513
+ _renderGeneralError(msg) {
514
+ const summary = this.form.querySelector('.form-error-summary');
515
+ if (summary) {
516
+ summary.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0 stroke-current" fill="none" viewBox="0 0 24 24">\n' +
517
+ '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />\n' +
518
+ '</svg>' + msg;
519
+ summary.classList.remove('hidden');
520
+ } else {
521
+ // Updated to use DSAlert instead of native alert
522
+ DSAlert.fire({
523
+ title: this._t('error'),
524
+ text: msg,
525
+ icon: 'error',
526
+ confirmButtonText: 'OK'
527
+ });
528
+ }
529
+ }
530
+
531
+ _emit(event, detail = {}) {
532
+ // Call registered handlers
533
+ (this._listeners[event] || new Set()).forEach(fn => { try { fn(detail); } catch (err) { console.warn(err); } });
534
+ // Mirror as DOM CustomEvent on the form element
535
+ this.form.dispatchEvent(new CustomEvent(`smartform:${event}`, { bubbles: true, detail }));
536
+ }
537
+
538
+ _t(key) {
539
+ return (this.cfg.translations && this.cfg.translations[key]) || DSForm._defaults.translations[key] || key;
540
+ }
541
+
542
+ /** Toast helper using DSAlert */
543
+ _toast(message, type = 'info') {
544
+ if (!this.cfg.toast?.enabled) return;
545
+
546
+ const msg = message || (type === 'success' ? this._t('success') : this._t('error'));
547
+
548
+ // Map DSForm toast config to DSAlert config
549
+ DSAlert.fire({
550
+ toast: true,
551
+ icon: type,
552
+ title: msg,
553
+ position: this.cfg.toast.position || 'top-end',
554
+ showConfirmButton: false,
555
+ timer: this.cfg.toast.timer || 3000,
556
+ timerProgressBar: this.cfg.toast.timerProgressBar ?? true,
557
+ });
558
+ }
559
+
560
+ static _defaults = {
561
+ translations: {
562
+ loading: 'Loading...',
563
+ networkError: 'Network error.',
564
+ unknownError: 'Something went wrong.',
565
+ success: 'Success!',
566
+ error: 'There was a problem.',
567
+ },
568
+ };
569
569
  }