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