@hortonstudio/main 1.9.11 → 1.9.20

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 (120) hide show
  1. package/.prettierrc +8 -0
  2. package/README.md +146 -0
  3. package/eslint.config.js +32 -0
  4. package/index.ts +275 -0
  5. package/package.json +19 -2
  6. package/public/bootstrap.js +16 -0
  7. package/src/animations/animations.ts +93 -0
  8. package/src/animations/functions/counter/counter.ts +137 -0
  9. package/src/config.json +570 -0
  10. package/src/config.ts +105 -0
  11. package/src/modules/default/README.md +167 -0
  12. package/src/modules/default/default.ts +71 -0
  13. package/{autoInit → src/modules/default/functions}/accessibility/README.md +44 -12
  14. package/src/modules/default/functions/accessibility/accessibility.ts +54 -0
  15. package/src/modules/default/functions/accordion/README.md +451 -0
  16. package/src/modules/default/functions/accordion/accordion.ts +189 -0
  17. package/src/modules/default/functions/comparison/comparison.ts +424 -0
  18. package/src/modules/default/functions/marquee/marquee.ts +206 -0
  19. package/src/modules/default/functions/navbar/README.md +393 -0
  20. package/src/modules/default/functions/navbar/functions/arrow-navigation/arrow-navigation.ts +183 -0
  21. package/src/modules/default/functions/navbar/functions/dropdown/dropdown.ts +313 -0
  22. package/src/modules/default/functions/navbar/functions/menu/menu.ts +315 -0
  23. package/src/modules/default/functions/navbar/navbar.ts +51 -0
  24. package/{autoInit → src/modules/default/functions}/smooth-scroll/README.md +45 -14
  25. package/{autoInit/smooth-scroll/smooth-scroll.js → src/modules/default/functions/smooth-scroll/smooth-scroll.ts} +33 -38
  26. package/{autoInit → src/modules/default/functions}/transition/README.md +59 -32
  27. package/src/modules/default/functions/transition/transition.ts +290 -0
  28. package/src/modules/normalize/README.md +172 -0
  29. package/src/modules/normalize/functions/clickable/README.md +84 -0
  30. package/src/modules/normalize/functions/clickable/clickable.ts +43 -0
  31. package/src/modules/normalize/functions/clickable/functions/normalize/README.md +213 -0
  32. package/src/modules/normalize/functions/clickable/functions/normalize/normalize.ts +68 -0
  33. package/src/modules/normalize/functions/dupe/README.md +405 -0
  34. package/src/modules/normalize/functions/dupe/dupe.ts +197 -0
  35. package/src/modules/normalize/functions/sync/sync.ts +378 -0
  36. package/src/modules/normalize/normalize.ts +58 -0
  37. package/src/modules/structure/README.md +190 -0
  38. package/src/modules/structure/functions/form/README.md +94 -0
  39. package/src/modules/structure/functions/form/form.ts +54 -0
  40. package/src/modules/structure/functions/form/functions/honeypot/README.md +77 -0
  41. package/src/modules/structure/functions/form/functions/honeypot/honeypot.ts +37 -0
  42. package/src/modules/structure/functions/form/functions/range/README.md +410 -0
  43. package/src/modules/structure/functions/form/functions/range/range.ts +92 -0
  44. package/src/modules/structure/functions/form/functions/select/README.md +393 -0
  45. package/src/modules/structure/functions/form/functions/select/functions/custom-select/custom-select.ts +637 -0
  46. package/src/modules/structure/functions/form/functions/select/functions/states/states.ts +118 -0
  47. package/src/modules/structure/functions/form/functions/select/select.ts +48 -0
  48. package/src/modules/structure/functions/form/functions/test/test.ts +132 -0
  49. package/{autoInit/accessibility → src/modules/structure}/functions/pagination/README.md +147 -72
  50. package/{autoInit/accessibility/functions/pagination/pagination.js → src/modules/structure/functions/pagination/pagination.ts} +98 -50
  51. package/{autoInit → src/modules/structure/functions}/site-settings/README.md +57 -27
  52. package/{autoInit/site-settings/site-settings.js → src/modules/structure/functions/site-settings/site-settings.ts} +36 -32
  53. package/{autoInit/accessibility → src/modules/structure}/functions/toc/README.md +18 -15
  54. package/{autoInit/accessibility/functions/toc/toc.js → src/modules/structure/functions/toc/functions/heading-links/heading-links.ts} +43 -63
  55. package/src/modules/structure/functions/toc/functions/progress-bar/progress-bar.ts +101 -0
  56. package/src/modules/structure/functions/toc/toc.ts +35 -0
  57. package/{autoInit/accessibility → src/modules/structure}/functions/year-replacement/README.md +7 -6
  58. package/src/modules/structure/functions/year-replacement/year-replacement.ts +59 -0
  59. package/src/modules/structure/structure.ts +59 -0
  60. package/src/utils/attributeSelector.ts +78 -0
  61. package/src/utils/cssVariables.ts +24 -0
  62. package/src/utils/gsap.ts +198 -0
  63. package/src/utils/heightAnimator.ts +130 -0
  64. package/src/utils/modalManager.ts +150 -0
  65. package/src/utils.ts +54 -0
  66. package/tsconfig.json +24 -0
  67. package/vite.config.js +45 -0
  68. package/.claude/settings.local.json +0 -70
  69. package/archive/hero.js +0 -794
  70. package/archive/modal.js +0 -80
  71. package/archive/text.js +0 -628
  72. package/autoInit/accessibility/accessibility.js +0 -53
  73. package/autoInit/accessibility/functions/blog-remover/README.md +0 -61
  74. package/autoInit/accessibility/functions/blog-remover/blog-remover.js +0 -31
  75. package/autoInit/accessibility/functions/click-forwarding/README.md +0 -60
  76. package/autoInit/accessibility/functions/click-forwarding/click-forwarding.js +0 -82
  77. package/autoInit/accessibility/functions/dropdown/README.md +0 -212
  78. package/autoInit/accessibility/functions/dropdown/dropdown.js +0 -167
  79. package/autoInit/accessibility/functions/list-accessibility/README.md +0 -56
  80. package/autoInit/accessibility/functions/list-accessibility/list-accessibility.js +0 -23
  81. package/autoInit/accessibility/functions/text-synchronization/README.md +0 -62
  82. package/autoInit/accessibility/functions/text-synchronization/text-synchronization.js +0 -101
  83. package/autoInit/accessibility/functions/year-replacement/year-replacement.js +0 -43
  84. package/autoInit/button/README.md +0 -122
  85. package/autoInit/button/button.js +0 -51
  86. package/autoInit/counter/README.md +0 -274
  87. package/autoInit/counter/counter.js +0 -185
  88. package/autoInit/form/README.md +0 -338
  89. package/autoInit/form/form.js +0 -374
  90. package/autoInit/navbar/README.md +0 -366
  91. package/autoInit/navbar/navbar.js +0 -786
  92. package/autoInit/transition/transition.js +0 -116
  93. package/index.js +0 -305
  94. package/utils/before-after/README.md +0 -520
  95. package/utils/before-after/before-after.js +0 -653
  96. package/utils/css-animations/buttons/main/bgbasic/btn-main-bgbasic.html +0 -10
  97. package/utils/css-animations/buttons/main/bgfill/btn-main-bgfill.html +0 -29
  98. package/utils/css-animations/buttons/navbar/bgbasic/navbar-main-bgbasic.html +0 -17
  99. package/utils/css-animations/buttons/navbar/bgbasic/navbar-menu-bgbasic.html +0 -16
  100. package/utils/css-animations/buttons/navbar/bgfill/navbar-main-bgfill.html +0 -46
  101. package/utils/css-animations/buttons/navbar/bgfill/navbar-menu-bgfill.html +0 -39
  102. package/utils/css-animations/buttons/navbar/color/navbar-announce-color.html +0 -5
  103. package/utils/css-animations/buttons/navbar/color/navbar-main-color.html +0 -7
  104. package/utils/css-animations/buttons/navbar/color/navbar-menu-color.html +0 -7
  105. package/utils/css-animations/buttons/navbar/double-slide/navbar-announce-double-slide.html +0 -40
  106. package/utils/css-animations/buttons/navbar/double-slide/navbar-main-double-slide.html +0 -77
  107. package/utils/css-animations/buttons/navbar/scale/navbar-announce-scale.html +0 -6
  108. package/utils/css-animations/buttons/navbar/scale/navbar-main-scale.html +0 -9
  109. package/utils/css-animations/buttons/navbar/scale/navbar-menu-scale.html +0 -8
  110. package/utils/css-animations/buttons/navbar/underline/navbar-announce-underline.html +0 -32
  111. package/utils/css-animations/buttons/navbar/underline/navbar-main-underline.html +0 -56
  112. package/utils/css-animations/buttons/text/color/text-footer-color.html +0 -5
  113. package/utils/css-animations/buttons/text/color/text-main-color.html +0 -5
  114. package/utils/css-animations/buttons/text/double-slide/text-main-double-slide.html +0 -56
  115. package/utils/css-animations/buttons/text/scale/text-footer-scale.html +0 -6
  116. package/utils/css-animations/buttons/text/scale/text-main-scale.html +0 -6
  117. package/utils/css-animations/buttons/text/underline/text-footer-underline.html +0 -45
  118. package/utils/css-animations/buttons/text/underline/text-main-underline.html +0 -58
  119. package/utils/css-animations/cards/card-clickable.html +0 -11
  120. package/utils/css-animations/defaults.html +0 -69
@@ -1,53 +0,0 @@
1
- export async function init() {
2
- // Centralized cleanup tracking
3
- const cleanup = {
4
- modules: {},
5
- destroyFunctions: []
6
- };
7
-
8
- const functionMap = {
9
- "blog-remover": () => import("./functions/blog-remover/blog-remover.js"),
10
- "list-accessibility": () => import("./functions/list-accessibility/list-accessibility.js"),
11
- "year-replacement": () => import("./functions/year-replacement/year-replacement.js"),
12
- "click-forwarding": () => import("./functions/click-forwarding/click-forwarding.js"),
13
- "text-synchronization": () => import("./functions/text-synchronization/text-synchronization.js"),
14
- "toc": () => import("./functions/toc/toc.js"),
15
- "dropdown": () => import("./functions/dropdown/dropdown.js"),
16
- "pagination": () => import("./functions/pagination/pagination.js")
17
- };
18
-
19
- const loadFunction = async (functionName) => {
20
- try {
21
- const { init } = await functionMap[functionName]();
22
- const result = await init();
23
- cleanup.modules[functionName] = result;
24
- if (result && result.destroy) {
25
- cleanup.destroyFunctions.push(result.destroy);
26
- }
27
- return result;
28
- } catch (error) {
29
- console.error(`Failed to load accessibility function: ${functionName}`, error);
30
- throw error;
31
- }
32
- };
33
-
34
- // Load all functions
35
- const functionPromises = Object.keys(functionMap).map(name => loadFunction(name));
36
- await Promise.all(functionPromises);
37
-
38
- return {
39
- result: "accessibility initialized",
40
- destroy: () => {
41
- // Call all destroy functions
42
- cleanup.destroyFunctions.forEach(destroyFn => {
43
- try {
44
- destroyFn();
45
- } catch (error) {
46
- console.error('Error during accessibility cleanup:', error);
47
- }
48
- });
49
- cleanup.destroyFunctions.length = 0;
50
- cleanup.modules = {};
51
- }
52
- };
53
- }
@@ -1,61 +0,0 @@
1
- # **Blog Remover**
2
-
3
- ## **Overview**
4
-
5
- Automatically removes blog wrapper elements that have no blog list content. Useful for cleaning up empty blog sections when using Webflow CMS conditional visibility.
6
-
7
- ---
8
-
9
- ## **Required Elements**
10
-
11
- **Blog Wrapper**
12
- * data-site-blog="wrapper"
13
- * data-site-blog-config="delete-if-no-list" (triggers deletion check)
14
-
15
- **Blog List** *(descendant of wrapper)*
16
- * data-site-blog="list"
17
-
18
- ---
19
-
20
- ## **What It Does**
21
-
22
- 1. Finds all `[data-site-blog="wrapper"]` elements
23
- 2. Checks if wrapper has `data-site-blog-config="delete-if-no-list"`
24
- 3. If yes, checks for descendant with `[data-site-blog="list"]`
25
- 4. If no list found, deletes the entire wrapper
26
-
27
- ---
28
-
29
- ## **Usage Example**
30
-
31
- ```html
32
- <!-- Will be deleted if no blog list inside -->
33
- <div data-site-blog="wrapper" data-site-blog-config="delete-if-no-list">
34
- <h2>Recent Posts</h2>
35
- <!-- If Webflow CMS hides the list, wrapper gets removed -->
36
- </div>
37
-
38
- <!-- Will remain (no delete config) -->
39
- <div data-site-blog="wrapper">
40
- <h2>Recent Posts</h2>
41
- <p>No posts yet.</p>
42
- </div>
43
- ```
44
-
45
- ---
46
-
47
- ## **Key Attributes**
48
-
49
- | Attribute | Purpose |
50
- | ----- | ----- |
51
- | `data-site-blog="wrapper"` | Blog container |
52
- | `data-site-blog-config="delete-if-no-list"` | Enable deletion if empty |
53
- | `data-site-blog="list"` | Blog list (prevents deletion) |
54
-
55
- ---
56
-
57
- ## **Notes**
58
-
59
- * One-time operation on page load
60
- * No cleanup needed
61
- * Barba.js compatible
@@ -1,31 +0,0 @@
1
- export function init() {
2
- function setupBlogListCleanup() {
3
- const wrappers = document.querySelectorAll('[data-site-blog="wrapper"]');
4
-
5
- wrappers.forEach(wrapper => {
6
- // Check if wrapper has the delete-if-no-list config
7
- const shouldDelete = wrapper.getAttribute('data-site-blog-config') === 'delete-if-no-list';
8
-
9
- if (!shouldDelete) {
10
- return;
11
- }
12
-
13
- // Check if there's a descendant with data-site-blog="list"
14
- const hasList = wrapper.querySelector('[data-site-blog="list"]') !== null;
15
-
16
- // Delete wrapper if it doesn't have a list
17
- if (!hasList) {
18
- wrapper.remove();
19
- }
20
- });
21
- }
22
-
23
- setupBlogListCleanup();
24
-
25
- return {
26
- result: "blog-remover initialized",
27
- destroy: () => {
28
- // No cleanup needed - this is a one-time DOM operation
29
- }
30
- };
31
- }
@@ -1,60 +0,0 @@
1
- # **Click Forwarding**
2
-
3
- ## **Overview**
4
-
5
- Forwards clicks from decorative/wrapper elements to actual interactive trigger elements. Useful for making entire card areas clickable while maintaining semantic button/link.
6
-
7
- ---
8
-
9
- ## **Required Elements**
10
-
11
- **Clickable Element** *(wrapper users click)*
12
- * data-hs-a11y="click-trigger-[identifier], clickable"
13
-
14
- **Trigger Element** *(actual button/link)*
15
- * data-hs-a11y="click-trigger-[identifier], trigger"
16
-
17
- **Note:** `[identifier]` must match between clickable and trigger.
18
-
19
- ---
20
-
21
- ## **What It Does**
22
-
23
- 1. Finds all clickable elements
24
- 2. Matches them to trigger elements by identifier
25
- 3. Forwards click events from clickable → trigger
26
- 4. Adds keyboard support (Enter/Space)
27
- 5. Sets `tabindex="0"` and `role="button"` on clickable
28
-
29
- ---
30
-
31
- ## **Usage Example**
32
-
33
- ```html
34
- <div data-hs-a11y="click-trigger-card1, clickable">
35
- <h3>Card Title</h3>
36
- <p>Card description...</p>
37
- <button data-hs-a11y="click-trigger-card1, trigger">
38
- Learn More
39
- </button>
40
- </div>
41
- ```
42
-
43
- **Result:** Clicking anywhere in the div triggers the button.
44
-
45
- ---
46
-
47
- ## **Key Attributes**
48
-
49
- | Attribute | Purpose |
50
- | ----- | ----- |
51
- | `data-hs-a11y="click-trigger-[id], clickable"` | Wrapper to make clickable |
52
- | `data-hs-a11y="click-trigger-[id], trigger"` | Actual button/link |
53
-
54
- ---
55
-
56
- ## **Notes**
57
-
58
- * Identifier must match exactly
59
- * Event listeners cleaned up on destroy
60
- * Supports keyboard activation
@@ -1,82 +0,0 @@
1
- export function init() {
2
- const cleanup = {
3
- handlers: []
4
- };
5
-
6
- const addHandler = (element, event, handler, options) => {
7
- element.addEventListener(event, handler, options);
8
- cleanup.handlers.push({ element, event, handler, options });
9
- };
10
-
11
- function setupClickForwarding(addHandler) {
12
- // Find all clickable elements (custom styled elements users click)
13
- const clickableElements = document.querySelectorAll('[data-hs-a11y*="clickable"]');
14
-
15
- clickableElements.forEach(clickableElement => {
16
- const attribute = clickableElement.getAttribute('data-hs-a11y');
17
-
18
- // Parse the attribute: "click-trigger-[identifier], clickable"
19
- const parts = attribute.split(',').map(part => part.trim());
20
-
21
- // Find the part with click-trigger and the part with clickable
22
- const triggerPart = parts.find(part => part.startsWith('click-trigger-'));
23
- const rolePart = parts.find(part => part === 'clickable');
24
-
25
- if (!triggerPart || !rolePart) {
26
- return;
27
- }
28
-
29
- // Extract identifier from "click-trigger-[identifier]"
30
- const identifier = triggerPart.replace('click-trigger-', '').trim();
31
-
32
- // Find the corresponding trigger element
33
- const triggerSelector = `[data-hs-a11y*="click-trigger-${identifier}"][data-hs-a11y*="trigger"]`;
34
- const triggerElement = document.querySelector(triggerSelector);
35
-
36
- if (!triggerElement) {
37
- return;
38
- }
39
-
40
- // Add click event listener to forward clicks
41
- const clickHandler = (event) => {
42
- // Prevent default behavior on the clickable element
43
- event.preventDefault();
44
- event.stopPropagation();
45
-
46
- // Trigger click on the target element
47
- triggerElement.click();
48
- };
49
- addHandler(clickableElement, 'click', clickHandler);
50
-
51
- // Also handle keyboard events for accessibility
52
- const keydownHandler = (event) => {
53
- if (event.key === 'Enter' || event.key === ' ') {
54
- event.preventDefault();
55
- event.stopPropagation();
56
- triggerElement.click();
57
- }
58
- };
59
- addHandler(clickableElement, 'keydown', keydownHandler);
60
-
61
- // Ensure clickable element is keyboard accessible
62
- if (!clickableElement.hasAttribute('tabindex')) {
63
- clickableElement.setAttribute('tabindex', '0');
64
- }
65
- if (!clickableElement.hasAttribute('role')) {
66
- clickableElement.setAttribute('role', 'button');
67
- }
68
- });
69
- }
70
-
71
- setupClickForwarding(addHandler);
72
-
73
- return {
74
- result: "click-forwarding initialized",
75
- destroy: () => {
76
- cleanup.handlers.forEach(({ element, event, handler, options }) => {
77
- element.removeEventListener(event, handler, options);
78
- });
79
- cleanup.handlers.length = 0;
80
- }
81
- };
82
- }
@@ -1,212 +0,0 @@
1
- # **Dropdown Accessibility**
2
-
3
- ## **Overview**
4
-
5
- Universal dropdown system for FAQ, summary/read-more, and general toggle components. Automatically syncs ARIA attributes with Webflow interactions and optionally updates text content.
6
-
7
- **This function handles all dropdown/accordion/toggle patterns with a single unified system.**
8
-
9
- ---
10
-
11
- ## **Required Elements**
12
-
13
- **Dropdown Wrapper**
14
- * data-hs-dropdown="wrapper"
15
- * data-hs-dropdown-text-swap="Close" (text for open state, defaults to "Close")
16
-
17
- **Dropdown Toggle** *(gets is-active class from Webflow IX)*
18
- * data-hs-dropdown="toggle"
19
- * Contains clickable element
20
-
21
- **Clickable Element**
22
- * data-site-clickable="element"
23
- * First child receives ARIA attributes
24
-
25
- **Text Wrapper** *(optional - controls text update scope)*
26
- * data-hs-dropdown="text"
27
- * If present: all text inside swaps
28
- * If absent: only aria-label updates (visual text stays static)
29
-
30
- **Dropdown Content** *(expandable area)*
31
- * data-hs-dropdown="content"
32
-
33
- ---
34
-
35
- ## **What It Does**
36
-
37
- 1. **Monitors Toggle State**: MutationObserver watches for `is-active` class changes
38
- 2. **Updates ARIA**: Sets `aria-expanded`, `aria-controls`, `aria-hidden`, `role="region"`
39
- 3. **Text Updates**:
40
- - **With text wrapper**: Swaps all text nodes + aria-label
41
- - **Without text wrapper**: Only updates aria-label (visual stays static)
42
- 4. **Focus Management**: Returns focus to toggle button when closing if focus is inside content
43
- 5. **Supports Nesting**: Each dropdown manages its own state independently
44
-
45
- ---
46
-
47
- ## **Usage Examples**
48
-
49
- ### **FAQ (No Visual Text Change)**
50
-
51
- ```html
52
- <div data-hs-dropdown="wrapper" data-hs-dropdown-text-swap="Close FAQ">
53
- <div data-hs-dropdown="toggle">
54
- <div data-site-clickable="element">
55
- <button>
56
- <span class="u-sr-only">Open FAQ</span>
57
- </button>
58
- </div>
59
- <h3>What is this product?</h3>
60
- </div>
61
- <div data-hs-dropdown="content">
62
- <p>This is a detailed answer...</p>
63
- </div>
64
- </div>
65
- ```
66
-
67
- **Result:**
68
- - Question text stays visible ✅
69
- - Only aria-label swaps: "Open FAQ" ↔ "Close FAQ"
70
- - Perfect for FAQ lists
71
-
72
- ---
73
-
74
- ### **Summary (Full Text Swap)**
75
-
76
- ```html
77
- <div data-hs-dropdown="wrapper" data-hs-dropdown-text-swap="Close">
78
- <div data-hs-dropdown="toggle">
79
- <p>View Quick Summary</p>
80
- <div data-hs-dropdown="text">
81
- <div data-site-clickable="element">
82
- <button>View</button>
83
- </div>
84
- </div>
85
- </div>
86
- <div data-hs-dropdown="content">
87
- <p>Full summary content...</p>
88
- </div>
89
- </div>
90
- ```
91
-
92
- **Result:**
93
- - All text in "text" wrapper swaps: "View" ↔ "Close"
94
- - aria-label also swaps to match
95
- - Perfect for read-more toggles
96
-
97
- ---
98
-
99
- ### **Product Details (No Text Wrapper)**
100
-
101
- ```html
102
- <div data-hs-dropdown="wrapper" data-hs-dropdown-text-swap="Hide Details">
103
- <div data-hs-dropdown="toggle">
104
- <div data-site-clickable="element">
105
- <button>Show Details</button>
106
- </div>
107
- </div>
108
- <div data-hs-dropdown="content">
109
- <p>Product specifications...</p>
110
- </div>
111
- </div>
112
- ```
113
-
114
- **Result:**
115
- - Only aria-label swaps: "Show Details" ↔ "Hide Details"
116
- - Visual button text stays static
117
-
118
- ---
119
-
120
- ### **Nested Dropdowns**
121
-
122
- ```html
123
- <div data-hs-dropdown="wrapper" data-hs-dropdown-text-swap="Hide">
124
- <div data-hs-dropdown="toggle">
125
- <button>Product Details</button>
126
- </div>
127
- <div data-hs-dropdown="content">
128
- <p>Product info...</p>
129
-
130
- <!-- Nested FAQ -->
131
- <div data-hs-dropdown="wrapper" data-hs-dropdown-text-swap="Close FAQ">
132
- <div data-hs-dropdown="toggle">
133
- <button>Shipping Questions</button>
134
- </div>
135
- <div data-hs-dropdown="content">
136
- <p>Shipping details...</p>
137
- </div>
138
- </div>
139
- </div>
140
- </div>
141
- ```
142
-
143
- **Result:**
144
- - Each dropdown manages its own state
145
- - Focus returns to parent when parent closes
146
- - Fully accessible keyboard navigation
147
-
148
- ---
149
-
150
- ## **Key Attributes**
151
-
152
- | Attribute | Purpose |
153
- | ----- | ----- |
154
- | `data-hs-dropdown="wrapper"` | Container for entire dropdown |
155
- | `data-hs-dropdown-text-swap` | Text for open state (default: "Close") |
156
- | `data-hs-dropdown="toggle"` | Gets is-active from Webflow IX |
157
- | `data-hs-dropdown="text"` | Optional text update scope |
158
- | `data-site-clickable="element"` | Contains actual button/link |
159
- | `data-hs-dropdown="content"` | Expandable content area |
160
-
161
- ---
162
-
163
- ## **How It Works**
164
-
165
- 1. **Initial Setup**: Scans for all `[data-hs-dropdown="wrapper"]` elements
166
- 2. **ID Generation**: Creates unique IDs for ARIA relationships
167
- 3. **Text Capture**: Saves original text from text wrapper or clickable
168
- 4. **State Detection**: Checks for existing `is-active` class on toggle
169
- 5. **Observer Setup**: MutationObserver watches toggle for class changes
170
- 6. **State Sync**: When `is-active` changes, updates ARIA and text
171
- 7. **Focus Management**: Returns focus to button when closing if needed
172
-
173
- ---
174
-
175
- ## **Focus Management**
176
-
177
- When any dropdown closes:
178
- - If focus is anywhere inside content area
179
- - Focus automatically returns to toggle button
180
- - Then ARIA updates apply
181
- - Prevents keyboard users from losing focus
182
-
183
- Works for:
184
- - Buttons inside content ✅
185
- - Nested dropdowns ✅
186
- - Form fields inside content ✅
187
-
188
- ---
189
-
190
- ## **Notes**
191
-
192
- * Pattern matches navbar structure
193
- * Works with Webflow IX for visual state (`is-active`)
194
- * Text wrapper is optional - use for visual text changes
195
- * Without text wrapper, only aria-label changes
196
- * Supports infinite nesting depth
197
- * Warns in console if multiple clickables found in toggle
198
- * All observers cleaned up on destroy for Barba.js compatibility
199
-
200
- ---
201
-
202
- ## **Common Patterns**
203
-
204
- ### **When to use text wrapper:**
205
- - Summary/Read More toggles
206
- - Buttons that should visually change
207
- - Any toggle where text swap is visible
208
-
209
- ### **When NOT to use text wrapper:**
210
- - FAQ items (question should stay visible)
211
- - Toggles with static visible labels
212
- - Dropdowns where only accessible text should change
@@ -1,167 +0,0 @@
1
- export function init() {
2
- const cleanup = {
3
- observers: []
4
- };
5
-
6
- const addObserver = (observer) => cleanup.observers.push(observer);
7
-
8
- function setupDropdownAccessibility(addObserver) {
9
- const dropdownWrappers = document.querySelectorAll('[data-hs-dropdown="wrapper"]');
10
-
11
- dropdownWrappers.forEach((wrapper, index) => {
12
- const toggle = wrapper.querySelector('[data-hs-dropdown="toggle"]');
13
- const content = wrapper.querySelector('[data-hs-dropdown="content"]');
14
- const textSwapValue = wrapper.getAttribute('data-hs-dropdown-text-swap') || 'Close';
15
-
16
- if (!toggle || !content) {
17
- return;
18
- }
19
-
20
- // Check for multiple clickables and warn
21
- const clickables = toggle.querySelectorAll('[data-site-clickable="element"]');
22
- if (clickables.length > 1) {
23
- console.warn('[dropdown-accessibility] Multiple clickables found in toggle. Using first one only.', toggle);
24
- }
25
-
26
- const clickable = clickables[0];
27
- if (!clickable) {
28
- return;
29
- }
30
-
31
- const button = clickable.children[0];
32
- if (!button) {
33
- return;
34
- }
35
-
36
- // Generate unique IDs
37
- const buttonId = `hs-dropdown-btn-${index}`;
38
- const contentId = `hs-dropdown-content-${index}`;
39
-
40
- // Check if text wrapper exists
41
- const textWrapper = toggle.querySelector('[data-hs-dropdown="text"]');
42
-
43
- // Capture original text
44
- let originalText = '';
45
- if (textWrapper) {
46
- // Get text from text wrapper
47
- const walker = document.createTreeWalker(
48
- textWrapper,
49
- NodeFilter.SHOW_TEXT,
50
- null,
51
- false
52
- );
53
-
54
- let firstTextNode = walker.nextNode();
55
- while (firstTextNode && !firstTextNode.textContent.trim()) {
56
- firstTextNode = walker.nextNode();
57
- }
58
-
59
- originalText = firstTextNode ? firstTextNode.textContent.trim() : textWrapper.textContent.trim();
60
- } else {
61
- // Get text from clickable for aria-label fallback
62
- const walker = document.createTreeWalker(
63
- clickable,
64
- NodeFilter.SHOW_TEXT,
65
- null,
66
- false
67
- );
68
-
69
- let firstTextNode = walker.nextNode();
70
- while (firstTextNode && !firstTextNode.textContent.trim()) {
71
- firstTextNode = walker.nextNode();
72
- }
73
-
74
- originalText = firstTextNode ? firstTextNode.textContent.trim() : clickable.textContent.trim();
75
- }
76
-
77
- // Function to update text nodes
78
- function updateText(newText) {
79
- if (textWrapper) {
80
- // Update all text nodes in text wrapper
81
- const walker = document.createTreeWalker(
82
- textWrapper,
83
- NodeFilter.SHOW_TEXT,
84
- null,
85
- false
86
- );
87
-
88
- const textNodes = [];
89
- let node;
90
- while (node = walker.nextNode()) {
91
- if (node.textContent.trim()) {
92
- textNodes.push(node);
93
- }
94
- }
95
-
96
- textNodes.forEach(textNode => {
97
- textNode.textContent = newText;
98
- });
99
- }
100
-
101
- // Always update aria-label
102
- button.setAttribute('aria-label', newText);
103
- }
104
-
105
- // Set initial IDs and ARIA attributes
106
- button.setAttribute('id', buttonId);
107
- content.setAttribute('id', contentId);
108
- content.setAttribute('role', 'region');
109
- content.setAttribute('aria-labelledby', buttonId);
110
-
111
- button.setAttribute('aria-controls', contentId);
112
-
113
- // Function to check if dropdown is open
114
- function isDropdownOpen() {
115
- return toggle.classList.contains('is-active');
116
- }
117
-
118
- // Update ARIA states based on current visual state
119
- function updateARIAStates() {
120
- const isOpen = isDropdownOpen();
121
- const wasOpen = button.getAttribute("aria-expanded") === "true";
122
-
123
- // If closing and focus is inside content, return focus first
124
- if (wasOpen && !isOpen && content.contains(document.activeElement)) {
125
- button.focus();
126
- }
127
-
128
- // Update ARIA attributes
129
- button.setAttribute("aria-expanded", isOpen ? "true" : "false");
130
- content.setAttribute("aria-hidden", isOpen ? "false" : "true");
131
-
132
- // Update text and aria-label
133
- if (isOpen) {
134
- updateText(textSwapValue);
135
- } else {
136
- updateText(originalText);
137
- }
138
- }
139
-
140
- // Set initial state based on existing is-active class
141
- updateARIAStates();
142
-
143
- // Monitor for class changes on toggle
144
- const observer = new MutationObserver(() => {
145
- updateARIAStates();
146
- });
147
-
148
- observer.observe(toggle, {
149
- attributes: true,
150
- attributeFilter: ['class']
151
- });
152
-
153
- addObserver(observer);
154
- });
155
- }
156
-
157
- setupDropdownAccessibility(addObserver);
158
-
159
- return {
160
- result: "dropdown initialized",
161
- destroy: () => {
162
- // Disconnect all observers
163
- cleanup.observers.forEach(obs => obs.disconnect());
164
- cleanup.observers.length = 0;
165
- }
166
- };
167
- }