@hortonstudio/main 1.9.10 → 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 (124) 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/src/modules/structure/functions/pagination/README.md +527 -0
  50. package/src/modules/structure/functions/pagination/pagination.ts +493 -0
  51. package/src/modules/structure/functions/site-settings/README.md +395 -0
  52. package/src/modules/structure/functions/site-settings/site-settings.ts +158 -0
  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/pagination/README.md +0 -428
  82. package/autoInit/accessibility/functions/pagination/pagination.js +0 -359
  83. package/autoInit/accessibility/functions/text-synchronization/README.md +0 -62
  84. package/autoInit/accessibility/functions/text-synchronization/text-synchronization.js +0 -101
  85. package/autoInit/accessibility/functions/year-replacement/year-replacement.js +0 -43
  86. package/autoInit/button/README.md +0 -122
  87. package/autoInit/button/button.js +0 -51
  88. package/autoInit/counter/README.md +0 -274
  89. package/autoInit/counter/counter.js +0 -185
  90. package/autoInit/form/README.md +0 -338
  91. package/autoInit/form/form.js +0 -374
  92. package/autoInit/navbar/README.md +0 -366
  93. package/autoInit/navbar/navbar.js +0 -786
  94. package/autoInit/site-settings/README.md +0 -218
  95. package/autoInit/site-settings/site-settings.js +0 -134
  96. package/autoInit/transition/transition.js +0 -116
  97. package/index.js +0 -305
  98. package/utils/before-after/README.md +0 -520
  99. package/utils/before-after/before-after.js +0 -653
  100. package/utils/css-animations/buttons/main/bgbasic/btn-main-bgbasic.html +0 -10
  101. package/utils/css-animations/buttons/main/bgfill/btn-main-bgfill.html +0 -29
  102. package/utils/css-animations/buttons/navbar/bgbasic/navbar-main-bgbasic.html +0 -17
  103. package/utils/css-animations/buttons/navbar/bgbasic/navbar-menu-bgbasic.html +0 -16
  104. package/utils/css-animations/buttons/navbar/bgfill/navbar-main-bgfill.html +0 -46
  105. package/utils/css-animations/buttons/navbar/bgfill/navbar-menu-bgfill.html +0 -39
  106. package/utils/css-animations/buttons/navbar/color/navbar-announce-color.html +0 -5
  107. package/utils/css-animations/buttons/navbar/color/navbar-main-color.html +0 -7
  108. package/utils/css-animations/buttons/navbar/color/navbar-menu-color.html +0 -7
  109. package/utils/css-animations/buttons/navbar/double-slide/navbar-announce-double-slide.html +0 -40
  110. package/utils/css-animations/buttons/navbar/double-slide/navbar-main-double-slide.html +0 -77
  111. package/utils/css-animations/buttons/navbar/scale/navbar-announce-scale.html +0 -6
  112. package/utils/css-animations/buttons/navbar/scale/navbar-main-scale.html +0 -9
  113. package/utils/css-animations/buttons/navbar/scale/navbar-menu-scale.html +0 -8
  114. package/utils/css-animations/buttons/navbar/underline/navbar-announce-underline.html +0 -32
  115. package/utils/css-animations/buttons/navbar/underline/navbar-main-underline.html +0 -56
  116. package/utils/css-animations/buttons/text/color/text-footer-color.html +0 -5
  117. package/utils/css-animations/buttons/text/color/text-main-color.html +0 -5
  118. package/utils/css-animations/buttons/text/double-slide/text-main-double-slide.html +0 -56
  119. package/utils/css-animations/buttons/text/scale/text-footer-scale.html +0 -6
  120. package/utils/css-animations/buttons/text/scale/text-main-scale.html +0 -6
  121. package/utils/css-animations/buttons/text/underline/text-footer-underline.html +0 -45
  122. package/utils/css-animations/buttons/text/underline/text-main-underline.html +0 -58
  123. package/utils/css-animations/cards/card-clickable.html +0 -11
  124. package/utils/css-animations/defaults.html +0 -69
@@ -1,338 +0,0 @@
1
- # **Form System Documentation**
2
-
3
- ## **Overview**
4
-
5
- The form system provides two main features:
6
- 1. **Honeypot spam prevention** - Blocks bot submissions using a hidden field
7
- 2. **Custom select component** - Fully accessible, styled select dropdowns that sync with native `<select>` elements for form submission
8
-
9
- ---
10
-
11
- ## **Honeypot Spam Prevention**
12
-
13
- ### **Required Element**
14
-
15
- **Honeypot Field** *(hidden field in your form)*
16
-
17
- * data-hs-form="form-handler"
18
- * Should be hidden with CSS (bots will auto-fill it, humans won't see it)
19
-
20
- ### **What It Does**
21
-
22
- 1. **Listens for form submissions** - Captures all form submit events
23
- 2. **Checks honeypot field** - If the hidden field has a value, it's likely a bot
24
- 3. **Blocks submission** - Prevents form from submitting if honeypot is filled
25
-
26
- ### **Example**
27
-
28
- ```html
29
- <form>
30
- <input type="text" name="name" placeholder="Your Name">
31
- <input type="email" name="email" placeholder="Your Email">
32
-
33
- <!-- Honeypot field (hidden with CSS) -->
34
- <input type="text" name="website" data-hs-form="form-handler" style="display: none;">
35
-
36
- <button type="submit">Submit</button>
37
- </form>
38
- ```
39
-
40
- **Result:** Legitimate users never see the honeypot field. Bots auto-fill all fields, triggering the spam prevention.
41
-
42
- ---
43
-
44
- ## **Custom Select Component**
45
-
46
- ### **Required Elements**
47
-
48
- **Select Wrapper** *(main container)*
49
-
50
- * data-hs-form="select"
51
-
52
- **Native Select** *(hidden select element for form submission)*
53
-
54
- * Standard `<select>` element with `<option>` children
55
- * Should have `name` attribute for form submission
56
- * Will be hidden but remains functional
57
-
58
- **Toggle Button** *(visual button to open/close dropdown)*
59
-
60
- * `<button>` element or element with `role="button"`
61
- * Should contain a `<span>` element for displaying selected text
62
- * Webflow IX must add/remove `.is-active` class on this button
63
-
64
- **Custom List** *(dropdown container)*
65
-
66
- * data-hs-form="select-list"
67
- * First child is used as template for generating options
68
- * Webflow IX must show/hide this when button is clicked
69
-
70
- **Template Element** *(first child of select-list)*
71
-
72
- * Should contain a `<span>` element for option text
73
- * Will be cloned for each `<option>` in the native select
74
- * Original template is removed after cloning
75
-
76
- **Label** *(optional but recommended)*
77
-
78
- * Standard `<label>` element
79
- * Can be inside wrapper or reference select by `for` attribute
80
-
81
- **Typical element layout:**
82
-
83
- 1. Select Wrapper (data-hs-form="select")
84
- 1. Label (optional)
85
- 1. Text: "Choose an option"
86
- 2. Native Select (`<select>`)
87
- 1. Option 1 (`<option value="value1">`)
88
- 1. Text: "Option 1"
89
- 2. Option 2 (`<option value="value2">`)
90
- 1. Text: "Option 2"
91
- 3. Toggle Button (`<button>`)
92
- 1. Button Text (`<span>`)
93
- 1. Text: "Choose an option"
94
- 4. Custom List (data-hs-form="select-list")
95
- 1. Template Element
96
- 1. Text Span (`<span>`)
97
- 1. Text: "Placeholder"
98
-
99
- ### **What It Does**
100
-
101
- 1. **Unwraps Webflow slots** - Removes any `<div>` wrappers inside `<select>` elements
102
- 2. **Clones template** - Uses first child of select-list as template
103
- 3. **Generates options** - Creates custom option elements from native `<option>` elements
104
- 4. **Syncs state** - Keeps custom UI and native select in sync
105
- 5. **ARIA management** - Automatically updates accessibility attributes
106
- 6. **Keyboard navigation** - Full arrow key, Tab, Enter, Space, Escape support
107
- 7. **Focus management** - Handles focus return, tabbing out, and keyboard navigation
108
-
109
- ### **When It Runs**
110
-
111
- * Runs once on page load (or DOMContentLoaded)
112
- * Available for manual re-initialization via `window.initCustomSelects()`
113
- * Runs again on Barba.js page transitions (via `reinitialize()`)
114
-
115
- ### **Interaction Requirements (IX3)**
116
-
117
- **CRITICAL:** Webflow interactions MUST add/remove `.is-active` class on the toggle button.
118
-
119
- **Triggers:**
120
-
121
- 1. **Click trigger** on `<button>` element
122
- 1. `Each click: Toggle play/reverse`
123
-
124
- **Timeline:**
125
-
126
- 1. 0s: Toggle `.is-active` class on the button element
127
- 2. .06s: Rest of animation (fade in, slide, etc.)
128
-
129
- ---
130
-
131
- ## **Accessibility**
132
-
133
- ### **Automatic ARIA Setup**
134
-
135
- For each select, the system automatically:
136
-
137
- 1. **Button (combobox)**
138
- - `role="combobox"`
139
- - `aria-haspopup="listbox"`
140
- - `aria-expanded="true|false"` (synced with `.is-active` class)
141
- - `aria-controls="[listbox-id]"`
142
- - `aria-labelledby="[label-id]"` (if label exists)
143
- - `aria-activedescendant="[option-id]"` (when navigating with arrows)
144
-
145
- 2. **Select List**
146
- - `role="listbox"`
147
- - `tabindex="-1"` (not directly tabbable)
148
-
149
- 3. **Options**
150
- - `role="option"`
151
- - `aria-selected="true|false"`
152
- - `tabindex="0"` when dropdown open, `tabindex="-1"` when closed
153
- - Unique `id` for aria-activedescendant
154
-
155
- 4. **Label**
156
- - Connected to native select with `for` attribute
157
- - Linked to button with `aria-labelledby`
158
-
159
- ### **Focus Management**
160
-
161
- 1. **Opening dropdown** - All options become tabbable
162
- 2. **Closing dropdown** - Focus returns to button if inside list
163
- 3. **Tabbing out** - Automatically closes dropdown
164
- 4. **Arrow navigation** - Focuses options with visual `.focused` class
165
-
166
- ---
167
-
168
- ## **Usage Examples**
169
-
170
- ### **Basic Select**
171
-
172
- ```html
173
- <div data-hs-form="select">
174
- <label for="fruit-select">Choose a fruit</label>
175
-
176
- <select name="fruit" id="fruit-select">
177
- <option value="">Choose an option</option>
178
- <option value="apple">Apple</option>
179
- <option value="banana">Banana</option>
180
- <option value="orange">Orange</option>
181
- </select>
182
-
183
- <button>
184
- <span>Choose an option</span>
185
- </button>
186
-
187
- <div data-hs-form="select-list">
188
- <div>
189
- <span>Template</span>
190
- </div>
191
- </div>
192
- </div>
193
- ```
194
-
195
- **Result:** Creates 4 custom options (including empty "Choose an option"). Native select stays synced for form submission.
196
-
197
- ### **Pre-selected Option**
198
-
199
- ```html
200
- <select name="size">
201
- <option value="small">Small</option>
202
- <option value="medium" selected>Medium</option>
203
- <option value="large">Large</option>
204
- </select>
205
- ```
206
-
207
- **Result:** Button text automatically shows "Medium" on page load. Corresponding custom option has `aria-selected="true"`.
208
-
209
- ### **Options with Special Characters**
210
-
211
- ```html
212
- <option value='product"deluxe"'>Deluxe Edition</option>
213
- <option value="user's choice">Custom Option</option>
214
- ```
215
-
216
- **Result:** Works correctly - uses safe value comparison instead of selector injection.
217
-
218
- ---
219
-
220
- ## **Keyboard Navigation**
221
-
222
- ### **When Button is Focused**
223
-
224
- 1. **Space** or **Enter** � Open/close dropdown
225
- 2. **ArrowDown** � Open dropdown (or focus first option if already open)
226
- 3. **ArrowUp** � Focus last option (if dropdown is open)
227
- 4. **Escape** � Close dropdown (if open)
228
-
229
- ### **When Option is Focused**
230
-
231
- 1. **ArrowDown** � Focus next option
232
- 2. **ArrowUp** � Focus previous option (or close and focus button if on first option)
233
- 3. **Enter** or **Space** � Select option and close dropdown
234
- 4. **Escape** � Close dropdown and return focus to button
235
- 5. **Tab** � Navigate through options, tab out of last option closes dropdown
236
-
237
- ---
238
-
239
- ## **State Synchronization**
240
-
241
- ### **Native Select � Custom UI**
242
-
243
- When native select value changes (programmatically or via Webflow):
244
- 1. Finds matching custom option by value
245
- 2. Updates button text
246
- 3. Updates `aria-selected` on all options
247
-
248
- ### **Custom UI � Native Select**
249
-
250
- When user selects custom option:
251
- 1. Updates native select's `value`
252
- 2. Dispatches `change` event on native select
253
- 3. Updates button text
254
- 4. Updates `aria-selected`
255
- 5. Closes dropdown
256
-
257
- **Both stay in sync at all times.**
258
-
259
- ---
260
-
261
- ## **Key Attributes Summary**
262
-
263
- | Attribute | Purpose | Required On |
264
- | ----- | ----- | ----- |
265
- | `data-hs-form="select"` | Select wrapper | Wrapper div |
266
- | `data-hs-form="select-list"` | Custom options container | List div |
267
- | `data-hs-form="form-handler"` | Honeypot spam field | Hidden input (optional) |
268
-
269
- ---
270
-
271
- ## **API**
272
-
273
- ### **Manual Re-initialization**
274
-
275
- ```javascript
276
- window.initCustomSelects();
277
- ```
278
-
279
- Re-scans page for new selects and initializes them. Useful for dynamically added content.
280
-
281
- ---
282
-
283
- ## **Notes**
284
-
285
- 1. **Native select required** - Must have actual `<select>` element for form submission
286
- 2. **Button must have span** - `<span>` child is required for text updates
287
- 3. **Template is removed** - First child of select-list is cloned then deleted
288
- 4. **Webflow IX required** - Must toggle `.is-active` class on button
289
- 5. **Barba.js compatible** - Automatically cleans up on destroy/reinitialize
290
- 6. **Silent failures** - Missing required elements cause function to return early (no errors)
291
- 7. **Empty values supported** - Options with `value=""` work correctly
292
- 8. **Special characters safe** - Handles quotes and special characters in option values
293
-
294
- ---
295
-
296
- ## **Common Issues**
297
-
298
- **Dropdown not opening:**
299
-
300
- 1. Verify Webflow IX adds `.is-active` class to button (not wrapper or list)
301
- 2. Check that button exists and is `<button>` or has `role="button"`
302
- 3. Ensure select-list exists with `data-hs-form="select-list"`
303
-
304
- **Button text not updating:**
305
-
306
- 1. Verify button has a `<span>` child element
307
- 2. Check that template element has a `<span>` child
308
- 3. Ensure option values match between native and custom
309
-
310
- **Options not clickable:**
311
-
312
- 1. Check that template element exists as first child of select-list
313
- 2. Verify template has `<span>` for text
314
- 3. Ensure options are being generated (inspect DOM)
315
-
316
- **Can't tab to options:**
317
-
318
- 1. Verify dropdown is open (`.is-active` class on button)
319
- 2. Check Webflow IX animation shows the select-list
320
- 3. Ensure `display: none` is removed when open
321
-
322
- **Form not submitting selected value:**
323
-
324
- 1. Native `<select>` must have `name` attribute
325
- 2. Check that native select's value is updating (inspect DOM or use console)
326
- 3. Verify native select is inside `<form>` element
327
-
328
- **Dropdown doesn't close when tabbing out:**
329
-
330
- 1. Ensure wrapper element properly contains button and list
331
- 2. Check that `data-hs-form="select"` is on wrapper
332
- 3. Verify Webflow IX removes `.is-active` when clicking button again
333
-
334
- **Spam prevention not working:**
335
-
336
- 1. Verify honeypot field has `data-hs-form="form-handler"`
337
- 2. Ensure field is hidden with CSS (not removed from DOM)
338
- 3. Check that field is inside the `<form>` element
@@ -1,374 +0,0 @@
1
- export function init() {
2
- // Centralized cleanup tracking
3
- const cleanup = {
4
- observers: [],
5
- handlers: [],
6
- honeypotHandler: null
7
- };
8
-
9
- const addObserver = (observer) => cleanup.observers.push(observer);
10
- const addHandler = (element, event, handler, options) => {
11
- element.addEventListener(event, handler, options);
12
- cleanup.handlers.push({ element, event, handler, options });
13
- };
14
-
15
- // Honeypot spam prevention
16
- const honeypotHandler = (e) => {
17
- const form = e.target;
18
- if (form.tagName !== 'FORM') return;
19
-
20
- const honeypot = form.querySelector('[data-hs-form="form-handler"]');
21
- if (honeypot && honeypot.value) {
22
- // Honeypot filled - likely a bot
23
- e.preventDefault();
24
- e.stopPropagation();
25
- e.stopImmediatePropagation();
26
- return false;
27
- }
28
- };
29
-
30
- cleanup.honeypotHandler = honeypotHandler;
31
- document.addEventListener('submit', honeypotHandler, true);
32
-
33
- // Simple Custom Select Component for Webflow
34
- (function() {
35
- 'use strict';
36
-
37
- // Initialize all custom selects on the page
38
- function initCustomSelects() {
39
- // Unwrap any divs inside select elements (Webflow component slots)
40
- document.querySelectorAll('select > div').forEach(div => {
41
- const select = div.parentElement;
42
- while (div.firstChild) {
43
- select.appendChild(div.firstChild);
44
- }
45
- div.remove();
46
- });
47
-
48
- const selectWrappers = document.querySelectorAll('[data-hs-form="select"]');
49
-
50
- selectWrappers.forEach(wrapper => {
51
- initSingleSelect(wrapper, addHandler, addObserver);
52
- });
53
- }
54
-
55
- // Initialize a single custom select
56
- function initSingleSelect(wrapper, addHandler, addObserver) {
57
- // Find all required elements
58
- const realSelect = wrapper.querySelector('select');
59
- if (!realSelect) return;
60
-
61
- const selectName = realSelect.getAttribute('name') || 'custom-select';
62
- const customList = wrapper.querySelector('[data-hs-form="select-list"]');
63
- const button = wrapper.querySelector('button') || wrapper.querySelector('[role="button"]');
64
-
65
- if (!customList || !button) return;
66
-
67
- // Get and clone the option template
68
- const optionTemplate = customList.firstElementChild;
69
- if (!optionTemplate) return;
70
-
71
- const templateClone = optionTemplate.cloneNode(true);
72
- optionTemplate.remove();
73
-
74
- // Build options from real select
75
- const realOptions = realSelect.querySelectorAll('option');
76
- realOptions.forEach((option, index) => {
77
- const optionElement = templateClone.cloneNode(true);
78
- const textSpan = optionElement.querySelector('span');
79
-
80
- if (textSpan) {
81
- textSpan.textContent = option.textContent;
82
- }
83
-
84
- // Add attributes
85
- optionElement.setAttribute('data-value', option.value);
86
- optionElement.setAttribute('role', 'option');
87
- optionElement.setAttribute('id', `${selectName}-option-${index}`);
88
- optionElement.setAttribute('tabindex', '-1');
89
-
90
- // Set selected state if this option is selected
91
- if (option.selected) {
92
- optionElement.setAttribute('aria-selected', 'true');
93
- // Update button text
94
- const buttonText = button.querySelector('span') || button;
95
- if (buttonText.tagName === 'SPAN') {
96
- buttonText.textContent = option.textContent;
97
- }
98
- } else {
99
- optionElement.setAttribute('aria-selected', 'false');
100
- }
101
-
102
- customList.appendChild(optionElement);
103
- });
104
-
105
- // Add ARIA attributes
106
- customList.setAttribute('role', 'listbox');
107
- customList.setAttribute('id', `${selectName}-listbox`);
108
- customList.setAttribute('tabindex', '-1');
109
-
110
- button.setAttribute('role', 'combobox');
111
- button.setAttribute('aria-haspopup', 'listbox');
112
- button.setAttribute('aria-controls', `${selectName}-listbox`);
113
- button.setAttribute('aria-expanded', 'false');
114
- button.setAttribute('id', `${selectName}-button`);
115
-
116
- // Find and connect label if exists
117
- const label = wrapper.querySelector('label') ||
118
- document.querySelector(`label[for="${realSelect.id}"]`);
119
- if (label) {
120
- const labelId = label.id || `${selectName}-label`;
121
- label.id = labelId;
122
- // Ensure real select has proper ID for label connection
123
- if (!realSelect.id) {
124
- realSelect.id = `${selectName}-select`;
125
- }
126
- label.setAttribute('for', realSelect.id);
127
- button.setAttribute('aria-labelledby', labelId);
128
- }
129
-
130
- // Track state
131
- let isOpen = false;
132
-
133
- // Update expanded state
134
- function updateExpandedState(expanded) {
135
- isOpen = expanded;
136
- button.setAttribute('aria-expanded', expanded.toString());
137
- }
138
-
139
- // Focus option by index
140
- function focusOption(index) {
141
- const options = customList.querySelectorAll('[role="option"]');
142
- if (index < 0 || index >= options.length) return;
143
-
144
- // Remove previous focus
145
- options.forEach(opt => {
146
- opt.classList.remove('focused');
147
- opt.setAttribute('tabindex', '-1');
148
- });
149
-
150
- // Add new focus
151
- options[index].classList.add('focused');
152
- options[index].setAttribute('tabindex', '0');
153
- options[index].focus();
154
- button.setAttribute('aria-activedescendant', options[index].id);
155
- }
156
-
157
- // Select option
158
- function selectOption(optionElement) {
159
- const value = optionElement.getAttribute('data-value') || '';
160
- const text = optionElement.querySelector('span')?.textContent || optionElement.textContent;
161
-
162
- // Update real select
163
- realSelect.value = value;
164
- realSelect.dispatchEvent(new Event('change', { bubbles: true }));
165
-
166
- // Update button text
167
- const buttonText = button.querySelector('span') || button;
168
- if (buttonText.tagName === 'SPAN') {
169
- buttonText.textContent = text;
170
- }
171
-
172
- // Update aria-selected
173
- customList.querySelectorAll('[role="option"]').forEach(opt => {
174
- opt.setAttribute('aria-selected', 'false');
175
- });
176
- optionElement.setAttribute('aria-selected', 'true');
177
-
178
- // Close dropdown by clicking button
179
- button.click();
180
- }
181
-
182
- // Button keyboard events
183
- const buttonKeydownHandler = (e) => {
184
- switch(e.key) {
185
- case ' ':
186
- case 'Enter':
187
- e.preventDefault();
188
- button.click();
189
- break;
190
-
191
- case 'ArrowDown':
192
- e.preventDefault();
193
- if (!isOpen) {
194
- button.click();
195
- } else {
196
- focusOption(0);
197
- }
198
- break;
199
-
200
- case 'ArrowUp':
201
- e.preventDefault();
202
- if (isOpen) {
203
- const options = customList.querySelectorAll('[role="option"]');
204
- focusOption(options.length - 1);
205
- }
206
- break;
207
-
208
- case 'Escape':
209
- if (isOpen) {
210
- e.preventDefault();
211
- button.click();
212
- }
213
- break;
214
- }
215
- };
216
- addHandler(button, 'keydown', buttonKeydownHandler);
217
-
218
- // Option keyboard events (delegated)
219
- const listKeydownHandler = (e) => {
220
- const option = e.target.closest('[role="option"]');
221
- if (!option) return;
222
-
223
- const options = Array.from(customList.querySelectorAll('[role="option"]'));
224
- const currentIdx = options.indexOf(option);
225
-
226
- switch(e.key) {
227
- case 'ArrowDown':
228
- e.preventDefault();
229
- if (currentIdx < options.length - 1) {
230
- focusOption(currentIdx + 1);
231
- }
232
- break;
233
-
234
- case 'ArrowUp':
235
- e.preventDefault();
236
- if (currentIdx === 0) {
237
- button.click();
238
- button.focus();
239
- } else {
240
- focusOption(currentIdx - 1);
241
- }
242
- break;
243
-
244
- case 'Enter':
245
- case ' ':
246
- e.preventDefault();
247
- selectOption(option);
248
- break;
249
-
250
- case 'Escape':
251
- e.preventDefault();
252
- button.click();
253
- button.focus();
254
- break;
255
- }
256
- };
257
- addHandler(customList, 'keydown', listKeydownHandler);
258
-
259
- // Option click events
260
- const listClickHandler = (e) => {
261
- const option = e.target.closest('[role="option"]');
262
- if (option) {
263
- selectOption(option);
264
- }
265
- };
266
- addHandler(customList, 'click', listClickHandler);
267
-
268
- // Handle tabbing out of select list
269
- const listFocusoutHandler = (e) => {
270
- // Check if focus is moving outside the wrapper (or to browser chrome)
271
- if ((!e.relatedTarget || !wrapper.contains(e.relatedTarget)) && button.classList.contains('is-active')) {
272
- button.click(); // Close the dropdown
273
- }
274
- };
275
- addHandler(customList, 'focusout', listFocusoutHandler);
276
-
277
- // Track open/close state via .is-active class on button
278
- const observer = new MutationObserver(() => {
279
- const isActive = button.classList.contains('is-active');
280
-
281
- updateExpandedState(isActive);
282
-
283
- if (isActive) {
284
- // Make all options tabbable when opened
285
- const options = customList.querySelectorAll('[role="option"]');
286
- options.forEach(opt => {
287
- opt.setAttribute('tabindex', '0');
288
- });
289
- } else {
290
- // Return focus to button BEFORE aria changes if it was in the list
291
- if (document.activeElement?.closest('[data-hs-form="select-list"]') === customList) {
292
- button.focus();
293
- }
294
-
295
- // Reset on close
296
- button.removeAttribute('aria-activedescendant');
297
- const options = customList.querySelectorAll('[role="option"]');
298
- options.forEach(opt => {
299
- opt.setAttribute('tabindex', '-1');
300
- });
301
- }
302
- });
303
-
304
- // Observe the button for .is-active class changes
305
- observer.observe(button, {
306
- attributes: true,
307
- attributeFilter: ['class']
308
- });
309
- addObserver(observer);
310
-
311
- // Sync with real select changes
312
- const selectChangeHandler = () => {
313
- const selectedOption = realSelect.options[realSelect.selectedIndex];
314
- if (selectedOption) {
315
- const options = customList.querySelectorAll('[role="option"]');
316
- const customOption = Array.from(options).find(opt =>
317
- opt.getAttribute('data-value') === selectedOption.value
318
- );
319
- if (customOption) {
320
- // Update button text
321
- const text = customOption.querySelector('span')?.textContent || customOption.textContent;
322
- const buttonText = button.querySelector('span') || button;
323
- if (buttonText.tagName === 'SPAN') {
324
- buttonText.textContent = text;
325
- }
326
-
327
- // Update aria-selected
328
- options.forEach(opt => {
329
- opt.setAttribute('aria-selected', 'false');
330
- });
331
- customOption.setAttribute('aria-selected', 'true');
332
- }
333
- }
334
- };
335
- addHandler(realSelect, 'change', selectChangeHandler);
336
- }
337
-
338
- // Initialize on DOM ready
339
- if (document.readyState === 'loading') {
340
- document.addEventListener('DOMContentLoaded', initCustomSelects);
341
- } else {
342
- initCustomSelects();
343
- }
344
-
345
- // Reinitialize for dynamic content
346
- window.initCustomSelects = initCustomSelects;
347
- })();
348
-
349
- return {
350
- result: "form initialized",
351
- destroy: () => {
352
- // Remove honeypot handler
353
- if (cleanup.honeypotHandler) {
354
- document.removeEventListener('submit', cleanup.honeypotHandler, true);
355
- cleanup.honeypotHandler = null;
356
- }
357
-
358
- // Disconnect all observers
359
- cleanup.observers.forEach(obs => obs.disconnect());
360
- cleanup.observers.length = 0;
361
-
362
- // Remove all event listeners
363
- cleanup.handlers.forEach(({ element, event, handler, options }) => {
364
- element.removeEventListener(event, handler, options);
365
- });
366
- cleanup.handlers.length = 0;
367
-
368
- // Remove window API
369
- if (window.initCustomSelects) {
370
- delete window.initCustomSelects;
371
- }
372
- }
373
- };
374
- }