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