@zero-studio/library 1.0.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/src/index.js ADDED
@@ -0,0 +1,1160 @@
1
+ /*!
2
+ * ZERO Technology Library (ZTL) v1.0.0
3
+ * MIT License — https://zero-tech.dev
4
+ * @author ZERO
5
+ */
6
+
7
+ 'use strict';
8
+
9
+ // ─────────────────────────────────────────────────────────────────────────────
10
+ // CONSTANTS & DEFAULTS
11
+ // ─────────────────────────────────────────────────────────────────────────────
12
+ const ZTL_VERSION = '1.0.0';
13
+
14
+ const DEFAULTS = {
15
+ theme: 'dark',
16
+ accent: '#00f5a0',
17
+ rtl: false,
18
+ lang: 'en',
19
+ toastPosition: 'br', // bl | br | tl | tr | tc
20
+ toastDuration: 4000,
21
+ };
22
+
23
+ // ─────────────────────────────────────────────────────────────────────────────
24
+ // UTILS
25
+ // ─────────────────────────────────────────────────────────────────────────────
26
+ const $ = (selector, context = document) => context.querySelector(selector);
27
+ const $$ = (selector, context = document) => [...context.querySelectorAll(selector)];
28
+
29
+ function uid() {
30
+ return 'ztl-' + Math.random().toString(36).slice(2, 9);
31
+ }
32
+
33
+ function merge(...objects) {
34
+ return Object.assign({}, ...objects);
35
+ }
36
+
37
+ function emit(el, eventName, detail = {}) {
38
+ el.dispatchEvent(new CustomEvent(`ztl:${eventName}`, { bubbles: true, detail }));
39
+ }
40
+
41
+ function onClickOutside(el, callback) {
42
+ const handler = (e) => {
43
+ if (!el.contains(e.target)) {
44
+ callback(e);
45
+ document.removeEventListener('click', handler);
46
+ }
47
+ };
48
+ setTimeout(() => document.addEventListener('click', handler), 0);
49
+ }
50
+
51
+ // ─────────────────────────────────────────────────────────────────────────────
52
+ // i18n — INTERNATIONALIZATION
53
+ // English (primary) + Arabic (secondary)
54
+ // ─────────────────────────────────────────────────────────────────────────────
55
+ const i18n = {
56
+ en: {
57
+ close: 'Close',
58
+ confirm: 'Confirm',
59
+ cancel: 'Cancel',
60
+ ok: 'OK',
61
+ copy: 'Copy',
62
+ copied: 'Copied!',
63
+ loading: 'Loading…',
64
+ noData: 'No data available',
65
+ error: 'An error occurred',
66
+ success: 'Success',
67
+ warning: 'Warning',
68
+ info: 'Info',
69
+ required: 'This field is required',
70
+ minLength: (n) => `Minimum ${n} characters`,
71
+ maxLength: (n) => `Maximum ${n} characters`,
72
+ invalid: 'Invalid input',
73
+ search: 'Search…',
74
+ prev: 'Previous',
75
+ next: 'Next',
76
+ showing: (from, to, total) => `Showing ${from}–${to} of ${total}`,
77
+ deleteConfirm: 'Are you sure? This action cannot be undone.',
78
+ uploadDrop: 'Drop files here or click to browse',
79
+ uploadLimit: (mb) => `Max file size: ${mb}MB`,
80
+ },
81
+ ar: {
82
+ close: 'إغلاق',
83
+ confirm: 'تأكيد',
84
+ cancel: 'إلغاء',
85
+ ok: 'موافق',
86
+ copy: 'نسخ',
87
+ copied: 'تم النسخ!',
88
+ loading: 'جارٍ التحميل…',
89
+ noData: 'لا توجد بيانات',
90
+ error: 'حدث خطأ',
91
+ success: 'تم بنجاح',
92
+ warning: 'تحذير',
93
+ info: 'معلومة',
94
+ required: 'هذا الحقل مطلوب',
95
+ minLength: (n) => `الحد الأدنى ${n} أحرف`,
96
+ maxLength: (n) => `الحد الأقصى ${n} حرف`,
97
+ invalid: 'مدخل غير صالح',
98
+ search: 'بحث…',
99
+ prev: 'السابق',
100
+ next: 'التالي',
101
+ showing: (from, to, total) => `عرض ${from}–${to} من ${total}`,
102
+ deleteConfirm: 'هل أنت متأكد؟ لا يمكن التراجع عن هذا الإجراء.',
103
+ uploadDrop: 'أسقط الملفات هنا أو اضغط للتصفح',
104
+ uploadLimit: (mb) => `الحجم الأقصى: ${mb} ميغابايت`,
105
+ },
106
+ };
107
+
108
+ // ─────────────────────────────────────────────────────────────────────────────
109
+ // TOAST MANAGER
110
+ // ─────────────────────────────────────────────────────────────────────────────
111
+ class ToastManager {
112
+ constructor(position = 'br', lang = 'en') {
113
+ this._lang = lang;
114
+ this._stack = this._createStack(position);
115
+ document.body.appendChild(this._stack);
116
+ }
117
+
118
+ _createStack(position) {
119
+ const el = document.createElement('div');
120
+ el.className = `ztl-toast-stack ztl-toast-stack-${position}`;
121
+ el.setAttribute('aria-live', 'polite');
122
+ el.setAttribute('aria-atomic', 'false');
123
+ return el;
124
+ }
125
+
126
+ /**
127
+ * Show a toast notification.
128
+ * @param {string} message
129
+ * @param {'success'|'danger'|'warning'|'info'} type
130
+ * @param {Object} [options]
131
+ * @param {number} [options.duration=4000]
132
+ * @param {string} [options.icon]
133
+ * @param {boolean} [options.closable=true]
134
+ */
135
+ show(message, type = 'info', options = {}) {
136
+ const opts = merge({ duration: DEFAULTS.toastDuration, closable: true }, options);
137
+ const iconMap = { success: '✅', danger: '❌', warning: '⚠️', info: 'ℹ️' };
138
+ const icon = opts.icon || iconMap[type] || 'ℹ️';
139
+
140
+ const toast = document.createElement('div');
141
+ toast.className = `ztl-toast ztl-toast-${type}`;
142
+ toast.setAttribute('role', 'alert');
143
+ toast.innerHTML = `
144
+ <span class="ztl-toast-icon" aria-hidden="true">${icon}</span>
145
+ <span class="ztl-toast-text">${message}</span>
146
+ ${opts.closable ? `<button class="ztl-toast-close" aria-label="${i18n[this._lang]?.close || i18n.en.close}">✕</button>` : ''}
147
+ `;
148
+
149
+ this._stack.appendChild(toast);
150
+
151
+ if (opts.closable) {
152
+ $('.ztl-toast-close', toast).addEventListener('click', () => this._remove(toast));
153
+ }
154
+
155
+ const timer = setTimeout(() => this._remove(toast), opts.duration);
156
+ toast._timer = timer;
157
+
158
+ // Pause on hover
159
+ toast.addEventListener('mouseenter', () => clearTimeout(toast._timer));
160
+ toast.addEventListener('mouseleave', () => {
161
+ toast._timer = setTimeout(() => this._remove(toast), opts.duration / 2);
162
+ });
163
+
164
+ emit(this._stack, 'toast:show', { message, type });
165
+ return toast;
166
+ }
167
+
168
+ _remove(toast) {
169
+ if (!toast.parentNode) return;
170
+ toast.classList.add('ztl-removing');
171
+ toast.addEventListener('animationend', () => toast.remove(), { once: true });
172
+ setTimeout(() => toast.remove(), 400);
173
+ }
174
+
175
+ success(msg, opts) { return this.show(msg, 'success', opts); }
176
+ danger(msg, opts) { return this.show(msg, 'danger', opts); }
177
+ warning(msg, opts) { return this.show(msg, 'warning', opts); }
178
+ info(msg, opts) { return this.show(msg, 'info', opts); }
179
+ clear() { $$('.ztl-toast', this._stack).forEach(t => this._remove(t)); }
180
+
181
+ destroy() { this._stack.remove(); }
182
+ }
183
+
184
+ // ─────────────────────────────────────────────────────────────────────────────
185
+ // MODAL
186
+ // ─────────────────────────────────────────────────────────────────────────────
187
+ class Modal {
188
+ /**
189
+ * @param {Object} options
190
+ * @param {string} [options.title]
191
+ * @param {string} [options.body]
192
+ * @param {string} [options.size=''] - sm | lg | xl | full
193
+ * @param {boolean} [options.closable=true]
194
+ * @param {Function} [options.onConfirm]
195
+ * @param {Function} [options.onCancel]
196
+ * @param {string} [options.confirmLabel]
197
+ * @param {string} [options.cancelLabel]
198
+ * @param {string} [options.lang='en']
199
+ */
200
+ constructor(options = {}) {
201
+ this._opts = merge({
202
+ title: '', body: '', size: '', closable: true,
203
+ onConfirm: null, onCancel: null,
204
+ confirmLabel: null, cancelLabel: null,
205
+ lang: 'en',
206
+ }, options);
207
+
208
+ this._t = i18n[this._opts.lang] || i18n.en;
209
+ this._el = this._build();
210
+ document.body.appendChild(this._el);
211
+ this._bindEvents();
212
+ }
213
+
214
+ _build() {
215
+ const sizeClass = this._opts.size ? `ztl-modal-${this._opts.size}` : '';
216
+ const overlay = document.createElement('div');
217
+ overlay.className = 'ztl-overlay';
218
+ overlay.setAttribute('role', 'dialog');
219
+ overlay.setAttribute('aria-modal', 'true');
220
+ overlay.setAttribute('aria-labelledby', 'ztl-modal-title');
221
+
222
+ overlay.innerHTML = `
223
+ <div class="ztl-modal ${sizeClass}">
224
+ ${this._opts.title ? `
225
+ <div class="ztl-modal-header">
226
+ <span class="ztl-modal-title" id="ztl-modal-title">${this._opts.title}</span>
227
+ ${this._opts.closable ? `<button class="ztl-btn ztl-btn-ghost ztl-btn-sm ztl-modal-close-btn" aria-label="${this._t.close}">✕</button>` : ''}
228
+ </div>` : ''}
229
+ <div class="ztl-modal-body">${this._opts.body}</div>
230
+ ${(this._opts.onConfirm || this._opts.cancelLabel) ? `
231
+ <div class="ztl-modal-footer">
232
+ <button class="ztl-btn ztl-btn-ghost ztl-btn-sm ztl-modal-cancel-btn">${this._opts.cancelLabel || this._t.cancel}</button>
233
+ <button class="ztl-btn ztl-btn-primary ztl-btn-sm ztl-modal-confirm-btn">${this._opts.confirmLabel || this._t.confirm}</button>
234
+ </div>` : ''}
235
+ </div>
236
+ `;
237
+ return overlay;
238
+ }
239
+
240
+ _bindEvents() {
241
+ // Close on overlay click
242
+ this._el.addEventListener('click', (e) => {
243
+ if (e.target === this._el && this._opts.closable) this.close();
244
+ });
245
+
246
+ // Close button
247
+ const closeBtn = $('.ztl-modal-close-btn', this._el);
248
+ if (closeBtn) closeBtn.addEventListener('click', () => this.close());
249
+
250
+ // Cancel
251
+ const cancelBtn = $('.ztl-modal-cancel-btn', this._el);
252
+ if (cancelBtn) cancelBtn.addEventListener('click', () => {
253
+ if (this._opts.onCancel) this._opts.onCancel();
254
+ this.close();
255
+ });
256
+
257
+ // Confirm
258
+ const confirmBtn = $('.ztl-modal-confirm-btn', this._el);
259
+ if (confirmBtn) confirmBtn.addEventListener('click', () => {
260
+ if (this._opts.onConfirm) this._opts.onConfirm();
261
+ this.close();
262
+ });
263
+
264
+ // Escape key
265
+ this._keyHandler = (e) => { if (e.key === 'Escape' && this._opts.closable) this.close(); };
266
+ document.addEventListener('keydown', this._keyHandler);
267
+ }
268
+
269
+ open() {
270
+ this._el.classList.add('ztl-open');
271
+ document.body.style.overflow = 'hidden';
272
+ emit(this._el, 'modal:open');
273
+ return this;
274
+ }
275
+
276
+ close() {
277
+ this._el.classList.remove('ztl-open');
278
+ document.body.style.overflow = '';
279
+ document.removeEventListener('keydown', this._keyHandler);
280
+ emit(this._el, 'modal:close');
281
+ return this;
282
+ }
283
+
284
+ destroy() {
285
+ this.close();
286
+ setTimeout(() => this._el.remove(), 300);
287
+ }
288
+
289
+ setBody(html) {
290
+ $('.ztl-modal-body', this._el).innerHTML = html;
291
+ return this;
292
+ }
293
+
294
+ setTitle(text) {
295
+ const t = $('#ztl-modal-title', this._el);
296
+ if (t) t.textContent = text;
297
+ return this;
298
+ }
299
+ }
300
+
301
+ // Shorthand: confirm dialog
302
+ Modal.confirm = function(message, opts = {}) {
303
+ return new Promise((resolve) => {
304
+ const m = new Modal(merge({
305
+ title: opts.title || (i18n[opts.lang || 'en'] || i18n.en).confirm,
306
+ body: `<p style="color:var(--ztl-text-muted);font-size:14px;line-height:1.7">${message}</p>`,
307
+ size: 'sm',
308
+ onConfirm: () => { resolve(true); m.destroy(); },
309
+ onCancel: () => { resolve(false); m.destroy(); },
310
+ ...opts,
311
+ }));
312
+ m.open();
313
+ });
314
+ };
315
+
316
+ // ─────────────────────────────────────────────────────────────────────────────
317
+ // DROPDOWN
318
+ // ─────────────────────────────────────────────────────────────────────────────
319
+ class Dropdown {
320
+ /**
321
+ * @param {HTMLElement} trigger
322
+ * @param {HTMLElement|string} menu - element or CSS selector
323
+ */
324
+ constructor(trigger, menu) {
325
+ this._trigger = typeof trigger === 'string' ? $(trigger) : trigger;
326
+ this._menu = typeof menu === 'string' ? $(menu) : menu;
327
+ this._parent = this._trigger.closest('.ztl-dropdown') || this._trigger.parentElement;
328
+ this._bindEvents();
329
+ }
330
+
331
+ _bindEvents() {
332
+ this._trigger.addEventListener('click', (e) => {
333
+ e.stopPropagation();
334
+ this.isOpen() ? this.close() : this.open();
335
+ });
336
+ }
337
+
338
+ isOpen() { return this._parent.classList.contains('ztl-open'); }
339
+
340
+ open() {
341
+ this._parent.classList.add('ztl-open');
342
+ this._trigger.setAttribute('aria-expanded', 'true');
343
+ emit(this._parent, 'dropdown:open');
344
+ onClickOutside(this._parent, () => this.close());
345
+ }
346
+
347
+ close() {
348
+ this._parent.classList.remove('ztl-open');
349
+ this._trigger.setAttribute('aria-expanded', 'false');
350
+ emit(this._parent, 'dropdown:close');
351
+ }
352
+
353
+ toggle() { this.isOpen() ? this.close() : this.open(); }
354
+ destroy() { /* cleanup if needed */ }
355
+ }
356
+
357
+ // ─────────────────────────────────────────────────────────────────────────────
358
+ // TABS
359
+ // ─────────────────────────────────────────────────────────────────────────────
360
+ class Tabs {
361
+ /**
362
+ * @param {HTMLElement|string} container
363
+ * @param {Object} [options]
364
+ * @param {Function} [options.onChange]
365
+ */
366
+ constructor(container, options = {}) {
367
+ this._el = typeof container === 'string' ? $(container) : container;
368
+ this._opts = merge({ onChange: null }, options);
369
+ this._init();
370
+ }
371
+
372
+ _init() {
373
+ this._buttons = $$('.ztl-tab-btn', this._el);
374
+ this._panels = $$('.ztl-tab-panel', this._el);
375
+
376
+ this._buttons.forEach((btn, i) => {
377
+ btn.setAttribute('role', 'tab');
378
+ btn.setAttribute('aria-selected', btn.classList.contains('ztl-active'));
379
+ btn.addEventListener('click', () => this.activate(i));
380
+ });
381
+
382
+ this._panels.forEach((panel) => {
383
+ panel.setAttribute('role', 'tabpanel');
384
+ });
385
+ }
386
+
387
+ activate(index) {
388
+ const prev = this._buttons.findIndex(b => b.classList.contains('ztl-active'));
389
+
390
+ this._buttons.forEach((btn, i) => {
391
+ btn.classList.toggle('ztl-active', i === index);
392
+ btn.setAttribute('aria-selected', i === index);
393
+ });
394
+
395
+ this._panels.forEach((panel, i) => {
396
+ panel.classList.toggle('ztl-active', i === index);
397
+ });
398
+
399
+ if (this._opts.onChange) this._opts.onChange(index, prev);
400
+ emit(this._el, 'tabs:change', { index, prev });
401
+ }
402
+
403
+ getActive() {
404
+ return this._buttons.findIndex(b => b.classList.contains('ztl-active'));
405
+ }
406
+ }
407
+
408
+ // ─────────────────────────────────────────────────────────────────────────────
409
+ // PROGRESS BAR
410
+ // ─────────────────────────────────────────────────────────────────────────────
411
+ class ProgressBar {
412
+ /**
413
+ * @param {HTMLElement|string} element - the .ztl-progress-fill element
414
+ * @param {Object} [options]
415
+ * @param {number} [options.value=0]
416
+ * @param {number} [options.min=0]
417
+ * @param {number} [options.max=100]
418
+ * @param {boolean} [options.animate=true]
419
+ */
420
+ constructor(element, options = {}) {
421
+ this._el = typeof element === 'string' ? $(element) : element;
422
+ this._opts = merge({ value: 0, min: 0, max: 100, animate: true }, options);
423
+ this.set(this._opts.value);
424
+ }
425
+
426
+ set(value) {
427
+ const clamped = Math.max(this._opts.min, Math.min(this._opts.max, value));
428
+ const pct = ((clamped - this._opts.min) / (this._opts.max - this._opts.min)) * 100;
429
+ this._el.style.width = `${pct}%`;
430
+ this._el.setAttribute('aria-valuenow', clamped);
431
+ this._current = clamped;
432
+ emit(this._el, 'progress:change', { value: clamped, percent: pct });
433
+ return this;
434
+ }
435
+
436
+ increment(by = 1) { return this.set(this._current + by); }
437
+ decrement(by = 1) { return this.set(this._current - by); }
438
+ complete() { return this.set(this._opts.max); }
439
+ reset() { return this.set(this._opts.min); }
440
+ get value() { return this._current; }
441
+
442
+ /**
443
+ * Animate progress from current to target value.
444
+ * @param {number} target
445
+ * @param {number} [duration=800] ms
446
+ */
447
+ animateTo(target, duration = 800) {
448
+ const start = this._current;
449
+ const diff = target - start;
450
+ const begin = performance.now();
451
+
452
+ const step = (now) => {
453
+ const elapsed = now - begin;
454
+ const progress = Math.min(elapsed / duration, 1);
455
+ const ease = 1 - Math.pow(1 - progress, 3); // ease-out-cubic
456
+ this.set(start + diff * ease);
457
+ if (progress < 1) requestAnimationFrame(step);
458
+ };
459
+
460
+ requestAnimationFrame(step);
461
+ return this;
462
+ }
463
+ }
464
+
465
+ // Circular progress
466
+ class CircularProgress {
467
+ /**
468
+ * @param {HTMLElement|string} container - .ztl-progress-circle
469
+ * @param {Object} options
470
+ * @param {number} [options.value=0]
471
+ * @param {number} [options.size=80]
472
+ * @param {number} [options.strokeWidth=6]
473
+ * @param {string} [options.color]
474
+ */
475
+ constructor(container, options = {}) {
476
+ this._el = typeof container === 'string' ? $(container) : container;
477
+ this._opts = merge({ value: 0, size: 80, strokeWidth: 6, color: 'var(--ztl-accent)' }, options);
478
+ this._init();
479
+ }
480
+
481
+ _init() {
482
+ const { size, strokeWidth } = this._opts;
483
+ const r = (size / 2) - strokeWidth;
484
+ const circ = 2 * Math.PI * r;
485
+ this._circ = circ;
486
+ this._r = r;
487
+
488
+ this._el.style.width = size + 'px';
489
+ this._el.style.height = size + 'px';
490
+
491
+ this._el.innerHTML = `
492
+ <svg viewBox="0 0 ${size} ${size}" width="${size}" height="${size}">
493
+ <circle class="ztl-progress-circle-track" cx="${size/2}" cy="${size/2}" r="${r}" stroke-width="${strokeWidth}"/>
494
+ <circle class="ztl-progress-circle-fill" cx="${size/2}" cy="${size/2}" r="${r}" stroke-width="${strokeWidth}"
495
+ stroke="${this._opts.color}"
496
+ stroke-dasharray="${circ}"
497
+ stroke-dashoffset="${circ}"/>
498
+ </svg>
499
+ <span class="ztl-progress-circle-label">0%</span>
500
+ `;
501
+
502
+ this._fill = $('circle.ztl-progress-circle-fill', this._el);
503
+ this._label = $('.ztl-progress-circle-label', this._el);
504
+ this.set(this._opts.value);
505
+ }
506
+
507
+ set(value) {
508
+ const clamped = Math.max(0, Math.min(100, value));
509
+ const offset = this._circ * (1 - clamped / 100);
510
+ this._fill.style.strokeDashoffset = offset;
511
+ this._label.textContent = `${Math.round(clamped)}%`;
512
+ this._current = clamped;
513
+ return this;
514
+ }
515
+
516
+ animateTo(target, duration = 800) {
517
+ const start = this._current || 0;
518
+ const diff = target - start;
519
+ const begin = performance.now();
520
+ const step = (now) => {
521
+ const p = Math.min((now - begin) / duration, 1);
522
+ this.set(start + diff * (1 - Math.pow(1 - p, 3)));
523
+ if (p < 1) requestAnimationFrame(step);
524
+ };
525
+ requestAnimationFrame(step);
526
+ return this;
527
+ }
528
+ }
529
+
530
+ // ─────────────────────────────────────────────────────────────────────────────
531
+ // TOOLTIP (programmatic)
532
+ // ─────────────────────────────────────────────────────────────────────────────
533
+ class Tooltip {
534
+ /**
535
+ * @param {HTMLElement|string} element
536
+ * @param {string} text
537
+ * @param {'top'|'bottom'|'left'|'right'} [position='top']
538
+ */
539
+ constructor(element, text, position = 'top') {
540
+ this._el = typeof element === 'string' ? $(element) : element;
541
+ this._el.setAttribute('data-ztl-tip', text);
542
+ if (position !== 'top') this._el.setAttribute('data-ztl-tip-pos', position);
543
+ }
544
+
545
+ update(text) { this._el.setAttribute('data-ztl-tip', text); return this; }
546
+
547
+ destroy() {
548
+ this._el.removeAttribute('data-ztl-tip');
549
+ this._el.removeAttribute('data-ztl-tip-pos');
550
+ }
551
+ }
552
+
553
+ // ─────────────────────────────────────────────────────────────────────────────
554
+ // FORM VALIDATOR
555
+ // ─────────────────────────────────────────────────────────────────────────────
556
+ class FormValidator {
557
+ /**
558
+ * @param {HTMLFormElement|string} form
559
+ * @param {Object} rules - { fieldName: [...rules] }
560
+ * @param {Object} [options]
561
+ * @param {string} [options.lang='en']
562
+ */
563
+ constructor(form, rules, options = {}) {
564
+ this._form = typeof form === 'string' ? $(form) : form;
565
+ this._rules = rules;
566
+ this._opts = merge({ lang: 'en', onSubmit: null }, options);
567
+ this._t = i18n[this._opts.lang] || i18n.en;
568
+ this._errors = {};
569
+ this._bindEvents();
570
+ }
571
+
572
+ _bindEvents() {
573
+ this._form.setAttribute('novalidate', '');
574
+ this._form.addEventListener('submit', (e) => {
575
+ e.preventDefault();
576
+ const valid = this.validate();
577
+ if (valid && this._opts.onSubmit) {
578
+ this._opts.onSubmit(this.getValues(), e);
579
+ }
580
+ });
581
+
582
+ // Live validation
583
+ $$('input, select, textarea', this._form).forEach(el => {
584
+ el.addEventListener('blur', () => this._validateField(el.name || el.id, el.value));
585
+ });
586
+ }
587
+
588
+ _validateField(name, value) {
589
+ const rules = this._rules[name];
590
+ if (!rules) return true;
591
+
592
+ const errors = [];
593
+
594
+ rules.forEach(rule => {
595
+ if (rule === 'required' && !value.trim()) {
596
+ errors.push(this._t.required);
597
+ }
598
+ if (rule.type === 'minLength' && value.length < rule.value) {
599
+ errors.push(this._t.minLength(rule.value));
600
+ }
601
+ if (rule.type === 'maxLength' && value.length > rule.value) {
602
+ errors.push(this._t.maxLength(rule.value));
603
+ }
604
+ if (rule.type === 'pattern' && !rule.value.test(value)) {
605
+ errors.push(rule.message || this._t.invalid);
606
+ }
607
+ if (rule.type === 'email' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
608
+ errors.push(this._t.invalid);
609
+ }
610
+ if (rule.type === 'custom') {
611
+ const result = rule.validate(value);
612
+ if (result !== true) errors.push(result || this._t.invalid);
613
+ }
614
+ });
615
+
616
+ this._setFieldError(name, errors[0] || null);
617
+ return errors.length === 0;
618
+ }
619
+
620
+ _setFieldError(name, message) {
621
+ const field = this._form.querySelector(`[name="${name}"], #${name}`);
622
+ if (!field) return;
623
+
624
+ const wrap = field.closest('.ztl-field') || field.parentElement;
625
+ let errorEl = wrap.querySelector('.ztl-field-error');
626
+
627
+ if (message) {
628
+ field.classList.add('ztl-input-error');
629
+ field.classList.remove('ztl-input-success');
630
+ if (!errorEl) {
631
+ errorEl = document.createElement('span');
632
+ errorEl.className = 'ztl-field-error';
633
+ wrap.appendChild(errorEl);
634
+ }
635
+ errorEl.textContent = message;
636
+ this._errors[name] = message;
637
+ } else {
638
+ field.classList.remove('ztl-input-error');
639
+ field.classList.add('ztl-input-success');
640
+ if (errorEl) errorEl.remove();
641
+ delete this._errors[name];
642
+ }
643
+ }
644
+
645
+ validate() {
646
+ let valid = true;
647
+ Object.entries(this._rules).forEach(([name]) => {
648
+ const field = this._form.querySelector(`[name="${name}"], #${name}`);
649
+ if (field && !this._validateField(name, field.value)) valid = false;
650
+ });
651
+ emit(this._form, 'validate', { valid, errors: this._errors });
652
+ return valid;
653
+ }
654
+
655
+ getValues() {
656
+ const data = {};
657
+ new FormData(this._form).forEach((val, key) => { data[key] = val; });
658
+ return data;
659
+ }
660
+
661
+ clearErrors() {
662
+ $$('.ztl-field-error', this._form).forEach(el => el.remove());
663
+ $$('.ztl-input-error, .ztl-input-success', this._form).forEach(el => {
664
+ el.classList.remove('ztl-input-error', 'ztl-input-success');
665
+ });
666
+ this._errors = {};
667
+ }
668
+
669
+ setErrors(errors) {
670
+ Object.entries(errors).forEach(([name, msg]) => this._setFieldError(name, msg));
671
+ }
672
+ }
673
+
674
+ // ─────────────────────────────────────────────────────────────────────────────
675
+ // TAGS / CHIPS INPUT
676
+ // ─────────────────────────────────────────────────────────────────────────────
677
+ class TagInput {
678
+ /**
679
+ * @param {HTMLElement|string} container - .ztl-tag-input-wrap
680
+ * @param {Object} [options]
681
+ * @param {string[]} [options.initial=[]]
682
+ * @param {number} [options.max=Infinity]
683
+ * @param {Function} [options.onChange]
684
+ * @param {Function} [options.onAdd]
685
+ * @param {Function} [options.onRemove]
686
+ */
687
+ constructor(container, options = {}) {
688
+ this._el = typeof container === 'string' ? $(container) : container;
689
+ this._opts = merge({ initial: [], max: Infinity, onChange: null, onAdd: null, onRemove: null }, options);
690
+ this._tags = [...this._opts.initial];
691
+ this._input = document.createElement('input');
692
+ this._input.placeholder = '+ Add tag';
693
+ this._el.appendChild(this._input);
694
+ this._renderAll();
695
+ this._bindEvents();
696
+ }
697
+
698
+ _renderAll() {
699
+ $$('.ztl-tag', this._el).forEach(t => t.remove());
700
+ this._tags.forEach(tag => this._renderTag(tag));
701
+ this._el.appendChild(this._input);
702
+ }
703
+
704
+ _renderTag(text) {
705
+ const tag = document.createElement('span');
706
+ tag.className = 'ztl-tag ztl-tag-removable';
707
+ tag.innerHTML = `${text} <span class="ztl-tag-remove" aria-label="Remove ${text}">×</span>`;
708
+ tag.querySelector('.ztl-tag-remove').addEventListener('click', () => this.remove(text));
709
+ this._el.insertBefore(tag, this._input);
710
+ }
711
+
712
+ _bindEvents() {
713
+ this._input.addEventListener('keydown', (e) => {
714
+ if ((e.key === 'Enter' || e.key === ',') && this._input.value.trim()) {
715
+ e.preventDefault();
716
+ this.add(this._input.value.trim().replace(/,$/, ''));
717
+ this._input.value = '';
718
+ }
719
+ if (e.key === 'Backspace' && !this._input.value && this._tags.length) {
720
+ this.remove(this._tags[this._tags.length - 1]);
721
+ }
722
+ });
723
+
724
+ this._el.addEventListener('click', () => this._input.focus());
725
+ }
726
+
727
+ add(text) {
728
+ if (this._tags.includes(text) || this._tags.length >= this._opts.max) return this;
729
+ this._tags.push(text);
730
+ this._renderTag(text);
731
+ if (this._opts.onAdd) this._opts.onAdd(text, this._tags);
732
+ if (this._opts.onChange) this._opts.onChange(this._tags);
733
+ emit(this._el, 'tag:add', { tag: text, tags: this._tags });
734
+ return this;
735
+ }
736
+
737
+ remove(text) {
738
+ this._tags = this._tags.filter(t => t !== text);
739
+ this._renderAll();
740
+ if (this._opts.onRemove) this._opts.onRemove(text, this._tags);
741
+ if (this._opts.onChange) this._opts.onChange(this._tags);
742
+ emit(this._el, 'tag:remove', { tag: text, tags: this._tags });
743
+ return this;
744
+ }
745
+
746
+ getTags() { return [...this._tags]; }
747
+ clear() { this._tags = []; this._renderAll(); return this; }
748
+ }
749
+
750
+ // ─────────────────────────────────────────────────────────────────────────────
751
+ // COPY TO CLIPBOARD
752
+ // ─────────────────────────────────────────────────────────────────────────────
753
+ function copyToClipboard(text, lang = 'en') {
754
+ const t = i18n[lang] || i18n.en;
755
+ return navigator.clipboard.writeText(text).then(() => {
756
+ emit(document, 'clipboard:copy', { text });
757
+ return { success: true, message: t.copied };
758
+ }).catch(() => {
759
+ // fallback for older browsers
760
+ const ta = document.createElement('textarea');
761
+ ta.value = text;
762
+ ta.style.cssText = 'position:fixed;opacity:0;top:0;left:0';
763
+ document.body.appendChild(ta);
764
+ ta.focus();
765
+ ta.select();
766
+ try { document.execCommand('copy'); return { success: true, message: t.copied }; }
767
+ catch { return { success: false, message: 'Copy failed' }; }
768
+ finally { ta.remove(); }
769
+ });
770
+ }
771
+
772
+ // Auto-bind copy buttons
773
+ function initCopyButtons(lang = 'en') {
774
+ $$('[data-ztl-copy]').forEach(btn => {
775
+ btn.addEventListener('click', async () => {
776
+ const target = btn.getAttribute('data-ztl-copy');
777
+ const text = target
778
+ ? ($(target) ? $(target).textContent : target)
779
+ : btn.textContent;
780
+
781
+ const result = await copyToClipboard(text, lang);
782
+ const original = btn.textContent;
783
+ btn.textContent = result.message;
784
+ btn.disabled = true;
785
+ setTimeout(() => { btn.textContent = original; btn.disabled = false; }, 2000);
786
+ });
787
+ });
788
+ }
789
+
790
+ // ─────────────────────────────────────────────────────────────────────────────
791
+ // SCROLL REVEAL
792
+ // ─────────────────────────────────────────────────────────────────────────────
793
+ function initScrollReveal(selector = '[data-ztl-reveal]') {
794
+ if (!('IntersectionObserver' in window)) return;
795
+
796
+ const observer = new IntersectionObserver((entries) => {
797
+ entries.forEach(entry => {
798
+ if (entry.isIntersecting) {
799
+ const el = entry.target;
800
+ const animation = el.getAttribute('data-ztl-reveal') || 'slide-up';
801
+ el.classList.add(`ztl-animate-${animation}`);
802
+ el.style.opacity = '1';
803
+ observer.unobserve(el);
804
+ }
805
+ });
806
+ }, { threshold: 0.15 });
807
+
808
+ $$(selector).forEach(el => {
809
+ el.style.opacity = '0';
810
+ observer.observe(el);
811
+ });
812
+ }
813
+
814
+ // ─────────────────────────────────────────────────────────────────────────────
815
+ // COUNTER ANIMATION
816
+ // ─────────────────────────────────────────────────────────────────────────────
817
+ function animateCounter(el, target, options = {}) {
818
+ const opts = merge({ duration: 1200, prefix: '', suffix: '', decimals: 0 }, options);
819
+ const start = parseFloat(el.textContent) || 0;
820
+ const diff = target - start;
821
+ const begin = performance.now();
822
+
823
+ const step = (now) => {
824
+ const elapsed = now - begin;
825
+ const progress = Math.min(elapsed / opts.duration, 1);
826
+ const ease = 1 - Math.pow(1 - progress, 3);
827
+ const current = start + diff * ease;
828
+ el.textContent = opts.prefix + current.toFixed(opts.decimals) + opts.suffix;
829
+ if (progress < 1) requestAnimationFrame(step);
830
+ else el.textContent = opts.prefix + target.toFixed(opts.decimals) + opts.suffix;
831
+ };
832
+
833
+ requestAnimationFrame(step);
834
+ }
835
+
836
+ // Init counter elements automatically
837
+ function initCounters(selector = '[data-ztl-count]') {
838
+ if (!('IntersectionObserver' in window)) return;
839
+
840
+ const observer = new IntersectionObserver((entries) => {
841
+ entries.forEach(entry => {
842
+ if (entry.isIntersecting) {
843
+ const el = entry.target;
844
+ const target = parseFloat(el.getAttribute('data-ztl-count'));
845
+ const suffix = el.getAttribute('data-ztl-suffix') || '';
846
+ const prefix = el.getAttribute('data-ztl-prefix') || '';
847
+ const duration = parseInt(el.getAttribute('data-ztl-duration') || 1200);
848
+ animateCounter(el, target, { suffix, prefix, duration });
849
+ observer.unobserve(el);
850
+ }
851
+ });
852
+ }, { threshold: 0.5 });
853
+
854
+ $$(selector).forEach(el => observer.observe(el));
855
+ }
856
+
857
+ // ─────────────────────────────────────────────────────────────────────────────
858
+ // THEME MANAGER
859
+ // ─────────────────────────────────────────────────────────────────────────────
860
+ class ThemeManager {
861
+ constructor(options = {}) {
862
+ this._opts = merge({ storageKey: 'ztl-theme', default: 'dark' }, options);
863
+ this._current = localStorage.getItem(this._opts.storageKey) || this._opts.default;
864
+ this.apply(this._current);
865
+ }
866
+
867
+ apply(theme) {
868
+ document.documentElement.setAttribute('data-ztl-theme', theme);
869
+ this._current = theme;
870
+ localStorage.setItem(this._opts.storageKey, theme);
871
+ emit(document, 'theme:change', { theme });
872
+ }
873
+
874
+ toggle() {
875
+ this.apply(this._current === 'dark' ? 'light' : 'dark');
876
+ }
877
+
878
+ setAccent(color) {
879
+ document.documentElement.style.setProperty('--ztl-accent', color);
880
+ emit(document, 'theme:accent', { color });
881
+ }
882
+
883
+ get current() { return this._current; }
884
+ }
885
+
886
+ // ─────────────────────────────────────────────────────────────────────────────
887
+ // SKELETON LOADER HELPER
888
+ // ─────────────────────────────────────────────────────────────────────────────
889
+ function skeleton(container, count = 3) {
890
+ const el = typeof container === 'string' ? $(container) : container;
891
+ el.innerHTML = Array(count).fill(`
892
+ <div style="display:flex;flex-direction:column;gap:8px;margin-bottom:16px">
893
+ <div class="ztl-skeleton ztl-skeleton-title" style="width:${40 + Math.random() * 30}%"></div>
894
+ <div class="ztl-skeleton ztl-skeleton-text"></div>
895
+ <div class="ztl-skeleton ztl-skeleton-text" style="width:80%"></div>
896
+ </div>
897
+ `).join('');
898
+
899
+ return {
900
+ clear: () => { el.innerHTML = ''; },
901
+ replace: (html) => { el.innerHTML = html; },
902
+ };
903
+ }
904
+
905
+ // ─────────────────────────────────────────────────────────────────────────────
906
+ // DATA TABLE (Simple)
907
+ // ─────────────────────────────────────────────────────────────────────────────
908
+ class DataTable {
909
+ /**
910
+ * @param {HTMLElement|string} container
911
+ * @param {Object} options
912
+ * @param {Array<{key,label,sortable?,render?}>} options.columns
913
+ * @param {Array<Object>} options.data
914
+ * @param {number} [options.pageSize=10]
915
+ * @param {boolean} [options.searchable=true]
916
+ * @param {string} [options.lang='en']
917
+ */
918
+ constructor(container, options) {
919
+ this._el = typeof container === 'string' ? $(container) : container;
920
+ this._opts = merge({ pageSize: 10, searchable: true, lang: 'en' }, options);
921
+ this._t = i18n[this._opts.lang] || i18n.en;
922
+ this._data = [...this._opts.data];
923
+ this._filtered = [...this._data];
924
+ this._page = 1;
925
+ this._sortKey = null;
926
+ this._sortDir = 'asc';
927
+ this._render();
928
+ }
929
+
930
+ _render() {
931
+ const { pageSize } = this._opts;
932
+ const start = (this._page - 1) * pageSize;
933
+ const rows = this._filtered.slice(start, start + pageSize);
934
+ const total = this._filtered.length;
935
+ const pages = Math.ceil(total / pageSize);
936
+
937
+ this._el.innerHTML = `
938
+ ${this._opts.searchable ? `
939
+ <div class="ztl-search-wrap" style="margin-bottom:12px">
940
+ <span class="ztl-search-icon">🔍</span>
941
+ <input class="ztl-input ztl-table-search" placeholder="${this._t.search}" value="${this._searchQuery || ''}">
942
+ </div>` : ''}
943
+ <div class="ztl-table-wrap">
944
+ <table class="ztl-table">
945
+ <thead>
946
+ <tr>${this._opts.columns.map(col => `
947
+ <th ${col.sortable ? `data-sort="${col.key}" style="cursor:pointer"` : ''}>
948
+ ${col.label}${this._sortKey === col.key ? (this._sortDir === 'asc' ? ' ↑' : ' ↓') : ''}
949
+ </th>`).join('')}
950
+ </tr>
951
+ </thead>
952
+ <tbody>
953
+ ${rows.length ? rows.map(row => `
954
+ <tr>${this._opts.columns.map(col => `
955
+ <td>${col.render ? col.render(row[col.key], row) : (row[col.key] ?? '—')}</td>`).join('')}
956
+ </tr>`).join('')
957
+ : `<tr><td colspan="${this._opts.columns.length}" style="text-align:center;color:var(--ztl-text-muted);padding:32px">${this._t.noData}</td></tr>`}
958
+ </tbody>
959
+ </table>
960
+ </div>
961
+ ${pages > 1 ? `
962
+ <div style="display:flex;align-items:center;justify-content:space-between;margin-top:12px;font-size:12px;color:var(--ztl-text-muted)">
963
+ <span>${this._t.showing(start + 1, Math.min(start + pageSize, total), total)}</span>
964
+ <div style="display:flex;gap:6px">
965
+ <button class="ztl-btn ztl-btn-ghost ztl-btn-sm ztl-dt-prev" ${this._page <= 1 ? 'disabled' : ''}>${this._t.prev}</button>
966
+ <button class="ztl-btn ztl-btn-ghost ztl-btn-sm ztl-dt-next" ${this._page >= pages ? 'disabled' : ''}>${this._t.next}</button>
967
+ </div>
968
+ </div>` : ''}
969
+ `;
970
+
971
+ this._bindTableEvents();
972
+ }
973
+
974
+ _bindTableEvents() {
975
+ // Search
976
+ const searchEl = $('.ztl-table-search', this._el);
977
+ if (searchEl) {
978
+ searchEl.addEventListener('input', (e) => {
979
+ this._searchQuery = e.target.value.toLowerCase();
980
+ this._filtered = this._data.filter(row =>
981
+ Object.values(row).some(v => String(v).toLowerCase().includes(this._searchQuery))
982
+ );
983
+ this._page = 1;
984
+ this._render();
985
+ });
986
+ }
987
+
988
+ // Sorting
989
+ $$('[data-sort]', this._el).forEach(th => {
990
+ th.addEventListener('click', () => {
991
+ const key = th.getAttribute('data-sort');
992
+ this._sortDir = this._sortKey === key && this._sortDir === 'asc' ? 'desc' : 'asc';
993
+ this._sortKey = key;
994
+ this._filtered.sort((a, b) => {
995
+ const av = a[key], bv = b[key];
996
+ return this._sortDir === 'asc'
997
+ ? (av > bv ? 1 : av < bv ? -1 : 0)
998
+ : (av < bv ? 1 : av > bv ? -1 : 0);
999
+ });
1000
+ this._render();
1001
+ });
1002
+ });
1003
+
1004
+ // Pagination
1005
+ const prev = $('.ztl-dt-prev', this._el);
1006
+ const next = $('.ztl-dt-next', this._el);
1007
+ if (prev) prev.addEventListener('click', () => { this._page--; this._render(); });
1008
+ if (next) next.addEventListener('click', () => { this._page++; this._render(); });
1009
+ }
1010
+
1011
+ update(data) {
1012
+ this._data = [...data];
1013
+ this._filtered = [...data];
1014
+ this._page = 1;
1015
+ this._render();
1016
+ return this;
1017
+ }
1018
+
1019
+ setPage(page) { this._page = page; this._render(); return this; }
1020
+ }
1021
+
1022
+ // ─────────────────────────────────────────────────────────────────────────────
1023
+ // AUTO-INIT (data-ztl-* attributes)
1024
+ // ─────────────────────────────────────────────────────────────────────────────
1025
+ function autoInit() {
1026
+ // Tabs
1027
+ $$('[data-ztl="tabs"]').forEach(el => new Tabs(el));
1028
+
1029
+ // Dropdowns
1030
+ $$('[data-ztl="dropdown"]').forEach(wrapper => {
1031
+ const trigger = $('[data-ztl-trigger]', wrapper) || wrapper.firstElementChild;
1032
+ new Dropdown(trigger, wrapper);
1033
+ });
1034
+
1035
+ // Scroll reveal
1036
+ initScrollReveal();
1037
+
1038
+ // Counters
1039
+ initCounters();
1040
+
1041
+ // Copy buttons
1042
+ initCopyButtons();
1043
+ }
1044
+
1045
+ // ─────────────────────────────────────────────────────────────────────────────
1046
+ // MAIN ZTL OBJECT
1047
+ // ─────────────────────────────────────────────────────────────────────────────
1048
+ const ZTL = {
1049
+ version: ZTL_VERSION,
1050
+
1051
+ /**
1052
+ * Initialize the library.
1053
+ * @param {Object} [config]
1054
+ * @param {string} [config.theme='dark']
1055
+ * @param {string} [config.accent='#00f5a0']
1056
+ * @param {boolean} [config.rtl=false]
1057
+ * @param {string} [config.lang='en']
1058
+ * @param {string} [config.toastPosition='br']
1059
+ * @param {number} [config.toastDuration=4000]
1060
+ * @param {boolean} [config.autoInit=true]
1061
+ */
1062
+ init(config = {}) {
1063
+ const opts = merge(DEFAULTS, config);
1064
+
1065
+ // Language
1066
+ this.lang = opts.lang;
1067
+ this._t = i18n[opts.lang] || i18n.en;
1068
+
1069
+ // Theme
1070
+ this.theme = new ThemeManager({ default: opts.theme });
1071
+ if (opts.accent && opts.accent !== DEFAULTS.accent) {
1072
+ this.theme.setAccent(opts.accent);
1073
+ }
1074
+
1075
+ // RTL
1076
+ if (opts.rtl || opts.lang === 'ar') {
1077
+ document.documentElement.setAttribute('dir', 'rtl');
1078
+ document.documentElement.setAttribute('lang', opts.lang);
1079
+ } else {
1080
+ document.documentElement.setAttribute('dir', 'ltr');
1081
+ document.documentElement.setAttribute('lang', opts.lang);
1082
+ }
1083
+
1084
+ // Toast manager
1085
+ DEFAULTS.toastDuration = opts.toastDuration;
1086
+ this.toast = new ToastManager(opts.toastPosition, opts.lang);
1087
+
1088
+ // Auto-init data-ztl-* components
1089
+ if (opts.autoInit !== false) {
1090
+ if (document.readyState === 'loading') {
1091
+ document.addEventListener('DOMContentLoaded', autoInit);
1092
+ } else {
1093
+ autoInit();
1094
+ }
1095
+ }
1096
+
1097
+ console.log(`%c⚡ ZTL v${ZTL_VERSION} initialized | lang: ${opts.lang} | theme: ${opts.theme}`,
1098
+ 'color:#00f5a0;font-family:monospace;font-weight:bold');
1099
+
1100
+ return this;
1101
+ },
1102
+
1103
+ // ── Component constructors ──
1104
+ Modal,
1105
+ Tabs,
1106
+ Dropdown,
1107
+ ProgressBar,
1108
+ CircularProgress,
1109
+ Tooltip,
1110
+ FormValidator,
1111
+ TagInput,
1112
+ DataTable,
1113
+ ThemeManager,
1114
+ ToastManager,
1115
+
1116
+ // ── Utilities ──
1117
+ copyToClipboard,
1118
+ animateCounter,
1119
+ skeleton,
1120
+ initScrollReveal,
1121
+ initCounters,
1122
+ initCopyButtons,
1123
+
1124
+ // ── i18n ──
1125
+ i18n,
1126
+ t(key, ...args) {
1127
+ const translation = (i18n[this.lang] || i18n.en)[key];
1128
+ return typeof translation === 'function' ? translation(...args) : (translation || key);
1129
+ },
1130
+
1131
+ // ── DOM helpers ──
1132
+ $,
1133
+ $$,
1134
+ };
1135
+
1136
+ // ─────────────────────────────────────────────────────────────────────────────
1137
+ // EXPORTS
1138
+ // ─────────────────────────────────────────────────────────────────────────────
1139
+ export default ZTL;
1140
+ export {
1141
+ ZTL,
1142
+ Modal,
1143
+ Tabs,
1144
+ Dropdown,
1145
+ ProgressBar,
1146
+ CircularProgress,
1147
+ Tooltip,
1148
+ FormValidator,
1149
+ TagInput,
1150
+ DataTable,
1151
+ ThemeManager,
1152
+ ToastManager,
1153
+ copyToClipboard,
1154
+ animateCounter,
1155
+ skeleton,
1156
+ initScrollReveal,
1157
+ initCounters,
1158
+ initCopyButtons,
1159
+ i18n,
1160
+ };