@whykusanagi/corrupted-theme 0.1.6 → 0.1.8

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.
@@ -4,13 +4,25 @@
4
4
  *
5
5
  * Provides helper functions for:
6
6
  * - Accordion/Collapse
7
+ * - Modal
8
+ * - Dropdown
9
+ * - Tabs
7
10
  * - Toast Notifications
8
- * - Auto-initialization
11
+ * - Auto-initialization via data-ct-* attributes
9
12
  *
10
13
  * @module components
11
- * @version 1.0.0
14
+ * @version 2.0.0
12
15
  */
13
16
 
17
+ import { EventTracker } from '../core/event-tracker.js';
18
+
19
+ /**
20
+ * Shared tracker for listeners created during auto-initialization.
21
+ * Cleaned up by destroyComponents().
22
+ * @private
23
+ */
24
+ const _initTracker = new EventTracker();
25
+
14
26
  // ========== ACCORDION / COLLAPSE ==========
15
27
 
16
28
  /**
@@ -28,7 +40,7 @@ export function initAccordions() {
28
40
  const header = item.querySelector('.accordion-header');
29
41
  if (!header) return;
30
42
 
31
- header.addEventListener('click', () => {
43
+ _initTracker.add(header, 'click', () => {
32
44
  const wasActive = item.classList.contains('active');
33
45
 
34
46
  // Close all items in this accordion (unless it's already active)
@@ -137,32 +149,38 @@ class ToastManager {
137
149
  const toast = document.createElement('div');
138
150
  toast.className = `toast ${type}`;
139
151
 
140
- // Create toast content
141
- const content = `
142
- ${title ? `
143
- <div class="toast-header">
144
- <span>${title}</span>
145
- </div>
146
- ` : ''}
147
- <div class="toast-body">${message}</div>
148
- <button class="toast-close" aria-label="Close">×</button>
149
- `;
150
-
151
- toast.innerHTML = content;
152
-
153
- // Add close handler
154
- const closeBtn = toast.querySelector('.toast-close');
152
+ // Create toast content using safe DOM methods
153
+ if (title) {
154
+ const header = document.createElement('div');
155
+ header.className = 'toast-header';
156
+ const titleSpan = document.createElement('span');
157
+ titleSpan.textContent = title;
158
+ header.appendChild(titleSpan);
159
+ toast.appendChild(header);
160
+ }
161
+
162
+ const body = document.createElement('div');
163
+ body.className = 'toast-body';
164
+ body.textContent = message;
165
+ toast.appendChild(body);
166
+
167
+ const closeBtn = document.createElement('button');
168
+ closeBtn.className = 'toast-close';
169
+ closeBtn.setAttribute('aria-label', 'Close');
170
+ closeBtn.textContent = '\u00d7';
155
171
  closeBtn.addEventListener('click', () => {
156
172
  this.dismiss(toast, onClose);
157
173
  });
174
+ toast.appendChild(closeBtn);
158
175
 
159
176
  // Add to container
160
177
  container.appendChild(toast);
161
178
  this.toasts.push(toast);
162
179
 
163
- // Auto-dismiss
180
+ // Auto-dismiss (tracked for cleanup on manual dismiss)
164
181
  if (duration > 0) {
165
- setTimeout(() => {
182
+ toast._autoDismissId = setTimeout(() => {
183
+ toast._autoDismissId = null;
166
184
  this.dismiss(toast, onClose);
167
185
  }, duration);
168
186
  }
@@ -178,6 +196,12 @@ class ToastManager {
178
196
  dismiss(toast, onClose = null) {
179
197
  if (!toast || !document.contains(toast)) return;
180
198
 
199
+ // Clear auto-dismiss timer if dismissing early
200
+ if (toast._autoDismissId) {
201
+ clearTimeout(toast._autoDismissId);
202
+ toast._autoDismissId = null;
203
+ }
204
+
181
205
  toast.classList.add('hiding');
182
206
 
183
207
  setTimeout(() => {
@@ -201,7 +225,19 @@ class ToastManager {
201
225
  * Dismiss all toasts
202
226
  */
203
227
  dismissAll() {
204
- this.toasts.forEach(toast => this.dismiss(toast));
228
+ [...this.toasts].forEach(toast => this.dismiss(toast));
229
+ }
230
+
231
+ /**
232
+ * Tear down the toast system: dismiss all toasts and remove container.
233
+ */
234
+ destroy() {
235
+ this.dismissAll();
236
+ if (this.container && this.container.parentNode) {
237
+ this.container.parentNode.removeChild(this.container);
238
+ }
239
+ this.container = null;
240
+ this.toasts = [];
205
241
  }
206
242
 
207
243
  // Convenience methods for different toast types
@@ -247,16 +283,305 @@ export const toast = {
247
283
  dismissAll: () => toastManager.dismissAll()
248
284
  };
249
285
 
286
+ // ========== MODAL ==========
287
+
288
+ /**
289
+ * Modal manager with lifecycle management
290
+ *
291
+ * Usage:
292
+ * ```html
293
+ * <button data-ct-toggle="modal" data-ct-target="#my-modal">Open</button>
294
+ * <div class="modal-overlay" id="my-modal">
295
+ * <div class="modal">
296
+ * <div class="modal-header">
297
+ * <h3 class="modal-title">Title</h3>
298
+ * <button class="modal-close">&times;</button>
299
+ * </div>
300
+ * <div class="modal-body">Content</div>
301
+ * </div>
302
+ * </div>
303
+ * ```
304
+ */
305
+ class ModalManager {
306
+ constructor() {
307
+ this._events = new EventTracker();
308
+ this._initialized = new Set();
309
+ }
310
+
311
+ /**
312
+ * Initialize a modal overlay
313
+ * @param {string|HTMLElement} selector - Modal overlay selector or element
314
+ */
315
+ init(selector) {
316
+ const overlay = typeof selector === 'string'
317
+ ? document.querySelector(selector) : selector;
318
+ if (!overlay || this._initialized.has(overlay)) return;
319
+
320
+ const closeBtn = overlay.querySelector('.modal-close');
321
+ if (closeBtn) {
322
+ this._events.add(closeBtn, 'click', () => this.close(overlay));
323
+ }
324
+
325
+ // Close on overlay click (outside modal content)
326
+ this._events.add(overlay, 'click', (e) => {
327
+ if (e.target === overlay) this.close(overlay);
328
+ });
329
+
330
+ this._initialized.add(overlay);
331
+ }
332
+
333
+ /**
334
+ * Open a modal
335
+ * @param {string|HTMLElement} selector - Modal overlay selector or element
336
+ */
337
+ open(selector) {
338
+ const overlay = typeof selector === 'string'
339
+ ? document.querySelector(selector) : selector;
340
+ if (!overlay) return;
341
+
342
+ overlay.classList.add('active');
343
+ document.body.style.overflow = 'hidden';
344
+
345
+ overlay.dispatchEvent(new CustomEvent('modal:open', { bubbles: true }));
346
+ }
347
+
348
+ /**
349
+ * Close a modal
350
+ * @param {string|HTMLElement} selector - Modal overlay selector or element
351
+ */
352
+ close(selector) {
353
+ const overlay = typeof selector === 'string'
354
+ ? document.querySelector(selector) : selector;
355
+ if (!overlay) return;
356
+
357
+ overlay.classList.remove('active');
358
+ document.body.style.overflow = '';
359
+
360
+ overlay.dispatchEvent(new CustomEvent('modal:close', { bubbles: true }));
361
+ }
362
+
363
+ /**
364
+ * Tear down all tracked listeners and state
365
+ */
366
+ destroy() {
367
+ this._events.removeAll();
368
+ this._initialized.clear();
369
+ }
370
+ }
371
+
372
+ const modalManager = new ModalManager();
373
+
374
+ /**
375
+ * Open a modal by selector
376
+ * @param {string|HTMLElement} selector
377
+ */
378
+ export function openModal(selector) {
379
+ modalManager.open(selector);
380
+ }
381
+
382
+ /**
383
+ * Close a modal by selector
384
+ * @param {string|HTMLElement} selector
385
+ */
386
+ export function closeModal(selector) {
387
+ modalManager.close(selector);
388
+ }
389
+
390
+ // ========== DROPDOWN ==========
391
+
392
+ /**
393
+ * Dropdown manager with click-outside-to-close
394
+ *
395
+ * Usage:
396
+ * ```html
397
+ * <div class="dropdown">
398
+ * <button class="dropdown-toggle" data-ct-toggle="dropdown">Menu</button>
399
+ * <div class="dropdown-menu">
400
+ * <a class="dropdown-item" href="#">Item 1</a>
401
+ * </div>
402
+ * </div>
403
+ * ```
404
+ */
405
+ class DropdownManager {
406
+ constructor() {
407
+ this._events = new EventTracker();
408
+ this._initialized = false;
409
+ this._outsideClickBound = false;
410
+ }
411
+
412
+ /**
413
+ * Initialize dropdown toggle behavior
414
+ * @param {HTMLElement} toggle - The dropdown-toggle element
415
+ */
416
+ init(toggle) {
417
+ const menu = toggle.parentElement?.querySelector('.dropdown-menu');
418
+ if (!menu) return;
419
+
420
+ this._events.add(toggle, 'click', (e) => {
421
+ e.stopPropagation();
422
+ const isOpen = menu.classList.contains('active');
423
+
424
+ // Close all other dropdowns first
425
+ this.closeAll();
426
+
427
+ if (!isOpen) {
428
+ menu.classList.add('active');
429
+ toggle.classList.add('active');
430
+ }
431
+ });
432
+
433
+ // Setup global click-outside handler once
434
+ if (!this._outsideClickBound) {
435
+ this._events.add(document, 'click', () => this.closeAll());
436
+ this._outsideClickBound = true;
437
+ }
438
+ }
439
+
440
+ /**
441
+ * Close all open dropdowns
442
+ */
443
+ closeAll() {
444
+ document.querySelectorAll('.dropdown-menu.active').forEach(menu => {
445
+ menu.classList.remove('active');
446
+ });
447
+ document.querySelectorAll('.dropdown-toggle.active').forEach(toggle => {
448
+ toggle.classList.remove('active');
449
+ });
450
+ }
451
+
452
+ /**
453
+ * Tear down all tracked listeners
454
+ */
455
+ destroy() {
456
+ this.closeAll();
457
+ this._events.removeAll();
458
+ this._outsideClickBound = false;
459
+ }
460
+ }
461
+
462
+ const dropdownManager = new DropdownManager();
463
+
464
+ // ========== TABS ==========
465
+
466
+ /**
467
+ * Tab manager
468
+ *
469
+ * Usage:
470
+ * ```html
471
+ * <div class="tabs">
472
+ * <button class="tab active" data-ct-target="#panel-1">Tab 1</button>
473
+ * <button class="tab" data-ct-target="#panel-2">Tab 2</button>
474
+ * </div>
475
+ * <div class="tab-content active" id="panel-1">Panel 1</div>
476
+ * <div class="tab-content" id="panel-2">Panel 2</div>
477
+ * ```
478
+ */
479
+ class TabManager {
480
+ constructor() {
481
+ this._events = new EventTracker();
482
+ }
483
+
484
+ /**
485
+ * Initialize a tab container
486
+ * @param {HTMLElement} tabsContainer - The .tabs element
487
+ */
488
+ init(tabsContainer) {
489
+ const tabs = tabsContainer.querySelectorAll('.tab[data-ct-target]');
490
+
491
+ tabs.forEach(tab => {
492
+ this._events.add(tab, 'click', () => {
493
+ // Deactivate all sibling tabs
494
+ tabsContainer.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
495
+
496
+ // Hide all associated panels
497
+ tabs.forEach(t => {
498
+ const panel = document.querySelector(t.dataset.ctTarget);
499
+ if (panel) panel.classList.remove('active');
500
+ });
501
+
502
+ // Activate clicked tab and its panel
503
+ tab.classList.add('active');
504
+ const panel = document.querySelector(tab.dataset.ctTarget);
505
+ if (panel) panel.classList.add('active');
506
+ });
507
+ });
508
+ }
509
+
510
+ /**
511
+ * Tear down all tracked listeners
512
+ */
513
+ destroy() {
514
+ this._events.removeAll();
515
+ }
516
+ }
517
+
518
+ const tabManager = new TabManager();
519
+
250
520
  // ========== AUTO-INITIALIZATION ==========
251
521
 
252
522
  /**
253
- * Initialize all components on page load
523
+ * Initialize all components on page load.
524
+ * Scans for data-ct-* attributes and wires up behavior.
525
+ * All listeners are tracked via _initTracker or manager EventTrackers.
254
526
  */
255
527
  function initComponents() {
256
- // Initialize accordions
528
+ // Accordions
257
529
  if (document.querySelector('.accordion')) {
258
530
  initAccordions();
259
531
  }
532
+
533
+ // Modals — init overlays and wire triggers
534
+ document.querySelectorAll('.modal-overlay').forEach(overlay => {
535
+ modalManager.init(overlay);
536
+ });
537
+
538
+ document.querySelectorAll('[data-ct-toggle="modal"]').forEach(trigger => {
539
+ const targetSel = trigger.dataset.ctTarget;
540
+ if (!targetSel) return;
541
+ _initTracker.add(trigger, 'click', () => modalManager.open(targetSel));
542
+ });
543
+
544
+ // Escape key closes active modals
545
+ if (document.querySelector('.modal-overlay')) {
546
+ _initTracker.add(document, 'keydown', (e) => {
547
+ if (e.key === 'Escape') {
548
+ document.querySelectorAll('.modal-overlay.active').forEach(overlay => {
549
+ modalManager.close(overlay);
550
+ });
551
+ }
552
+ });
553
+ }
554
+
555
+ // Dropdowns
556
+ document.querySelectorAll('[data-ct-toggle="dropdown"]').forEach(toggle => {
557
+ dropdownManager.init(toggle);
558
+ });
559
+
560
+ // Tabs
561
+ document.querySelectorAll('.tabs').forEach(tabsContainer => {
562
+ if (tabsContainer.querySelector('.tab[data-ct-target]')) {
563
+ tabManager.init(tabsContainer);
564
+ }
565
+ });
566
+
567
+ // Collapse triggers
568
+ document.querySelectorAll('[data-ct-toggle="collapse"]').forEach(trigger => {
569
+ const targetSel = trigger.dataset.ctTarget;
570
+ if (!targetSel) return;
571
+ _initTracker.add(trigger, 'click', () => toggleCollapse(targetSel));
572
+ });
573
+ }
574
+
575
+ /**
576
+ * Destroy all component managers and clean up listeners.
577
+ * Removes all tracked listeners from auto-initialization and managers.
578
+ */
579
+ export function destroyComponents() {
580
+ _initTracker.removeAll();
581
+ modalManager.destroy();
582
+ dropdownManager.destroy();
583
+ tabManager.destroy();
584
+ toastManager.destroy();
260
585
  }
261
586
 
262
587
  // Auto-initialize on DOM ready
@@ -271,13 +596,20 @@ if (typeof window !== 'undefined') {
271
596
  // ========== EXPORTS ==========
272
597
 
273
598
  export default {
274
- // Accordion
599
+ // Accordion / Collapse
275
600
  initAccordions,
276
601
  toggleCollapse,
277
602
  showCollapse,
278
603
  hideCollapse,
279
604
 
605
+ // Modal
606
+ openModal,
607
+ closeModal,
608
+
280
609
  // Toast
281
610
  showToast,
282
- toast
611
+ toast,
612
+
613
+ // Lifecycle
614
+ destroyComponents
283
615
  };