@hortonstudio/main 1.2.31 → 1.2.35

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.
@@ -37,7 +37,9 @@
37
37
  "Bash(grep:*)",
38
38
  "Bash(git reset:*)",
39
39
  "Bash(git checkout:*)",
40
- "Bash(rg:*)"
40
+ "Bash(rg:*)",
41
+ "Bash(/usr/local/lib/node_modules/@anthropic-ai/claude-code/vendor/ripgrep/arm64-darwin/rg -n \"data-hs-heroconfig\" /Users/devan/Documents/custom-code/main/animations/hero.js)",
42
+ "Bash(/usr/local/lib/node_modules/@anthropic-ai/claude-code/vendor/ripgrep/arm64-darwin/rg -n \"data-hs-config\" /Users/devan/Documents/custom-code/main/animations/hero.js)"
41
43
  ],
42
44
  "deny": []
43
45
  }
@@ -205,7 +205,7 @@ export async function init() {
205
205
  const appearElements = document.querySelectorAll('[data-hs-hero="appear"]');
206
206
 
207
207
  // Check if nav has advanced config
208
- const hasAdvancedNav = navElement && navElement.hasAttribute('data-hs-heroconfig') && navElement.getAttribute('data-hs-heroconfig') === 'advanced';
208
+ const hasAdvancedNav = navElement && navElement.hasAttribute('data-hs-config') && navElement.getAttribute('data-hs-config') === 'advanced';
209
209
 
210
210
  // First child elements - only select if advanced nav is enabled
211
211
  const navButton = [];
@@ -224,7 +224,7 @@ export async function init() {
224
224
  subheadingElements.forEach(el => {
225
225
  if (el.firstElementChild) {
226
226
  // Get the heroconfig attribute to determine animation type (default to appear)
227
- const heroConfig = el.getAttribute('data-hs-heroconfig') || 'appear';
227
+ const heroConfig = el.getAttribute('data-hs-config') || 'appear';
228
228
 
229
229
  if (heroConfig === 'appear') {
230
230
  subheadingAppearElements.push(el.firstElementChild);
@@ -243,7 +243,7 @@ export async function init() {
243
243
  headingElements.forEach(el => {
244
244
  if (el.firstElementChild) {
245
245
  // Get the heroconfig attribute to determine animation type
246
- const heroConfig = el.getAttribute('data-hs-heroconfig') || 'line'; // default to line if not specified
246
+ const heroConfig = el.getAttribute('data-hs-config') || 'line'; // default to line if not specified
247
247
 
248
248
  if (heroConfig === 'appear') {
249
249
  headingAppearElements.push(el.firstElementChild);
@@ -319,7 +319,7 @@ export async function init() {
319
319
  if (heading.length > 0) {
320
320
  headingSplitElements.forEach((parent, index) => {
321
321
  const textElement = heading[index];
322
- const splitType = parent.getAttribute('data-hs-heroconfig') || 'line';
322
+ const splitType = parent.getAttribute('data-hs-config') || 'line';
323
323
 
324
324
  let splitConfig = {};
325
325
  let elementsClass = '';
@@ -360,7 +360,7 @@ export async function init() {
360
360
  if (subheading.length > 0) {
361
361
  subheadingSplitElements.forEach((parent, index) => {
362
362
  const textElement = subheading[index];
363
- const splitType = parent.getAttribute('data-hs-heroconfig') || 'word';
363
+ const splitType = parent.getAttribute('data-hs-config') || 'word';
364
364
 
365
365
  let splitConfig = {};
366
366
  let elementsClass = '';
@@ -513,9 +513,7 @@ export async function init() {
513
513
  ease: config.headingSplit.ease,
514
514
  onComplete: () => {
515
515
  if (split && split.revert) {
516
- setTimeout(() => {
517
516
  split.revert();
518
- }, 100);
519
517
  }
520
518
  }
521
519
  },
@@ -534,9 +532,7 @@ export async function init() {
534
532
  ease: config.subheadingSplit.ease,
535
533
  onComplete: () => {
536
534
  if (split && split.revert) {
537
- setTimeout(() => {
538
535
  split.revert();
539
- }, 100);
540
536
  }
541
537
  }
542
538
  },
@@ -0,0 +1,71 @@
1
+ export function init() {
2
+ function setupStatsAccessibility() {
3
+ const statsElements = document.querySelectorAll('[data-hs-accessibility="stats"]');
4
+
5
+ statsElements.forEach(element => {
6
+ // Get all text content from the element and its children
7
+ const textContent = extractTextContent(element);
8
+
9
+ if (textContent) {
10
+ element.setAttribute('aria-label', textContent);
11
+ element.setAttribute('role', 'img');
12
+
13
+ // Debug: log what we found
14
+ console.log('Stats element aria-label:', textContent);
15
+ }
16
+ });
17
+ }
18
+
19
+ function extractTextContent(element) {
20
+ const textParts = [];
21
+
22
+ function processNode(node) {
23
+ // Skip elements with aria-hidden="true"
24
+ if (node.nodeType === Node.ELEMENT_NODE &&
25
+ node.getAttribute('aria-hidden') === 'true') {
26
+ return;
27
+ }
28
+
29
+ // If element has data-original-text, use that instead of current text
30
+ if (node.nodeType === Node.ELEMENT_NODE) {
31
+ const originalText = node.getAttribute('data-original-text');
32
+ if (originalText) {
33
+ textParts.push(originalText);
34
+ return; // Don't process children
35
+ }
36
+ }
37
+
38
+ // Process text nodes
39
+ if (node.nodeType === Node.TEXT_NODE) {
40
+ const text = node.textContent.trim();
41
+ if (text && text.length > 0) {
42
+ textParts.push(text);
43
+ }
44
+ }
45
+
46
+ // Process child nodes
47
+ if (node.childNodes) {
48
+ node.childNodes.forEach(processNode);
49
+ }
50
+ }
51
+
52
+ processNode(element);
53
+
54
+ // Combine all text parts with spaces and clean up
55
+ return textParts
56
+ .join(' ')
57
+ .replace(/\s+/g, ' ')
58
+ .trim();
59
+ }
60
+
61
+ function init() {
62
+ // Wait a bit for counter scripts to initialize and set data-original-text
63
+ setTimeout(() => {
64
+ setupStatsAccessibility();
65
+ }, 100);
66
+
67
+ return { result: 'accessibility initialized' };
68
+ }
69
+
70
+ return init();
71
+ }
@@ -1,10 +1,83 @@
1
+ // Global accessibility state
2
+ let supportsInert = null;
3
+ let screenReaderLiveRegion = null;
4
+
1
5
  export const init = () => {
6
+ setupAccessibilityFeatures();
2
7
  setupDynamicDropdowns();
3
8
  setupMobileMenuButton();
4
9
  setupMobileMenuARIA();
10
+ setupMobileMenuBreakpointHandler();
5
11
  return { result: 'navbar initialized' };
6
12
  };
7
13
 
14
+ // Accessibility features setup
15
+ function setupAccessibilityFeatures() {
16
+ // Check inert support once
17
+ supportsInert = 'inert' in HTMLElement.prototype;
18
+
19
+ // Create screen reader live region
20
+ screenReaderLiveRegion = document.createElement('div');
21
+ screenReaderLiveRegion.setAttribute('aria-live', 'polite');
22
+ screenReaderLiveRegion.setAttribute('aria-atomic', 'true');
23
+ screenReaderLiveRegion.className = 'u-sr-only';
24
+ screenReaderLiveRegion.style.cssText = 'position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;';
25
+ document.body.appendChild(screenReaderLiveRegion);
26
+ }
27
+
28
+ // Inert polyfill for browsers that don't support it
29
+ function setElementInert(element, isInert) {
30
+ if (supportsInert) {
31
+ element.inert = isInert;
32
+ } else {
33
+ // Polyfill: manage tabindex for all focusable elements
34
+ const focusableSelectors = 'a, button, input, select, textarea, [tabindex]:not([tabindex="-1"])';
35
+ const focusableElements = element.querySelectorAll(focusableSelectors);
36
+
37
+ if (isInert) {
38
+ // Store original tabindex values and disable
39
+ focusableElements.forEach(el => {
40
+ const currentTabindex = el.getAttribute('tabindex');
41
+ el.setAttribute('data-inert-tabindex', currentTabindex || '0');
42
+ el.setAttribute('tabindex', '-1');
43
+ });
44
+ element.setAttribute('data-inert', 'true');
45
+ } else {
46
+ // Restore original tabindex values
47
+ focusableElements.forEach(el => {
48
+ const originalTabindex = el.getAttribute('data-inert-tabindex');
49
+ if (originalTabindex === '0') {
50
+ el.removeAttribute('tabindex');
51
+ } else if (originalTabindex) {
52
+ el.setAttribute('tabindex', originalTabindex);
53
+ }
54
+ el.removeAttribute('data-inert-tabindex');
55
+ });
56
+ element.removeAttribute('data-inert');
57
+ }
58
+ }
59
+ }
60
+
61
+ // Screen reader announcements
62
+ function announceToScreenReader(message) {
63
+ if (screenReaderLiveRegion) {
64
+ screenReaderLiveRegion.textContent = message;
65
+
66
+ // Clear after a delay to allow for repeat announcements
67
+ setTimeout(() => {
68
+ screenReaderLiveRegion.textContent = '';
69
+ }, 1000);
70
+ }
71
+ }
72
+
73
+ // Extract menu name from element text or aria-label
74
+ function getMenuName(element) {
75
+ const text = element.textContent?.trim() ||
76
+ element.getAttribute('aria-label') ||
77
+ 'menu';
78
+ return text.replace(/^(Open|Close)\s+/i, '').replace(/\s+(menu|navigation)$/i, '');
79
+ }
80
+
8
81
  // Desktop dropdown system
9
82
  function setupDynamicDropdowns() {
10
83
  const dropdownWrappers = document.querySelectorAll('[data-hs-nav="dropdown"]');
@@ -67,6 +140,11 @@ function setupDynamicDropdowns() {
67
140
  menuItems.forEach(item => {
68
141
  item.setAttribute('tabindex', '0');
69
142
  });
143
+
144
+ // Announce to screen readers
145
+ const menuName = getMenuName(toggle);
146
+ announceToScreenReader(`${menuName} menu opened`);
147
+
70
148
  const clickEvent = new MouseEvent('click', {
71
149
  bubbles: true,
72
150
  cancelable: true,
@@ -88,6 +166,11 @@ function setupDynamicDropdowns() {
88
166
  menuItems.forEach(item => {
89
167
  item.setAttribute('tabindex', '-1');
90
168
  });
169
+
170
+ // Announce to screen readers
171
+ const menuName = getMenuName(toggle);
172
+ announceToScreenReader(`${menuName} menu closed`);
173
+
91
174
  const clickEvent = new MouseEvent('click', {
92
175
  bubbles: true,
93
176
  cancelable: true,
@@ -340,7 +423,7 @@ function addDesktopArrowNavigation() {
340
423
  });
341
424
  }
342
425
 
343
- // Mobile menu button system
426
+ // Mobile menu button system with modal-like functionality
344
427
  function setupMobileMenuButton() {
345
428
  const menuButton = document.querySelector('[data-hs-nav="menubtn"]');
346
429
  const mobileMenu = document.querySelector('[data-hs-nav="menu"]');
@@ -356,16 +439,39 @@ function setupMobileMenuButton() {
356
439
  mobileMenu.id = menuId;
357
440
  mobileMenu.setAttribute('role', 'dialog');
358
441
  mobileMenu.setAttribute('aria-modal', 'true');
359
- mobileMenu.inert = true;
442
+ setElementInert(mobileMenu, true);
360
443
 
361
444
  let isMenuOpen = false;
362
445
 
446
+ function shouldPreventMobileMenu() {
447
+ const menuHideElement = document.querySelector('.menu-hide.is-mobile');
448
+ if (!menuHideElement) return false;
449
+
450
+ const computedStyle = window.getComputedStyle(menuHideElement);
451
+ return computedStyle.display === 'none';
452
+ }
453
+
363
454
  function openMenu() {
364
- if (isMenuOpen) return;
455
+ if (isMenuOpen || shouldPreventMobileMenu()) return;
365
456
  isMenuOpen = true;
457
+
458
+ // Add body overflow hidden class
459
+ document.body.classList.add('u-overflow-hidden');
460
+
461
+ // Add blur effect to modal blur elements
462
+ document.querySelectorAll('[data-hs-nav="modal-blur"]').forEach(element => {
463
+ element.style.display = 'block';
464
+ element.style.opacity = '0.5';
465
+ element.style.transition = 'opacity 0.3s ease';
466
+ });
467
+
366
468
  menuButton.setAttribute('aria-expanded', 'true');
367
469
  menuButton.setAttribute('aria-label', 'Close navigation menu');
368
- mobileMenu.inert = false;
470
+ setElementInert(mobileMenu, false);
471
+
472
+ // Announce to screen readers
473
+ const menuName = getMenuName(menuButton);
474
+ announceToScreenReader(`${menuName} opened`);
369
475
 
370
476
  // Prevent tabbing outside navbar using tabindex management
371
477
  const navbarWrapper = document.querySelector('[data-hs-nav="wrapper"]') ||
@@ -392,12 +498,29 @@ function setupMobileMenuButton() {
392
498
  function closeMenu() {
393
499
  if (!isMenuOpen) return;
394
500
  isMenuOpen = false;
501
+
502
+ // Remove body overflow hidden class
503
+ document.body.classList.remove('u-overflow-hidden');
504
+
505
+ // Remove blur effect from modal blur elements
506
+ document.querySelectorAll('[data-hs-nav="modal-blur"]').forEach(element => {
507
+ element.style.opacity = '0';
508
+ element.style.transition = 'opacity 0.3s ease';
509
+ setTimeout(() => {
510
+ element.style.display = 'none';
511
+ }, 300);
512
+ });
513
+
395
514
  if (mobileMenu.contains(document.activeElement)) {
396
515
  menuButton.focus();
397
516
  }
398
517
  menuButton.setAttribute('aria-expanded', 'false');
399
518
  menuButton.setAttribute('aria-label', 'Open navigation menu');
400
- mobileMenu.inert = true;
519
+ setElementInert(mobileMenu, true);
520
+
521
+ // Announce to screen readers
522
+ const menuName = getMenuName(menuButton);
523
+ announceToScreenReader(`${menuName} closed`);
401
524
 
402
525
  // Restore tabbing to entire page using tabindex management
403
526
  const elementsToRestore = document.querySelectorAll('[data-mobile-menu-tabindex]');
@@ -420,6 +543,8 @@ function setupMobileMenuButton() {
420
543
  }
421
544
 
422
545
  function toggleMenu() {
546
+ if (shouldPreventMobileMenu()) return;
547
+
423
548
  if (isMenuOpen) {
424
549
  closeMenu();
425
550
  } else {
@@ -450,15 +575,43 @@ function setupMobileMenuButton() {
450
575
 
451
576
  menuButton.addEventListener('click', function(e) {
452
577
  if (!e.isTrusted) return;
578
+
579
+ if (shouldPreventMobileMenu()) return;
580
+
453
581
  if (isMenuOpen && mobileMenu.contains(document.activeElement)) {
454
582
  menuButton.focus();
455
583
  }
456
- isMenuOpen = !isMenuOpen;
584
+
585
+ const newMenuState = !isMenuOpen;
586
+ isMenuOpen = newMenuState;
587
+
588
+ // Handle body overflow class
589
+ if (isMenuOpen) {
590
+ document.body.classList.add('u-overflow-hidden');
591
+ } else {
592
+ document.body.classList.remove('u-overflow-hidden');
593
+ }
594
+
595
+ // Handle blur effect
596
+ document.querySelectorAll('[data-hs-nav="modal-blur"]').forEach(element => {
597
+ if (isMenuOpen) {
598
+ element.style.display = 'block';
599
+ element.style.opacity = '0.5';
600
+ element.style.transition = 'opacity 0.3s ease';
601
+ } else {
602
+ element.style.opacity = '0';
603
+ element.style.transition = 'opacity 0.3s ease';
604
+ setTimeout(() => {
605
+ element.style.display = 'none';
606
+ }, 300);
607
+ }
608
+ });
609
+
457
610
  menuButton.setAttribute('aria-expanded', isMenuOpen);
458
611
  menuButton.setAttribute('aria-label',
459
612
  isMenuOpen ? 'Close navigation menu' : 'Open navigation menu'
460
613
  );
461
- mobileMenu.inert = !isMenuOpen;
614
+ setElementInert(mobileMenu, !isMenuOpen);
462
615
 
463
616
  // Handle tabindex management for external clicks
464
617
  if (isMenuOpen) {
@@ -487,6 +640,53 @@ function setupMobileMenuButton() {
487
640
  });
488
641
  }
489
642
  });
643
+
644
+ // Store the menu state and functions for breakpoint handler
645
+ window.mobileMenuState = {
646
+ isMenuOpen: () => isMenuOpen,
647
+ closeMenu: closeMenu,
648
+ openMenu: openMenu
649
+ };
650
+ }
651
+
652
+ // Mobile menu breakpoint handler
653
+ function setupMobileMenuBreakpointHandler() {
654
+ let preventedMenuState = false;
655
+
656
+ function handleBreakpointChange() {
657
+ const menuHideElement = document.querySelector('.menu-hide.is-mobile');
658
+ if (!menuHideElement) return;
659
+
660
+ const computedStyle = window.getComputedStyle(menuHideElement);
661
+ const shouldPrevent = computedStyle.display === 'none';
662
+
663
+ if (!window.mobileMenuState) return;
664
+
665
+ if (shouldPrevent && window.mobileMenuState.isMenuOpen()) {
666
+ // Store that the menu was open before being prevented
667
+ preventedMenuState = true;
668
+ window.mobileMenuState.closeMenu();
669
+ } else if (!shouldPrevent && preventedMenuState) {
670
+ // Restore menu state if it was open before being prevented
671
+ preventedMenuState = false;
672
+ window.mobileMenuState.openMenu();
673
+ }
674
+ }
675
+
676
+ // Use ResizeObserver for more accurate detection
677
+ if (window.ResizeObserver) {
678
+ const resizeObserver = new ResizeObserver(handleBreakpointChange);
679
+ const menuHideElement = document.querySelector('.menu-hide.is-mobile');
680
+ if (menuHideElement) {
681
+ resizeObserver.observe(menuHideElement);
682
+ }
683
+ }
684
+
685
+ // Fallback to resize event
686
+ window.addEventListener('resize', handleBreakpointChange);
687
+
688
+ // Initial check
689
+ handleBreakpointChange();
490
690
  }
491
691
 
492
692
  function sanitizeForID(text) {
@@ -518,30 +718,38 @@ function setupMobileMenuARIA() {
518
718
  button.setAttribute('aria-expanded', 'false');
519
719
  button.setAttribute('aria-controls', listId);
520
720
 
521
- let dropdownList = button.nextElementSibling;
522
-
523
- if (!dropdownList || !dropdownList.querySelector('a')) {
524
- dropdownList = button.parentElement?.nextElementSibling;
525
- }
721
+ // Look for dropdown list in the same container as the button
722
+ let dropdownList = null;
723
+ const buttonContainer = button.closest('.menu-card_dropdown, .menu_contain, [data-hs-nav="menu"]');
526
724
 
527
- if (!dropdownList || !dropdownList.querySelector('a')) {
528
- const parent = button.closest('[data-hs-nav="menu"]');
529
- const allListElements = parent?.querySelectorAll('div, ul, nav');
530
- dropdownList = Array.from(allListElements || []).find(el =>
531
- el.querySelectorAll('a').length > 1 &&
532
- !el.contains(button)
533
- );
725
+ if (buttonContainer) {
726
+ // First try to find a list element within the same container
727
+ dropdownList = buttonContainer.querySelector('.menu-card_list, .dropdown-list, [role="menu"]');
728
+
729
+ // If not found, look for any element with multiple links that's not the button itself
730
+ if (!dropdownList || !dropdownList.querySelector('a')) {
731
+ const allListElements = buttonContainer.querySelectorAll('div, ul, nav');
732
+ dropdownList = Array.from(allListElements).find(el =>
733
+ el.querySelectorAll('a').length > 1 &&
734
+ !el.contains(button) &&
735
+ el !== button
736
+ );
737
+ }
534
738
  }
535
739
 
536
740
  if (dropdownList && dropdownList.querySelector('a')) {
537
741
  dropdownList.id = listId;
538
- dropdownList.inert = true;
742
+ setElementInert(dropdownList, true);
539
743
 
540
744
  button.addEventListener('click', function() {
541
745
  const isExpanded = button.getAttribute('aria-expanded') === 'true';
542
746
  const newState = !isExpanded;
543
747
  button.setAttribute('aria-expanded', newState);
544
- dropdownList.inert = !newState;
748
+ setElementInert(dropdownList, !newState);
749
+
750
+ // Announce to screen readers
751
+ const menuName = getMenuName(button);
752
+ announceToScreenReader(`${menuName} submenu ${newState ? 'opened' : 'closed'}`);
545
753
  });
546
754
  }
547
755
  });
@@ -565,7 +773,8 @@ function setupMobileMenuArrowNavigation(menuContainer) {
565
773
  return Array.from(allElements).filter(el => {
566
774
  let current = el;
567
775
  while (current && current !== menuContainer) {
568
- if (current.inert === true) {
776
+ // Check both native inert and polyfill inert
777
+ if (current.inert === true || current.getAttribute('data-inert') === 'true') {
569
778
  return false;
570
779
  }
571
780
  current = current.parentElement;
package/index.js CHANGED
@@ -1,4 +1,4 @@
1
- // Version:1.2.31
1
+ // Version:1.2.35
2
2
 
3
3
  const API_NAME = 'hsmain';
4
4
 
@@ -18,12 +18,14 @@ const initializeHsMain = async () => {
18
18
  const utilityModules = {
19
19
  'data-hs-util-toc': true,
20
20
  'data-hs-util-progress': true,
21
- 'data-hs-util-navbar': true
21
+ 'data-hs-util-ba': true
22
22
  };
23
23
 
24
24
  const autoInitModules = {
25
25
  'smooth-scroll': true,
26
- 'modal': true
26
+ 'modal': true,
27
+ 'navbar': true,
28
+ 'accessibility': true
27
29
  };
28
30
 
29
31
  const allDataAttributes = { ...animationModules, ...utilityModules };
@@ -47,9 +49,11 @@ const initializeHsMain = async () => {
47
49
  'data-hs-anim-transition': () => import('./animations/transition.js'),
48
50
  'data-hs-util-toc': () => import('./utils/toc.js'),
49
51
  'data-hs-util-progress': () => import('./utils/scroll-progress.js'),
50
- 'data-hs-util-navbar': () => import('./utils/navbar.js'),
52
+ 'data-hs-util-ba': () => import('./utils/before-after.js'),
51
53
  'smooth-scroll': () => import('./autoInit/smooth-scroll.js'),
52
- 'modal': () => import('./autoInit/modal.js')
54
+ 'modal': () => import('./autoInit/modal.js'),
55
+ 'navbar': () => import('./autoInit/navbar.js'),
56
+ 'accessibility': () => import('./autoInit/accessibility.js')
53
57
  };
54
58
 
55
59
  let scripts = [...document.querySelectorAll(`script[type="module"][src="${import.meta.url}"]`)];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hortonstudio/main",
3
- "version": "1.2.31",
3
+ "version": "1.2.35",
4
4
  "description": "Animation and utility library for client websites",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -0,0 +1,387 @@
1
+ export function init() {
2
+ const config = {
3
+ defaultMode: 'split',
4
+ sliderPosition: 50,
5
+ touchSensitivity: 1,
6
+ keyboardStep: 5,
7
+ autoPlay: false,
8
+ autoPlayInterval: 5000
9
+ };
10
+
11
+ let instances = [];
12
+ let currentSlideIndex = {};
13
+ let isDragging = false;
14
+ let dragInstance = null;
15
+
16
+ function updateConfig(newConfig) {
17
+ Object.assign(config, newConfig);
18
+ }
19
+
20
+ function initInstance(wrapper) {
21
+ const instanceId = instances.length;
22
+ const items = Array.from(wrapper.children);
23
+
24
+ if (items.length === 0) return;
25
+
26
+ const instance = {
27
+ id: instanceId,
28
+ wrapper,
29
+ items,
30
+ currentIndex: 0,
31
+ mode: config.defaultMode,
32
+ sliderPosition: config.sliderPosition,
33
+ previousSliderPosition: config.sliderPosition
34
+ };
35
+
36
+ instances.push(instance);
37
+ currentSlideIndex[instanceId] = 0;
38
+
39
+ setupInstance(instance);
40
+ showSlide(instance, 0);
41
+
42
+ return instance;
43
+ }
44
+
45
+ function setupInstance(instance) {
46
+ const { wrapper, items } = instance;
47
+
48
+ items.forEach((item, index) => {
49
+ item.style.display = index === 0 ? 'block' : 'none';
50
+
51
+ // Set default clip path for after image
52
+ const afterImage = item.querySelector('[data-hs-ba="image-after"]');
53
+ if (afterImage) {
54
+ afterImage.style.clipPath = `polygon(${config.sliderPosition}% 0%, 100% 0%, 100% 100%, ${config.sliderPosition}% 100%)`;
55
+ }
56
+
57
+ setupItemInteractions(instance, item, index);
58
+ });
59
+
60
+ setupKeyboardNavigation(instance);
61
+ }
62
+
63
+ function setupItemInteractions(instance, item, itemIndex) {
64
+ const modeButtons = item.querySelectorAll('[data-hs-ba^="mode-"]');
65
+ const leftArrow = item.querySelector('[data-hs-ba="left"]');
66
+ const rightArrow = item.querySelector('[data-hs-ba="right"]');
67
+ const slider = item.querySelector('[data-hs-ba="slider"]');
68
+ const pagination = item.querySelector('[data-hs-ba="pagination"]');
69
+
70
+ modeButtons.forEach(button => {
71
+ const mode = button.getAttribute('data-hs-ba').replace('mode-', '');
72
+ button.addEventListener('click', (e) => {
73
+ e.preventDefault();
74
+
75
+ // If clicking split mode when already in split mode, reset to default position
76
+ if (mode === 'split' && instance.mode === 'split') {
77
+ instance.sliderPosition = config.sliderPosition;
78
+ }
79
+
80
+ setMode(instance, itemIndex, mode);
81
+ });
82
+ });
83
+
84
+ if (leftArrow) {
85
+ leftArrow.addEventListener('click', (e) => {
86
+ e.preventDefault();
87
+ navigateSlide(instance, -1);
88
+ });
89
+ }
90
+
91
+ if (rightArrow) {
92
+ rightArrow.addEventListener('click', (e) => {
93
+ e.preventDefault();
94
+ navigateSlide(instance, 1);
95
+ });
96
+ }
97
+
98
+ if (slider) {
99
+ slider.style.cursor = 'grab';
100
+ setupSliderDragging(instance, slider, itemIndex);
101
+ }
102
+
103
+ if (pagination) {
104
+ setupPagination(instance, pagination);
105
+ }
106
+ }
107
+
108
+ function setMode(instance, itemIndex, mode) {
109
+ const item = instance.items[itemIndex];
110
+ const afterImage = item.querySelector('[data-hs-ba="image-after"]');
111
+ const slider = item.querySelector('.ba-slider');
112
+ const sliderHandle = item.querySelector('[data-hs-ba="slider"]');
113
+ const modeButtons = item.querySelectorAll('[data-hs-ba^="mode-"]');
114
+
115
+ modeButtons.forEach(btn => {
116
+ const btnMode = btn.getAttribute('data-hs-ba').replace('mode-', '');
117
+ if (btnMode === mode) {
118
+ btn.classList.remove('light');
119
+ } else {
120
+ btn.classList.add('light');
121
+ }
122
+ });
123
+
124
+ switch (mode) {
125
+ case 'before':
126
+ if (afterImage) afterImage.style.clipPath = 'polygon(100% 0%, 100% 0%, 100% 100%, 100% 100%)';
127
+ if (slider) slider.style.display = 'none';
128
+ break;
129
+ case 'after':
130
+ if (afterImage) afterImage.style.clipPath = 'polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)';
131
+ if (slider) slider.style.display = 'none';
132
+ break;
133
+ case 'split':
134
+ if (afterImage) afterImage.style.clipPath = `polygon(${instance.sliderPosition}% 0%, 100% 0%, 100% 100%, ${instance.sliderPosition}% 100%)`;
135
+ if (sliderHandle) sliderHandle.style.left = `${instance.sliderPosition}%`;
136
+ if (slider) slider.style.display = 'flex';
137
+ break;
138
+ }
139
+
140
+ instance.mode = mode;
141
+ }
142
+
143
+ function setupSliderDragging(instance, slider, itemIndex) {
144
+ const item = instance.items[itemIndex];
145
+ const imageWrap = item.querySelector('.ba-image_wrap');
146
+
147
+ if (!imageWrap) return;
148
+
149
+ function startDrag(e) {
150
+ isDragging = true;
151
+ dragInstance = { instance, itemIndex, imageWrap, slider };
152
+ document.body.style.userSelect = 'none';
153
+ document.body.style.cursor = 'grabbing';
154
+ slider.style.cursor = 'grabbing';
155
+ e.preventDefault();
156
+ }
157
+
158
+ function handleDrag(clientX) {
159
+ if (!isDragging || !dragInstance) return;
160
+
161
+ const rect = dragInstance.imageWrap.getBoundingClientRect();
162
+ const x = clientX - rect.left;
163
+ const percentage = Math.max(0, Math.min(100, (x / rect.width) * 100));
164
+
165
+ // Move the slider element directly
166
+ dragInstance.slider.style.left = `${percentage}%`;
167
+
168
+ // Update the clip path
169
+ updateSliderPosition(dragInstance.instance, dragInstance.itemIndex, percentage);
170
+ dragInstance.instance.sliderPosition = percentage;
171
+ }
172
+
173
+ function endDrag() {
174
+ isDragging = false;
175
+ if (dragInstance && dragInstance.slider) {
176
+ dragInstance.slider.style.cursor = 'grab';
177
+ }
178
+ dragInstance = null;
179
+ document.body.style.userSelect = '';
180
+ document.body.style.cursor = '';
181
+ }
182
+
183
+ slider.addEventListener('mousedown', startDrag);
184
+ slider.addEventListener('touchstart', startDrag, { passive: false });
185
+
186
+ document.addEventListener('mousemove', (e) => handleDrag(e.clientX));
187
+ document.addEventListener('touchmove', (e) => {
188
+ if (isDragging) {
189
+ e.preventDefault();
190
+ handleDrag(e.touches[0].clientX);
191
+ }
192
+ }, { passive: false });
193
+
194
+ document.addEventListener('mouseup', endDrag);
195
+ document.addEventListener('touchend', endDrag);
196
+
197
+ imageWrap.addEventListener('click', (e) => {
198
+ if (instance.mode !== 'split') return;
199
+
200
+ const rect = imageWrap.getBoundingClientRect();
201
+ const x = e.clientX - rect.left;
202
+ const percentage = (x / rect.width) * 100;
203
+
204
+ slider.style.left = `${percentage}%`;
205
+ updateSliderPosition(instance, itemIndex, percentage);
206
+ instance.sliderPosition = percentage;
207
+ });
208
+ }
209
+
210
+ function updateSliderPosition(instance, itemIndex, percentage) {
211
+ const item = instance.items[itemIndex];
212
+ const afterImage = item.querySelector('[data-hs-ba="image-after"]');
213
+
214
+ if (afterImage) {
215
+ afterImage.style.clipPath = `polygon(${percentage}% 0%, 100% 0%, 100% 100%, ${percentage}% 100%)`;
216
+ }
217
+ }
218
+
219
+ function navigateSlide(instance, direction) {
220
+ const newIndex = instance.currentIndex + direction;
221
+ const maxIndex = instance.items.length - 1;
222
+
223
+ let targetIndex;
224
+ if (newIndex > maxIndex) {
225
+ targetIndex = 0;
226
+ } else if (newIndex < 0) {
227
+ targetIndex = maxIndex;
228
+ } else {
229
+ targetIndex = newIndex;
230
+ }
231
+
232
+ showSlide(instance, targetIndex);
233
+ }
234
+
235
+ function showSlide(instance, index) {
236
+ if (index === instance.currentIndex) return;
237
+
238
+ // Reset split position to default when switching items
239
+ instance.sliderPosition = config.sliderPosition;
240
+
241
+ instance.items.forEach((item, i) => {
242
+ item.style.display = i === index ? 'block' : 'none';
243
+
244
+ // Update clip path for the new active item
245
+ if (i === index) {
246
+ const afterImage = item.querySelector('[data-hs-ba="image-after"]');
247
+ const sliderHandle = item.querySelector('[data-hs-ba="slider"]');
248
+
249
+ if (afterImage) {
250
+ // Apply default slider position to new item
251
+ if (instance.mode === 'split') {
252
+ afterImage.style.clipPath = `polygon(${instance.sliderPosition}% 0%, 100% 0%, 100% 100%, ${instance.sliderPosition}% 100%)`;
253
+ }
254
+ }
255
+
256
+ if (sliderHandle && instance.mode === 'split') {
257
+ // Position slider at default position
258
+ sliderHandle.style.left = `${instance.sliderPosition}%`;
259
+ }
260
+ }
261
+ });
262
+
263
+ instance.currentIndex = index;
264
+ currentSlideIndex[instance.id] = index;
265
+
266
+ updatePagination(instance);
267
+ setMode(instance, index, instance.mode);
268
+ }
269
+
270
+ function setupPagination(instance, pagination) {
271
+ const dots = Array.from(pagination.querySelectorAll('.ba-pag_dot'));
272
+
273
+ dots.forEach((dot, index) => {
274
+ dot.addEventListener('click', () => {
275
+ showSlide(instance, index);
276
+ });
277
+
278
+ dot.style.cursor = 'pointer';
279
+ dot.setAttribute('role', 'button');
280
+ dot.setAttribute('tabindex', '0');
281
+ dot.setAttribute('aria-label', `Go to slide ${index + 1}`);
282
+
283
+ dot.addEventListener('keydown', (e) => {
284
+ if (e.key === 'Enter' || e.key === ' ') {
285
+ e.preventDefault();
286
+ showSlide(instance, index);
287
+ }
288
+ });
289
+ });
290
+ }
291
+
292
+ function updatePagination(instance) {
293
+ instance.items.forEach((item, itemIndex) => {
294
+ const pagination = item.querySelector('[data-hs-ba="pagination"]');
295
+ if (!pagination) return;
296
+
297
+ const dots = pagination.querySelectorAll('.ba-pag_dot');
298
+ dots.forEach((dot, dotIndex) => {
299
+ if (dotIndex === instance.currentIndex) {
300
+ dot.classList.add('active');
301
+ dot.setAttribute('aria-current', 'true');
302
+ } else {
303
+ dot.classList.remove('active');
304
+ dot.removeAttribute('aria-current');
305
+ }
306
+ });
307
+ });
308
+ }
309
+
310
+ function setupKeyboardNavigation(instance) {
311
+ instance.wrapper.addEventListener('keydown', (e) => {
312
+ switch (e.key) {
313
+ case 'ArrowLeft':
314
+ e.preventDefault();
315
+ if (instance.mode === 'split') {
316
+ const newPos = Math.max(0, instance.sliderPosition - config.keyboardStep);
317
+ updateSliderPosition(instance, instance.currentIndex, newPos);
318
+ instance.sliderPosition = newPos;
319
+ } else {
320
+ navigateSlide(instance, -1);
321
+ }
322
+ break;
323
+ case 'ArrowRight':
324
+ e.preventDefault();
325
+ if (instance.mode === 'split') {
326
+ const newPos = Math.min(100, instance.sliderPosition + config.keyboardStep);
327
+ updateSliderPosition(instance, instance.currentIndex, newPos);
328
+ instance.sliderPosition = newPos;
329
+ } else {
330
+ navigateSlide(instance, 1);
331
+ }
332
+ break;
333
+ case 'ArrowUp':
334
+ e.preventDefault();
335
+ navigateSlide(instance, -1);
336
+ break;
337
+ case 'ArrowDown':
338
+ e.preventDefault();
339
+ navigateSlide(instance, 1);
340
+ break;
341
+ case '1':
342
+ setMode(instance, instance.currentIndex, 'before');
343
+ break;
344
+ case '2':
345
+ setMode(instance, instance.currentIndex, 'split');
346
+ break;
347
+ case '3':
348
+ setMode(instance, instance.currentIndex, 'after');
349
+ break;
350
+ }
351
+ });
352
+
353
+ instance.wrapper.setAttribute('tabindex', '0');
354
+ instance.wrapper.setAttribute('role', 'application');
355
+ instance.wrapper.setAttribute('aria-label', 'Before and after image comparison');
356
+ }
357
+
358
+ function init() {
359
+ const wrappers = document.querySelectorAll('[data-hs-ba="wrapper"]');
360
+
361
+ wrappers.forEach(wrapper => {
362
+ initInstance(wrapper);
363
+ });
364
+
365
+ return { result: 'before-after initialized' };
366
+ }
367
+
368
+ if (typeof window !== 'undefined') {
369
+ window.hsmain = window.hsmain || {};
370
+ window.hsmain.utilBeforeAfter = {
371
+ init,
372
+ config,
373
+ updateConfig,
374
+ instances,
375
+ showSlide: (instanceId, index) => {
376
+ const instance = instances[instanceId];
377
+ if (instance) showSlide(instance, index);
378
+ },
379
+ setMode: (instanceId, mode) => {
380
+ const instance = instances[instanceId];
381
+ if (instance) setMode(instance, instance.currentIndex, mode);
382
+ }
383
+ };
384
+ }
385
+
386
+ return init();
387
+ }