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