@briannorman9/eli-utils 1.0.0 → 1.1.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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +177 -35
  3. package/index.js +1100 -11
  4. package/package.json +17 -13
package/index.js CHANGED
@@ -1,13 +1,16 @@
1
1
  // ELI Utils - Utility functions for web experiments
2
- // This package can be installed via: npm install @briannorman9/eli-utils
3
- // Then imported in variant code using: import utils from '@eli/utils';
4
- // (The server automatically resolves @eli/utils to the @briannorman9/eli-utils package)
2
+ // This file can be imported in variant code using: import utils from '@eli/utils';
5
3
 
6
4
  export default {
7
5
  /**
8
- * Wait for an element to appear in the DOM
6
+ * Wait for the first element matching the selector to appear in the DOM
7
+ * Note: This only matches the first element. Use waitForElements() to match all elements.
9
8
  * @param {string|Element} selector - CSS selector or element
10
- * @returns {Promise<Element>} Promise that resolves with the element (waits indefinitely)
9
+ * @returns {Promise<Element>} Promise that resolves with the first matching element (waits indefinitely)
10
+ * @example
11
+ * utils.waitForElement('.product-card').then(element => {
12
+ * element.style.border = '2px solid red';
13
+ * });
11
14
  */
12
15
  waitForElement: function(selector) {
13
16
  return new Promise((resolve) => {
@@ -84,11 +87,13 @@ export default {
84
87
  * @param {string} value - Cookie value
85
88
  * @param {number} days - Number of days until expiration (default: 365)
86
89
  * @param {string} path - Cookie path (default: '/')
90
+ * @param {string} domain - Cookie domain (optional)
87
91
  */
88
- setCookie: function(name, value, days = 365, path = '/') {
92
+ setCookie: function(name, value, days = 365, path = '/', domain = '') {
89
93
  const expires = new Date();
90
94
  expires.setTime(expires.getTime() + (days * 24 * 60 * 60 * 1000));
91
- document.cookie = `${name}=${value};expires=${expires.toUTCString()};path=${path}`;
95
+ const domainStr = domain ? `domain=${domain};` : '';
96
+ document.cookie = `${name}=${value};expires=${expires.toUTCString()};path=${path};${domainStr}`;
92
97
  },
93
98
 
94
99
  /**
@@ -127,13 +132,39 @@ export default {
127
132
  },
128
133
 
129
134
  /**
130
- * Select multiple elements
135
+ * Wait for all elements matching the selector to appear in the DOM
136
+ * Note: This matches all elements. Use waitForElement() to match only the first element.
131
137
  * @param {string} selector - CSS selector
132
138
  * @param {Element} context - Context element (defaults to document)
133
- * @returns {NodeList} NodeList of elements
139
+ * @returns {Promise<NodeList>} Promise that resolves with all matching elements (waits indefinitely)
140
+ * @example
141
+ * utils.waitForElements('.product-card').then(elements => {
142
+ * elements.forEach(card => card.style.border = '2px solid red');
143
+ * });
134
144
  */
135
- selectAll: function(selector, context = document) {
136
- return context.querySelectorAll(selector);
145
+ waitForElements: function(selector, context = document) {
146
+ return new Promise((resolve) => {
147
+ // Check if elements already exist
148
+ const elements = context.querySelectorAll(selector);
149
+ if (elements.length > 0) {
150
+ resolve(elements);
151
+ return;
152
+ }
153
+
154
+ // Set up MutationObserver to watch for elements
155
+ const observer = new MutationObserver((mutations, obs) => {
156
+ const foundElements = context.querySelectorAll(selector);
157
+ if (foundElements.length > 0) {
158
+ obs.disconnect();
159
+ resolve(foundElements);
160
+ }
161
+ });
162
+
163
+ observer.observe(context === document ? document.body : context, {
164
+ childList: true,
165
+ subtree: true
166
+ });
167
+ });
137
168
  },
138
169
 
139
170
  /**
@@ -257,6 +288,1064 @@ export default {
257
288
  scrollIntoView: function(element, options = { behavior: 'smooth', block: 'center' }) {
258
289
  const el = typeof element === 'string' ? document.querySelector(element) : element;
259
290
  if (el) el.scrollIntoView(options);
291
+ },
292
+
293
+ /**
294
+ * Observe mutations on the first element matching the selector
295
+ * The callback is called every time the element is mutated (attributes, children, text, etc.)
296
+ * Note: This only observes the first matching element. Use observeSelectors() to observe all elements.
297
+ *
298
+ * @param {string|Element} selector - CSS selector or element to observe
299
+ * @param {Function} callback - Callback function that receives (element, mutationRecord, observer)
300
+ * @param {Object} options - Options object
301
+ * @param {Array<string>} options.mutations - Array of mutation types to observe. Options:
302
+ * - 'childList' - Watch for child nodes being added/removed (default: true)
303
+ * - 'attributes' - Watch for attribute changes (default: true)
304
+ * - 'characterData' - Watch for text content changes (default: false)
305
+ * - 'subtree' - Watch all descendants, not just direct children (default: true)
306
+ * - 'attributeOldValue' - Include old attribute value in mutation record (default: false)
307
+ * - 'characterDataOldValue' - Include old text value in mutation record (default: false)
308
+ * - 'attributeFilter' - Array of attribute names to observe (only these attributes will trigger)
309
+ * @param {number} options.timeout - Timeout in milliseconds (optional)
310
+ * @param {Function} options.onTimeout - Function to call on timeout (optional)
311
+ * @returns {Function} Function to stop observing
312
+ *
313
+ * @example
314
+ * // Observe attribute changes on a button
315
+ * const stopObserving = utils.observeSelector('#myButton', (element, mutation) => {
316
+ * console.log('Button mutated:', mutation.type);
317
+ * if (mutation.type === 'attributes') {
318
+ * console.log('Attribute changed:', mutation.attributeName);
319
+ * }
320
+ * }, {
321
+ * mutations: ['attributes'],
322
+ * attributeFilter: ['class', 'disabled']
323
+ * });
324
+ *
325
+ * @example
326
+ * // Observe when children are added/removed
327
+ * utils.observeSelector('.product-list', (element, mutation) => {
328
+ * if (mutation.type === 'childList') {
329
+ * console.log('Children changed:', mutation.addedNodes.length, 'added');
330
+ * }
331
+ * }, {
332
+ * mutations: ['childList', 'subtree']
333
+ * });
334
+ */
335
+ observeSelector: function(selector, callback, options = {}) {
336
+ const {
337
+ mutations = ['childList', 'attributes', 'subtree'],
338
+ timeout = null,
339
+ onTimeout = null,
340
+ attributeFilter = null
341
+ } = options;
342
+
343
+ // Build MutationObserver options
344
+ const observerOptions = {
345
+ childList: mutations.includes('childList'),
346
+ attributes: mutations.includes('attributes'),
347
+ characterData: mutations.includes('characterData'),
348
+ subtree: mutations.includes('subtree'),
349
+ attributeOldValue: mutations.includes('attributeOldValue'),
350
+ characterDataOldValue: mutations.includes('characterDataOldValue')
351
+ };
352
+
353
+ if (attributeFilter && Array.isArray(attributeFilter)) {
354
+ observerOptions.attributeFilter = attributeFilter;
355
+ }
356
+
357
+ let observer = null;
358
+ let timeoutId = null;
359
+ let isStopped = false;
360
+
361
+ // Get or wait for element
362
+ const element = typeof selector === 'string' ? document.querySelector(selector) : selector;
363
+
364
+ function startObserving(el) {
365
+ if (!el || isStopped) return;
366
+
367
+ observer = new MutationObserver((mutationRecords, obs) => {
368
+ if (isStopped) return;
369
+ mutationRecords.forEach(mutation => {
370
+ callback(el, mutation, obs);
371
+ });
372
+ });
373
+
374
+ observer.observe(el, observerOptions);
375
+
376
+ if (timeout) {
377
+ timeoutId = setTimeout(() => {
378
+ stopObserving();
379
+ if (onTimeout) onTimeout();
380
+ }, timeout);
381
+ }
382
+ }
383
+
384
+ function stopObserving() {
385
+ isStopped = true;
386
+ if (observer) {
387
+ observer.disconnect();
388
+ observer = null;
389
+ }
390
+ if (timeoutId) {
391
+ clearTimeout(timeoutId);
392
+ timeoutId = null;
393
+ }
394
+ }
395
+
396
+ if (element) {
397
+ // Element exists, start observing immediately
398
+ startObserving(element);
399
+ } else if (typeof selector === 'string') {
400
+ // Element doesn't exist yet, wait for it
401
+ this.waitForElement(selector).then(el => {
402
+ if (!isStopped) {
403
+ startObserving(el);
404
+ }
405
+ });
406
+ }
407
+
408
+ return stopObserving;
409
+ },
410
+
411
+ /**
412
+ * Observe mutations on all elements matching the selector
413
+ * The callback is called every time any matching element is mutated
414
+ * Note: This observes all matching elements. Use observeSelector() to observe only the first element.
415
+ *
416
+ * @param {string} selector - CSS selector to observe
417
+ * @param {Function} callback - Callback function that receives (element, mutationRecord, observer)
418
+ * @param {Object} options - Options object
419
+ * @param {Array<string>} options.mutations - Array of mutation types to observe. Options:
420
+ * - 'childList' - Watch for child nodes being added/removed (default: true)
421
+ * - 'attributes' - Watch for attribute changes (default: true)
422
+ * - 'characterData' - Watch for text content changes (default: false)
423
+ * - 'subtree' - Watch all descendants, not just direct children (default: true)
424
+ * - 'attributeOldValue' - Include old attribute value in mutation record (default: false)
425
+ * - 'characterDataOldValue' - Include old text value in mutation record (default: false)
426
+ * - 'attributeFilter' - Array of attribute names to observe (only these attributes will trigger)
427
+ * @param {number} options.timeout - Timeout in milliseconds (optional)
428
+ * @param {Function} options.onTimeout - Function to call on timeout (optional)
429
+ * @returns {Function} Function to stop observing
430
+ *
431
+ * @example
432
+ * // Observe all product cards for attribute changes
433
+ * const stopObserving = utils.observeSelectors('.product-card', (element, mutation) => {
434
+ * if (mutation.type === 'attributes' && mutation.attributeName === 'data-price') {
435
+ * console.log('Price changed on:', element);
436
+ * }
437
+ * }, {
438
+ * mutations: ['attributes'],
439
+ * attributeFilter: ['data-price', 'class']
440
+ * });
441
+ *
442
+ * @example
443
+ * // Observe when children are added to any matching container
444
+ * utils.observeSelectors('.dynamic-list', (element, mutation) => {
445
+ * if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
446
+ * console.log('New items added to:', element);
447
+ * }
448
+ * }, {
449
+ * mutations: ['childList']
450
+ * });
451
+ */
452
+ observeSelectors: function(selector, callback, options = {}) {
453
+ const {
454
+ mutations = ['childList', 'attributes', 'subtree'],
455
+ timeout = null,
456
+ onTimeout = null,
457
+ attributeFilter = null
458
+ } = options;
459
+
460
+ // Build MutationObserver options
461
+ const observerOptions = {
462
+ childList: mutations.includes('childList'),
463
+ attributes: mutations.includes('attributes'),
464
+ characterData: mutations.includes('characterData'),
465
+ subtree: mutations.includes('subtree'),
466
+ attributeOldValue: mutations.includes('attributeOldValue'),
467
+ characterDataOldValue: mutations.includes('characterDataOldValue')
468
+ };
469
+
470
+ if (attributeFilter && Array.isArray(attributeFilter)) {
471
+ observerOptions.attributeFilter = attributeFilter;
472
+ }
473
+
474
+ const observers = new Map(); // Map of element -> observer
475
+ let timeoutId = null;
476
+
477
+ // Function to observe a specific element
478
+ function observeElement(element) {
479
+ if (observers.has(element)) return; // Already observing
480
+
481
+ const observer = new MutationObserver((mutationRecords, obs) => {
482
+ mutationRecords.forEach(mutation => {
483
+ callback(element, mutation, obs);
484
+ });
485
+ });
486
+
487
+ observer.observe(element, observerOptions);
488
+ observers.set(element, observer);
489
+ }
490
+
491
+ // Function to find and observe all matching elements
492
+ function findAndObserveElements() {
493
+ const elements = document.querySelectorAll(selector);
494
+ elements.forEach(el => observeElement(el));
495
+ }
496
+
497
+ // Observe existing elements
498
+ findAndObserveElements();
499
+
500
+ // Set up observer to watch for new elements being added
501
+ const rootObserver = new MutationObserver(() => {
502
+ findAndObserveElements();
503
+ });
504
+
505
+ rootObserver.observe(document.body, {
506
+ childList: true,
507
+ subtree: true
508
+ });
509
+
510
+ if (timeout) {
511
+ timeoutId = setTimeout(() => {
512
+ stopObserving();
513
+ if (onTimeout) onTimeout();
514
+ }, timeout);
515
+ }
516
+
517
+ function stopObserving() {
518
+ rootObserver.disconnect();
519
+ observers.forEach(observer => observer.disconnect());
520
+ observers.clear();
521
+ }
522
+
523
+ return stopObserving;
524
+ },
525
+
526
+ /**
527
+ * Poll - repeatedly execute a callback at specified intervals
528
+ * @param {Function} callback - Function to execute
529
+ * @param {number} delay - Delay in milliseconds between executions
530
+ * @returns {Function} Function to cancel polling
531
+ */
532
+ poll: function(callback, delay) {
533
+ const intervalId = setInterval(callback, delay);
534
+ return () => clearInterval(intervalId);
535
+ },
536
+
537
+ /**
538
+ * Get element text content
539
+ * @param {Element|string} element - Element or selector
540
+ * @returns {string} Text content
541
+ */
542
+ getText: function(element) {
543
+ const el = typeof element === 'string' ? document.querySelector(element) : element;
544
+ return el ? el.textContent : '';
545
+ },
546
+
547
+ /**
548
+ * Set element text content
549
+ * @param {Element|string} element - Element or selector
550
+ * @param {string} text - Text to set
551
+ */
552
+ setText: function(element, text) {
553
+ const el = typeof element === 'string' ? document.querySelector(element) : element;
554
+ if (el) el.textContent = text;
555
+ },
556
+
557
+ /**
558
+ * Get element inner HTML
559
+ * @param {Element|string} element - Element or selector
560
+ * @returns {string} HTML content
561
+ */
562
+ getHTML: function(element) {
563
+ const el = typeof element === 'string' ? document.querySelector(element) : element;
564
+ return el ? el.innerHTML : '';
565
+ },
566
+
567
+ /**
568
+ * Set element inner HTML
569
+ * @param {Element|string} element - Element or selector
570
+ * @param {string} html - HTML to set
571
+ */
572
+ setHTML: function(element, html) {
573
+ const el = typeof element === 'string' ? document.querySelector(element) : element;
574
+ if (el) el.innerHTML = html;
575
+ },
576
+
577
+ /**
578
+ * Get element attribute value
579
+ * @param {Element|string} element - Element or selector
580
+ * @param {string} attr - Attribute name
581
+ * @returns {string|null} Attribute value or null
582
+ */
583
+ getAttr: function(element, attr) {
584
+ const el = typeof element === 'string' ? document.querySelector(element) : element;
585
+ return el ? el.getAttribute(attr) : null;
586
+ },
587
+
588
+ /**
589
+ * Set element attribute
590
+ * @param {Element|string} element - Element or selector
591
+ * @param {string} attr - Attribute name
592
+ * @param {string} value - Attribute value
593
+ */
594
+ setAttr: function(element, attr, value) {
595
+ const el = typeof element === 'string' ? document.querySelector(element) : element;
596
+ if (el) el.setAttribute(attr, value);
597
+ },
598
+
599
+ /**
600
+ * Remove element attribute
601
+ * @param {Element|string} element - Element or selector
602
+ * @param {string} attr - Attribute name
603
+ */
604
+ removeAttr: function(element, attr) {
605
+ const el = typeof element === 'string' ? document.querySelector(element) : element;
606
+ if (el) el.removeAttribute(attr);
607
+ },
608
+
609
+ /**
610
+ * Get data attribute value
611
+ * @param {Element|string} element - Element or selector
612
+ * @param {string} name - Data attribute name (without 'data-' prefix)
613
+ * @returns {string|null} Data attribute value or null
614
+ */
615
+ getData: function(element, name) {
616
+ const el = typeof element === 'string' ? document.querySelector(element) : element;
617
+ return el ? el.getAttribute(`data-${name}`) : null;
618
+ },
619
+
620
+ /**
621
+ * Set data attribute
622
+ * @param {Element|string} element - Element or selector
623
+ * @param {string} name - Data attribute name (without 'data-' prefix)
624
+ * @param {string} value - Data attribute value
625
+ */
626
+ setData: function(element, name, value) {
627
+ const el = typeof element === 'string' ? document.querySelector(element) : element;
628
+ if (el) el.setAttribute(`data-${name}`, value);
629
+ },
630
+
631
+ /**
632
+ * Get computed style property value
633
+ * @param {Element|string} element - Element or selector
634
+ * @param {string} property - CSS property name
635
+ * @returns {string} Computed style value
636
+ */
637
+ getStyle: function(element, property) {
638
+ const el = typeof element === 'string' ? document.querySelector(element) : element;
639
+ if (!el) return '';
640
+ const computed = window.getComputedStyle(el);
641
+ return property ? computed.getPropertyValue(property) : computed;
642
+ },
643
+
644
+ /**
645
+ * Set element style property
646
+ * @param {Element|string} element - Element or selector
647
+ * @param {string|Object} property - CSS property name or object of properties
648
+ * @param {string} value - CSS value (if property is string)
649
+ */
650
+ setStyle: function(element, property, value) {
651
+ const el = typeof element === 'string' ? document.querySelector(element) : element;
652
+ if (!el) return;
653
+
654
+ if (typeof property === 'object') {
655
+ Object.assign(el.style, property);
656
+ } else {
657
+ el.style[property] = value;
658
+ }
659
+ },
660
+
661
+ /**
662
+ * Get or set element value (for form inputs)
663
+ * @param {Element|string} element - Element or selector
664
+ * @param {string} value - Value to set (optional)
665
+ * @returns {string|undefined} Current value if getting, undefined if setting
666
+ */
667
+ val: function(element, value) {
668
+ const el = typeof element === 'string' ? document.querySelector(element) : element;
669
+ if (!el) return undefined;
670
+
671
+ if (value === undefined) {
672
+ return el.value || '';
673
+ } else {
674
+ el.value = value;
675
+ // Trigger input event for React/Vue compatibility
676
+ el.dispatchEvent(new Event('input', { bubbles: true }));
677
+ el.dispatchEvent(new Event('change', { bubbles: true }));
678
+ }
679
+ },
680
+
681
+ /**
682
+ * Check if element is visible (not hidden by display, visibility, or opacity)
683
+ * @param {Element|string} element - Element or selector
684
+ * @returns {boolean} True if element is visible
685
+ */
686
+ isVisible: function(element) {
687
+ const el = typeof element === 'string' ? document.querySelector(element) : element;
688
+ if (!el) return false;
689
+
690
+ const style = window.getComputedStyle(el);
691
+ return style.display !== 'none' &&
692
+ style.visibility !== 'hidden' &&
693
+ style.opacity !== '0' &&
694
+ el.offsetWidth > 0 &&
695
+ el.offsetHeight > 0;
696
+ },
697
+
698
+ /**
699
+ * Show element (set display to block or original value)
700
+ * @param {Element|string} element - Element or selector
701
+ * @param {string} display - Display value (default: 'block')
702
+ */
703
+ show: function(element, display = 'block') {
704
+ const el = typeof element === 'string' ? document.querySelector(element) : element;
705
+ if (el) {
706
+ if (!el.dataset.originalDisplay) {
707
+ el.dataset.originalDisplay = window.getComputedStyle(el).display;
708
+ }
709
+ el.style.display = display;
710
+ }
711
+ },
712
+
713
+ /**
714
+ * Hide element (set display to none)
715
+ * @param {Element|string} element - Element or selector
716
+ */
717
+ hide: function(element) {
718
+ const el = typeof element === 'string' ? document.querySelector(element) : element;
719
+ if (el) {
720
+ if (!el.dataset.originalDisplay) {
721
+ el.dataset.originalDisplay = window.getComputedStyle(el).display;
722
+ }
723
+ el.style.display = 'none';
724
+ }
725
+ },
726
+
727
+ /**
728
+ * Get element dimensions and position
729
+ * @param {Element|string} element - Element or selector
730
+ * @returns {Object} Object with width, height, top, left, right, bottom
731
+ */
732
+ getRect: function(element) {
733
+ const el = typeof element === 'string' ? document.querySelector(element) : element;
734
+ if (!el) return null;
735
+
736
+ const rect = el.getBoundingClientRect();
737
+ return {
738
+ width: rect.width,
739
+ height: rect.height,
740
+ top: rect.top,
741
+ left: rect.left,
742
+ right: rect.right,
743
+ bottom: rect.bottom,
744
+ x: rect.x,
745
+ y: rect.y
746
+ };
747
+ },
748
+
749
+ /**
750
+ * Get element offset (position relative to document)
751
+ * @param {Element|string} element - Element or selector
752
+ * @returns {Object} Object with top and left offset
753
+ */
754
+ getOffset: function(element) {
755
+ const el = typeof element === 'string' ? document.querySelector(element) : element;
756
+ if (!el) return null;
757
+
758
+ const rect = el.getBoundingClientRect();
759
+ return {
760
+ top: rect.top + window.scrollY,
761
+ left: rect.left + window.scrollX
762
+ };
763
+ },
764
+
765
+ /**
766
+ * Get or set URL query parameters
767
+ * @param {string} name - Parameter name (optional, if omitted returns all params)
768
+ * @param {string} value - Value to set (optional)
769
+ * @param {string} url - URL to use (defaults to current location)
770
+ * @returns {string|Object|null} Parameter value, all params object, or null
771
+ */
772
+ queryParam: function(name, value, url = window.location.href) {
773
+ const urlObj = new URL(url);
774
+
775
+ if (name === undefined) {
776
+ // Return all params as object
777
+ const params = {};
778
+ urlObj.searchParams.forEach((val, key) => {
779
+ params[key] = val;
780
+ });
781
+ return params;
782
+ }
783
+
784
+ if (value === undefined) {
785
+ // Get param
786
+ return urlObj.searchParams.get(name);
787
+ } else {
788
+ // Set param
789
+ urlObj.searchParams.set(name, value);
790
+ return urlObj.toString();
791
+ }
792
+ },
793
+
794
+ /**
795
+ * Update URL without page reload
796
+ * @param {string} url - New URL
797
+ * @param {boolean} replace - If true, replace current history entry (default: false)
798
+ */
799
+ updateURL: function(url, replace = false) {
800
+ if (replace) {
801
+ window.history.replaceState({}, '', url);
802
+ } else {
803
+ window.history.pushState({}, '', url);
804
+ }
805
+ },
806
+
807
+ /**
808
+ * Debounce function - delays execution until after wait time
809
+ * @param {Function} func - Function to debounce
810
+ * @param {number} wait - Wait time in milliseconds
811
+ * @param {boolean} immediate - If true, trigger on leading edge (default: false)
812
+ * @returns {Function} Debounced function
813
+ */
814
+ debounce: function(func, wait, immediate = false) {
815
+ let timeout;
816
+ return function executedFunction(...args) {
817
+ const later = () => {
818
+ timeout = null;
819
+ if (!immediate) func.apply(this, args);
820
+ };
821
+ const callNow = immediate && !timeout;
822
+ clearTimeout(timeout);
823
+ timeout = setTimeout(later, wait);
824
+ if (callNow) func.apply(this, args);
825
+ };
826
+ },
827
+
828
+ /**
829
+ * Throttle function - limits execution to once per wait time
830
+ * @param {Function} func - Function to throttle
831
+ * @param {number} wait - Wait time in milliseconds
832
+ * @returns {Function} Throttled function
833
+ */
834
+ throttle: function(func, wait) {
835
+ let inThrottle;
836
+ return function executedFunction(...args) {
837
+ if (!inThrottle) {
838
+ func.apply(this, args);
839
+ inThrottle = true;
840
+ setTimeout(() => inThrottle = false, wait);
841
+ }
842
+ };
843
+ },
844
+
845
+ /**
846
+ * Get localStorage item
847
+ * @param {string} key - Storage key
848
+ * @returns {string|null} Stored value or null
849
+ */
850
+ getLocalStorage: function(key) {
851
+ try {
852
+ return localStorage.getItem(key);
853
+ } catch (e) {
854
+ return null;
855
+ }
856
+ },
857
+
858
+ /**
859
+ * Set localStorage item
860
+ * @param {string} key - Storage key
861
+ * @param {string} value - Value to store
862
+ */
863
+ setLocalStorage: function(key, value) {
864
+ try {
865
+ localStorage.setItem(key, value);
866
+ } catch (e) {
867
+ console.warn('Failed to set localStorage:', e);
868
+ }
869
+ },
870
+
871
+ /**
872
+ * Remove localStorage item
873
+ * @param {string} key - Storage key
874
+ */
875
+ removeLocalStorage: function(key) {
876
+ try {
877
+ localStorage.removeItem(key);
878
+ } catch (e) {
879
+ console.warn('Failed to remove localStorage:', e);
880
+ }
881
+ },
882
+
883
+ /**
884
+ * Get sessionStorage item
885
+ * @param {string} key - Storage key
886
+ * @returns {string|null} Stored value or null
887
+ */
888
+ getSessionStorage: function(key) {
889
+ try {
890
+ return sessionStorage.getItem(key);
891
+ } catch (e) {
892
+ return null;
893
+ }
894
+ },
895
+
896
+ /**
897
+ * Set sessionStorage item
898
+ * @param {string} key - Storage key
899
+ * @param {string} value - Value to store
900
+ */
901
+ setSessionStorage: function(key, value) {
902
+ try {
903
+ sessionStorage.setItem(key, value);
904
+ } catch (e) {
905
+ console.warn('Failed to set sessionStorage:', e);
906
+ }
907
+ },
908
+
909
+ /**
910
+ * Remove sessionStorage item
911
+ * @param {string} key - Storage key
912
+ */
913
+ removeSessionStorage: function(key) {
914
+ try {
915
+ sessionStorage.removeItem(key);
916
+ } catch (e) {
917
+ console.warn('Failed to remove sessionStorage:', e);
918
+ }
919
+ },
920
+
921
+ /**
922
+ * Check if device is mobile
923
+ * @returns {boolean} True if mobile device
924
+ */
925
+ isMobile: function() {
926
+ return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
927
+ },
928
+
929
+ /**
930
+ * Check if device is tablet
931
+ * @returns {boolean} True if tablet device
932
+ */
933
+ isTablet: function() {
934
+ return /iPad|Android/i.test(navigator.userAgent) && window.innerWidth >= 768 && window.innerWidth <= 1024;
935
+ },
936
+
937
+ /**
938
+ * Check if device is desktop
939
+ * @returns {boolean} True if desktop device
940
+ */
941
+ isDesktop: function() {
942
+ return !this.isMobile() && !this.isTablet();
943
+ },
944
+
945
+ /**
946
+ * Get device type
947
+ * @returns {string} 'mobile', 'tablet', or 'desktop'
948
+ */
949
+ getDeviceType: function() {
950
+ if (this.isMobile()) return 'mobile';
951
+ if (this.isTablet()) return 'tablet';
952
+ return 'desktop';
953
+ },
954
+
955
+ /**
956
+ * Get browser name
957
+ * @returns {string} Browser name (chrome, firefox, safari, edge, etc.)
958
+ */
959
+ getBrowser: function() {
960
+ const ua = navigator.userAgent.toLowerCase();
961
+ if (ua.includes('chrome') && !ua.includes('edg')) return 'chrome';
962
+ if (ua.includes('firefox')) return 'firefox';
963
+ if (ua.includes('safari') && !ua.includes('chrome')) return 'safari';
964
+ if (ua.includes('edg')) return 'edge';
965
+ if (ua.includes('opera') || ua.includes('opr')) return 'opera';
966
+ return 'unknown';
967
+ },
968
+
969
+ /**
970
+ * Wait for DOM ready
971
+ * @returns {Promise} Promise that resolves when DOM is ready
972
+ */
973
+ ready: function() {
974
+ return new Promise((resolve) => {
975
+ if (document.readyState === 'loading') {
976
+ document.addEventListener('DOMContentLoaded', resolve);
977
+ } else {
978
+ resolve();
979
+ }
980
+ });
981
+ },
982
+
983
+ /**
984
+ * Wait for window load
985
+ * @returns {Promise} Promise that resolves when window is loaded
986
+ */
987
+ load: function() {
988
+ return new Promise((resolve) => {
989
+ if (document.readyState === 'complete') {
990
+ resolve();
991
+ } else {
992
+ window.addEventListener('load', resolve);
993
+ }
994
+ });
995
+ },
996
+
997
+ /**
998
+ * Create an element
999
+ * @param {string} tag - HTML tag name
1000
+ * @param {Object} attrs - Attributes object
1001
+ * @param {string|Element} content - Text content or child element
1002
+ * @returns {Element} Created element
1003
+ */
1004
+ createElement: function(tag, attrs = {}, content = '') {
1005
+ const el = document.createElement(tag);
1006
+
1007
+ Object.keys(attrs).forEach(key => {
1008
+ if (key === 'class') {
1009
+ el.className = attrs[key];
1010
+ } else if (key === 'style' && typeof attrs[key] === 'object') {
1011
+ Object.assign(el.style, attrs[key]);
1012
+ } else {
1013
+ el.setAttribute(key, attrs[key]);
1014
+ }
1015
+ });
1016
+
1017
+ if (typeof content === 'string') {
1018
+ el.textContent = content;
1019
+ } else if (content instanceof Element) {
1020
+ el.appendChild(content);
1021
+ }
1022
+
1023
+ return el;
1024
+ },
1025
+
1026
+ /**
1027
+ * Append element to parent
1028
+ * @param {Element|string} parent - Parent element or selector
1029
+ * @param {Element|string} child - Child element or selector
1030
+ */
1031
+ append: function(parent, child) {
1032
+ const parentEl = typeof parent === 'string' ? document.querySelector(parent) : parent;
1033
+ const childEl = typeof child === 'string' ? document.querySelector(child) : child;
1034
+ if (parentEl && childEl) {
1035
+ parentEl.appendChild(childEl);
1036
+ }
1037
+ },
1038
+
1039
+ /**
1040
+ * Prepend element to parent
1041
+ * @param {Element|string} parent - Parent element or selector
1042
+ * @param {Element|string} child - Child element or selector
1043
+ */
1044
+ prepend: function(parent, child) {
1045
+ const parentEl = typeof parent === 'string' ? document.querySelector(parent) : parent;
1046
+ const childEl = typeof child === 'string' ? document.querySelector(child) : child;
1047
+ if (parentEl && childEl) {
1048
+ parentEl.insertBefore(childEl, parentEl.firstChild);
1049
+ }
1050
+ },
1051
+
1052
+ /**
1053
+ * Remove element from DOM
1054
+ * @param {Element|string} element - Element or selector
1055
+ */
1056
+ remove: function(element) {
1057
+ const el = typeof element === 'string' ? document.querySelector(element) : element;
1058
+ if (el && el.parentNode) {
1059
+ el.parentNode.removeChild(el);
1060
+ }
1061
+ },
1062
+
1063
+ /**
1064
+ * Clone element
1065
+ * @param {Element|string} element - Element or selector
1066
+ * @param {boolean} deep - Deep clone (default: true)
1067
+ * @returns {Element} Cloned element
1068
+ */
1069
+ clone: function(element, deep = true) {
1070
+ const el = typeof element === 'string' ? document.querySelector(element) : element;
1071
+ return el ? el.cloneNode(deep) : null;
1072
+ },
1073
+
1074
+ /**
1075
+ * Get parent element
1076
+ * @param {Element|string} element - Element or selector
1077
+ * @param {string} selector - Optional selector to match parent
1078
+ * @returns {Element|null} Parent element or null
1079
+ */
1080
+ parent: function(element, selector) {
1081
+ const el = typeof element === 'string' ? document.querySelector(element) : element;
1082
+ if (!el) return null;
1083
+
1084
+ if (selector) {
1085
+ return el.closest(selector);
1086
+ }
1087
+ return el.parentElement;
1088
+ },
1089
+
1090
+ /**
1091
+ * Get children elements
1092
+ * @param {Element|string} element - Element or selector
1093
+ * @param {string} selector - Optional selector to filter children
1094
+ * @returns {Array} Array of child elements
1095
+ */
1096
+ children: function(element, selector) {
1097
+ const el = typeof element === 'string' ? document.querySelector(element) : element;
1098
+ if (!el) return [];
1099
+
1100
+ const children = Array.from(el.children);
1101
+ if (selector) {
1102
+ return children.filter(child => child.matches(selector));
1103
+ }
1104
+ return children;
1105
+ },
1106
+
1107
+ /**
1108
+ * Get siblings of element
1109
+ * @param {Element|string} element - Element or selector
1110
+ * @param {string} selector - Optional selector to filter siblings
1111
+ * @returns {Array} Array of sibling elements
1112
+ */
1113
+ siblings: function(element, selector) {
1114
+ const el = typeof element === 'string' ? document.querySelector(element) : element;
1115
+ if (!el || !el.parentNode) return [];
1116
+
1117
+ const siblings = Array.from(el.parentNode.children).filter(child => child !== el);
1118
+ if (selector) {
1119
+ return siblings.filter(sibling => sibling.matches(selector));
1120
+ }
1121
+ return siblings;
1122
+ },
1123
+
1124
+ /**
1125
+ * Find element within context
1126
+ * @param {Element|string} context - Context element or selector
1127
+ * @param {string} selector - CSS selector
1128
+ * @returns {Element|null} Found element or null
1129
+ */
1130
+ find: function(context, selector) {
1131
+ const ctx = typeof context === 'string' ? document.querySelector(context) : context;
1132
+ return ctx ? ctx.querySelector(selector) : null;
1133
+ },
1134
+
1135
+ /**
1136
+ * Find all elements within context
1137
+ * @param {Element|string} context - Context element or selector
1138
+ * @param {string} selector - CSS selector
1139
+ * @returns {NodeList} NodeList of found elements
1140
+ */
1141
+ findAll: function(context, selector) {
1142
+ const ctx = typeof context === 'string' ? document.querySelector(context) : context;
1143
+ return ctx ? ctx.querySelectorAll(selector) : [];
1144
+ },
1145
+
1146
+ /**
1147
+ * Check if element matches selector
1148
+ * @param {Element|string} element - Element or selector
1149
+ * @param {string} selector - CSS selector to match
1150
+ * @returns {boolean} True if element matches selector
1151
+ */
1152
+ matches: function(element, selector) {
1153
+ const el = typeof element === 'string' ? document.querySelector(element) : element;
1154
+ return el ? el.matches(selector) : false;
1155
+ },
1156
+
1157
+ /**
1158
+ * Get closest ancestor matching selector
1159
+ * @param {Element|string} element - Element or selector
1160
+ * @param {string} selector - CSS selector
1161
+ * @returns {Element|null} Closest matching element or null
1162
+ */
1163
+ closest: function(element, selector) {
1164
+ const el = typeof element === 'string' ? document.querySelector(element) : element;
1165
+ return el ? el.closest(selector) : null;
1166
+ },
1167
+
1168
+ /**
1169
+ * Track scroll depth
1170
+ * @param {Function} callback - Callback function that receives depth percentage
1171
+ * @param {Array} thresholds - Array of depth thresholds to trigger (default: [25, 50, 75, 100])
1172
+ * @returns {Function} Function to stop tracking
1173
+ */
1174
+ trackScrollDepth: function(callback, thresholds = [25, 50, 75, 100]) {
1175
+ const triggered = new Set();
1176
+ const maxScroll = document.documentElement.scrollHeight - window.innerHeight;
1177
+
1178
+ const handleScroll = this.throttle(() => {
1179
+ const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
1180
+ const depth = Math.round((scrollTop / maxScroll) * 100);
1181
+
1182
+ thresholds.forEach(threshold => {
1183
+ if (depth >= threshold && !triggered.has(threshold)) {
1184
+ triggered.add(threshold);
1185
+ callback(threshold, depth);
1186
+ }
1187
+ });
1188
+ }, 100);
1189
+
1190
+ window.addEventListener('scroll', handleScroll);
1191
+ return () => window.removeEventListener('scroll', handleScroll);
1192
+ },
1193
+
1194
+ /**
1195
+ * Track time on page
1196
+ * @param {Function} callback - Callback function that receives time in seconds
1197
+ * @param {number} interval - Check interval in milliseconds (default: 1000)
1198
+ * @returns {Function} Function to stop tracking
1199
+ */
1200
+ trackTimeOnPage: function(callback, interval = 1000) {
1201
+ const startTime = Date.now();
1202
+ const intervalId = setInterval(() => {
1203
+ const timeOnPage = Math.floor((Date.now() - startTime) / 1000);
1204
+ callback(timeOnPage);
1205
+ }, interval);
1206
+
1207
+ return () => clearInterval(intervalId);
1208
+ },
1209
+
1210
+ /**
1211
+ * Track element visibility using Intersection Observer
1212
+ * @param {Element|string} element - Element or selector
1213
+ * @param {Function} callback - Callback function that receives visibility state
1214
+ * @param {Object} options - Intersection Observer options
1215
+ * @returns {Function} Function to stop observing
1216
+ */
1217
+ trackVisibility: function(element, callback, options = {}) {
1218
+ const el = typeof element === 'string' ? document.querySelector(element) : element;
1219
+ if (!el) return () => {};
1220
+
1221
+ const observer = new IntersectionObserver((entries) => {
1222
+ entries.forEach(entry => {
1223
+ callback(entry.isIntersecting, entry.intersectionRatio, entry);
1224
+ });
1225
+ }, {
1226
+ threshold: options.threshold || 0,
1227
+ rootMargin: options.rootMargin || '0px',
1228
+ ...options
1229
+ });
1230
+
1231
+ observer.observe(el);
1232
+ return () => observer.disconnect();
1233
+ },
1234
+
1235
+ /**
1236
+ * Format number with commas
1237
+ * @param {number} num - Number to format
1238
+ * @returns {string} Formatted number string
1239
+ */
1240
+ formatNumber: function(num) {
1241
+ return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
1242
+ },
1243
+
1244
+ /**
1245
+ * Format currency
1246
+ * @param {number} amount - Amount to format
1247
+ * @param {string} currency - Currency code (default: 'USD')
1248
+ * @param {string} locale - Locale (default: 'en-US')
1249
+ * @returns {string} Formatted currency string
1250
+ */
1251
+ formatCurrency: function(amount, currency = 'USD', locale = 'en-US') {
1252
+ return new Intl.NumberFormat(locale, {
1253
+ style: 'currency',
1254
+ currency: currency
1255
+ }).format(amount);
1256
+ },
1257
+
1258
+ /**
1259
+ * Parse query string to object
1260
+ * @param {string} queryString - Query string (defaults to current search)
1261
+ * @returns {Object} Object with query parameters
1262
+ */
1263
+ parseQuery: function(queryString = window.location.search) {
1264
+ const params = {};
1265
+ const urlParams = new URLSearchParams(queryString);
1266
+ urlParams.forEach((value, key) => {
1267
+ params[key] = value;
1268
+ });
1269
+ return params;
1270
+ },
1271
+
1272
+ /**
1273
+ * Build query string from object
1274
+ * @param {Object} params - Object with query parameters
1275
+ * @returns {string} Query string
1276
+ */
1277
+ buildQuery: function(params) {
1278
+ const urlParams = new URLSearchParams();
1279
+ Object.keys(params).forEach(key => {
1280
+ if (params[key] !== null && params[key] !== undefined) {
1281
+ urlParams.append(key, params[key]);
1282
+ }
1283
+ });
1284
+ return urlParams.toString();
1285
+ },
1286
+
1287
+ /**
1288
+ * Get random number between min and max
1289
+ * @param {number} min - Minimum value
1290
+ * @param {number} max - Maximum value
1291
+ * @returns {number} Random number
1292
+ */
1293
+ random: function(min, max) {
1294
+ return Math.floor(Math.random() * (max - min + 1)) + min;
1295
+ },
1296
+
1297
+ /**
1298
+ * Get random item from array
1299
+ * @param {Array} array - Array to pick from
1300
+ * @returns {*} Random item
1301
+ */
1302
+ randomItem: function(array) {
1303
+ return array[Math.floor(Math.random() * array.length)];
1304
+ },
1305
+
1306
+ /**
1307
+ * Shuffle array
1308
+ * @param {Array} array - Array to shuffle
1309
+ * @returns {Array} Shuffled array (new array)
1310
+ */
1311
+ shuffle: function(array) {
1312
+ const arr = [...array];
1313
+ for (let i = arr.length - 1; i > 0; i--) {
1314
+ const j = Math.floor(Math.random() * (i + 1));
1315
+ [arr[i], arr[j]] = [arr[j], arr[i]];
1316
+ }
1317
+ return arr;
1318
+ },
1319
+
1320
+ /**
1321
+ * Deep clone object
1322
+ * @param {*} obj - Object to clone
1323
+ * @returns {*} Cloned object
1324
+ */
1325
+ deepClone: function(obj) {
1326
+ return JSON.parse(JSON.stringify(obj));
1327
+ },
1328
+
1329
+ /**
1330
+ * Merge objects
1331
+ * @param {Object} target - Target object
1332
+ * @param {...Object} sources - Source objects
1333
+ * @returns {Object} Merged object
1334
+ */
1335
+ merge: function(target, ...sources) {
1336
+ return Object.assign({}, target, ...sources);
1337
+ },
1338
+
1339
+ /**
1340
+ * Check if value is empty (null, undefined, empty string, empty array, empty object)
1341
+ * @param {*} value - Value to check
1342
+ * @returns {boolean} True if empty
1343
+ */
1344
+ isEmpty: function(value) {
1345
+ if (value == null) return true;
1346
+ if (typeof value === 'string' || Array.isArray(value)) return value.length === 0;
1347
+ if (typeof value === 'object') return Object.keys(value).length === 0;
1348
+ return false;
260
1349
  }
261
1350
  };
262
1351