@hortonstudio/main 1.9.6 → 1.9.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/autoInit/accessibility/README.md +94 -0
  2. package/autoInit/accessibility/accessibility.js +52 -0
  3. package/autoInit/accessibility/functions/blog-remover/README.md +61 -0
  4. package/autoInit/accessibility/functions/blog-remover/blog-remover.js +31 -0
  5. package/autoInit/accessibility/functions/click-forwarding/README.md +60 -0
  6. package/autoInit/accessibility/functions/click-forwarding/click-forwarding.js +82 -0
  7. package/autoInit/accessibility/functions/dropdown/README.md +212 -0
  8. package/autoInit/accessibility/functions/dropdown/dropdown.js +167 -0
  9. package/autoInit/accessibility/functions/list-accessibility/README.md +56 -0
  10. package/autoInit/accessibility/functions/list-accessibility/list-accessibility.js +23 -0
  11. package/autoInit/accessibility/functions/text-synchronization/README.md +62 -0
  12. package/autoInit/accessibility/functions/text-synchronization/text-synchronization.js +101 -0
  13. package/autoInit/accessibility/functions/toc/README.md +79 -0
  14. package/autoInit/accessibility/functions/toc/toc.js +191 -0
  15. package/autoInit/accessibility/functions/year-replacement/README.md +54 -0
  16. package/autoInit/accessibility/functions/year-replacement/year-replacement.js +43 -0
  17. package/autoInit/button/README.md +122 -0
  18. package/autoInit/counter/README.md +274 -0
  19. package/autoInit/{counter.js → counter/counter.js} +20 -5
  20. package/autoInit/form/README.md +338 -0
  21. package/autoInit/{form.js → form/form.js} +44 -29
  22. package/autoInit/navbar/README.md +366 -0
  23. package/autoInit/site-settings/README.md +218 -0
  24. package/autoInit/smooth-scroll/README.md +386 -0
  25. package/autoInit/transition/README.md +301 -0
  26. package/autoInit/{transition.js → transition/transition.js} +13 -2
  27. package/index.js +7 -7
  28. package/package.json +1 -1
  29. package/autoInit/accessibility.js +0 -786
  30. /package/autoInit/{button.js → button/button.js} +0 -0
  31. /package/autoInit/{site-settings.js → site-settings/site-settings.js} +0 -0
  32. /package/autoInit/{smooth-scroll.js → smooth-scroll/smooth-scroll.js} +0 -0
@@ -0,0 +1,167 @@
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
+ }
@@ -0,0 +1,56 @@
1
+ # **List Accessibility**
2
+
3
+ ## **Overview**
4
+
5
+ Adds proper ARIA roles to custom-styled lists that aren't using semantic HTML list elements.
6
+
7
+ ---
8
+
9
+ ## **Required Elements**
10
+
11
+ **List Container**
12
+ * data-hs-a11y="list"
13
+
14
+ **List Items**
15
+ * data-hs-a11y="list-item"
16
+
17
+ ---
18
+
19
+ ## **What It Does**
20
+
21
+ 1. Finds all `[data-hs-a11y="list"]` elements
22
+ 2. Adds `role="list"` to each
23
+ 3. Finds all `[data-hs-a11y="list-item"]` elements
24
+ 4. Adds `role="listitem"` to each
25
+
26
+ ---
27
+
28
+ ## **Usage Example**
29
+
30
+ ```html
31
+ <!-- Custom styled list -->
32
+ <div data-hs-a11y="list">
33
+ <div data-hs-a11y="list-item">Item 1</div>
34
+ <div data-hs-a11y="list-item">Item 2</div>
35
+ <div data-hs-a11y="list-item">Item 3</div>
36
+ </div>
37
+ ```
38
+
39
+ **Result:** Screen readers announce as a list with 3 items.
40
+
41
+ ---
42
+
43
+ ## **Key Attributes**
44
+
45
+ | Attribute | Purpose |
46
+ | ----- | ----- |
47
+ | `data-hs-a11y="list"` | List container |
48
+ | `data-hs-a11y="list-item"` | Individual list items |
49
+
50
+ ---
51
+
52
+ ## **Notes**
53
+
54
+ * Use when not using `<ul>/<ol>/<li>` semantic HTML
55
+ * ARIA roles remain after initialization
56
+ * No cleanup needed
@@ -0,0 +1,23 @@
1
+ export function init() {
2
+ function setupListAccessibility() {
3
+ const listElements = document.querySelectorAll('[data-hs-a11y="list"]');
4
+ const listItemElements = document.querySelectorAll('[data-hs-a11y="list-item"]');
5
+
6
+ listElements.forEach(element => {
7
+ element.setAttribute('role', 'list');
8
+ });
9
+
10
+ listItemElements.forEach(element => {
11
+ element.setAttribute('role', 'listitem');
12
+ });
13
+ }
14
+
15
+ setupListAccessibility();
16
+
17
+ return {
18
+ result: "list-accessibility initialized",
19
+ destroy: () => {
20
+ // No cleanup needed - ARIA attributes remain on elements
21
+ }
22
+ };
23
+ }
@@ -0,0 +1,62 @@
1
+ # **Text Synchronization**
2
+
3
+ ## **Overview**
4
+
5
+ Automatically synchronizes text content and aria-labels from an original element to multiple match elements. Updates in real-time when original changes.
6
+
7
+ ---
8
+
9
+ ## **Required Elements**
10
+
11
+ **Original Element** *(source of truth)*
12
+ * data-hs-a11y="match-text-[identifier], original"
13
+
14
+ **Match Elements** *(copies)*
15
+ * data-hs-a11y="match-text-[identifier], match"
16
+
17
+ **Note:** `[identifier]` must match between original and matches.
18
+
19
+ ---
20
+
21
+ ## **What It Does**
22
+
23
+ 1. Finds all original elements
24
+ 2. Matches them to corresponding match elements by identifier
25
+ 3. Copies text content from original → matches
26
+ 4. Syncs `aria-label` attribute
27
+ 5. Watches for changes with MutationObserver
28
+ 6. Updates matches automatically when original changes
29
+
30
+ ---
31
+
32
+ ## **Usage Example**
33
+
34
+ ```html
35
+ <!-- Original -->
36
+ <h1 data-hs-a11y="match-text-title, original">
37
+ Welcome to Our Site
38
+ </h1>
39
+
40
+ <!-- Matches (auto-updated) -->
41
+ <span data-hs-a11y="match-text-title, match"></span>
42
+ <div data-hs-a11y="match-text-title, match"></div>
43
+ ```
44
+
45
+ **Result:** Both match elements display "Welcome to Our Site". If original changes, matches update automatically.
46
+
47
+ ---
48
+
49
+ ## **Key Attributes**
50
+
51
+ | Attribute | Purpose |
52
+ | ----- | ----- |
53
+ | `data-hs-a11y="match-text-[id], original"` | Source element |
54
+ | `data-hs-a11y="match-text-[id], match"` | Target elements |
55
+
56
+ ---
57
+
58
+ ## **Notes**
59
+
60
+ * Real-time synchronization via MutationObserver
61
+ * Syncs both text and aria-label
62
+ * Observers cleaned up on destroy
@@ -0,0 +1,101 @@
1
+ export function init() {
2
+ const cleanup = {
3
+ observers: []
4
+ };
5
+
6
+ const addObserver = (observer) => cleanup.observers.push(observer);
7
+
8
+ function setupTextSynchronization(addObserver) {
9
+ // Find all original elements (source of truth)
10
+ const originalElements = document.querySelectorAll('[data-hs-a11y*="original"]');
11
+
12
+ originalElements.forEach(originalElement => {
13
+ const attribute = originalElement.getAttribute('data-hs-a11y');
14
+
15
+ // Parse the attribute: "match-text-[identifier], original"
16
+ const parts = attribute.split(',').map(part => part.trim());
17
+
18
+ // Find the part with match-text and the part with original
19
+ const textPart = parts.find(part => part.startsWith('match-text-'));
20
+ const rolePart = parts.find(part => part === 'original');
21
+
22
+ if (!textPart || !rolePart) {
23
+ return;
24
+ }
25
+
26
+ // Extract identifier from "match-text-[identifier]"
27
+ const identifier = textPart.replace('match-text-', '').trim();
28
+
29
+ // Find all corresponding match elements
30
+ const matchSelector = `[data-hs-a11y*="match-text-${identifier}"][data-hs-a11y*="match"]`;
31
+ const matchElements = document.querySelectorAll(matchSelector);
32
+
33
+ if (matchElements.length === 0) {
34
+ return;
35
+ }
36
+
37
+ // Function to synchronize text and aria-label
38
+ function synchronizeContent() {
39
+ const originalText = originalElement.textContent;
40
+ const originalAriaLabel = originalElement.getAttribute('aria-label');
41
+
42
+ matchElements.forEach(matchElement => {
43
+ // Copy text content
44
+ matchElement.textContent = originalText;
45
+
46
+ // Synchronize aria-label
47
+ if (originalAriaLabel) {
48
+ // If original has aria-label, copy it to match
49
+ matchElement.setAttribute('aria-label', originalAriaLabel);
50
+ } else {
51
+ // If original has no aria-label, remove it from match (keep in sync)
52
+ if (matchElement.hasAttribute('aria-label')) {
53
+ matchElement.removeAttribute('aria-label');
54
+ }
55
+ }
56
+ });
57
+ }
58
+
59
+ // Initial synchronization
60
+ synchronizeContent();
61
+
62
+ // Set up MutationObserver to watch for changes
63
+ const observer = new MutationObserver((mutations) => {
64
+ let shouldSync = false;
65
+
66
+ mutations.forEach((mutation) => {
67
+ if (mutation.type === 'childList' ||
68
+ mutation.type === 'characterData' ||
69
+ (mutation.type === 'attributes' && mutation.attributeName === 'aria-label')) {
70
+ shouldSync = true;
71
+ }
72
+ });
73
+
74
+ if (shouldSync) {
75
+ synchronizeContent();
76
+ }
77
+ });
78
+
79
+ // Observe text changes and attribute changes
80
+ observer.observe(originalElement, {
81
+ childList: true,
82
+ subtree: true,
83
+ characterData: true,
84
+ attributes: true,
85
+ attributeFilter: ['aria-label']
86
+ });
87
+
88
+ addObserver(observer);
89
+ });
90
+ }
91
+
92
+ setupTextSynchronization(addObserver);
93
+
94
+ return {
95
+ result: "text-synchronization initialized",
96
+ destroy: () => {
97
+ cleanup.observers.forEach(obs => obs.disconnect());
98
+ cleanup.observers.length = 0;
99
+ }
100
+ };
101
+ }
@@ -0,0 +1,79 @@
1
+ # **Table of Contents (TOC)**
2
+
3
+ ## **Overview**
4
+
5
+ Automatically generates a **Table of Contents** from H2 headings in rich text content, with smooth scrolling, focus management, and active state tracking based on scroll position.
6
+
7
+ **This function is specifically designed for creating dynamic TOC navigation from blog posts and long-form content.**
8
+
9
+ ---
10
+
11
+ ## **Required Elements**
12
+
13
+ **Rich Content Area**
14
+ * data-hs-a11y="rich-content"
15
+ * Contains H2 headings to generate TOC from
16
+
17
+ **TOC List**
18
+ * data-hs-a11y="rich-toc"
19
+ * Should contain one template item (first child)
20
+ * Template is cloned for each H2 heading
21
+
22
+ ---
23
+
24
+ ## **What It Does**
25
+
26
+ 1. **Section Creation:** Wraps each H2 and following content in a div with unique ID
27
+ 2. **TOC Generation:** Clones template for each H2, creates numbered links
28
+ 3. **Smooth Scrolling:** Handles clicks with smooth scroll to section
29
+ 4. **Focus Management:** Sets focus on section after scroll
30
+ 5. **Active Tracking:** Uses IntersectionObserver to highlight current section in TOC
31
+ 6. **Scroll Monitoring:** Updates active state on scroll (25% from bottom trigger)
32
+
33
+ ---
34
+
35
+ ## **Usage Example**
36
+
37
+ ```html
38
+ <!-- TOC List with template -->
39
+ <div data-hs-a11y="rich-toc">
40
+ <div>
41
+ <a href="#">Template Link</a>
42
+ </div>
43
+ </div>
44
+
45
+ <!-- Rich Content -->
46
+ <div data-hs-a11y="rich-content">
47
+ <h2>Introduction</h2>
48
+ <p>Content here...</p>
49
+
50
+ <h2>Features</h2>
51
+ <p>More content...</p>
52
+
53
+ <h2>Conclusion</h2>
54
+ <p>Final content...</p>
55
+ </div>
56
+ ```
57
+
58
+ **Result:**
59
+ - 3 TOC links: "1. Introduction", "2. Features", "3. Conclusion"
60
+ - Sections wrapped with IDs: `#introduction`, `#features`, `#conclusion`
61
+ - Active link tracked by scroll position
62
+
63
+ ---
64
+
65
+ ## **Key Attributes**
66
+
67
+ | Attribute | Purpose |
68
+ | ----- | ----- |
69
+ | `data-hs-a11y="rich-content"` | Content area with H2s |
70
+ | `data-hs-a11y="rich-toc"` | TOC list container |
71
+
72
+ ---
73
+
74
+ ## **Notes**
75
+
76
+ * IntersectionObserver with 75% bottom margin
77
+ * Scroll handler for smooth active tracking
78
+ * Template item removed after cloning
79
+ * All observers/handlers cleaned up on destroy
@@ -0,0 +1,191 @@
1
+ export function init() {
2
+ const cleanup = {
3
+ observers: [],
4
+ handlers: [],
5
+ scrollTimeout: null
6
+ };
7
+
8
+ const addObserver = (observer) => cleanup.observers.push(observer);
9
+ const addHandler = (element, event, handler, options) => {
10
+ element.addEventListener(event, handler, options);
11
+ cleanup.handlers.push({ element, event, handler, options });
12
+ };
13
+
14
+ function setupRichTextAccessibility(addObserver, addHandler, cleanup) {
15
+ const contentAreas = document.querySelectorAll('[data-hs-a11y="rich-content"]');
16
+ const tocLists = document.querySelectorAll('[data-hs-a11y="rich-toc"]');
17
+
18
+ contentAreas.forEach((contentArea) => {
19
+ // Since there's only 1 content area and 1 TOC list per page, use the first TOC list
20
+ const tocList = tocLists[0];
21
+
22
+ if (!tocList) {
23
+ return;
24
+ }
25
+
26
+ if (tocList.children.length === 0) {
27
+ return;
28
+ }
29
+
30
+ const template = tocList.children[0].cloneNode(true);
31
+ // Remove is-active class from template if it exists
32
+ const templateLink = template.querySelector("a");
33
+ if (templateLink) {
34
+ templateLink.classList.remove('is-active');
35
+ }
36
+
37
+ // Clear all original TOC items
38
+ tocList.innerHTML = "";
39
+
40
+ const h2Headings = contentArea.querySelectorAll("h2");
41
+
42
+
43
+ // Create sections and wrap content
44
+ h2Headings.forEach((heading) => {
45
+ const sectionId = heading.textContent
46
+ .toLowerCase()
47
+ .replace(/[^a-z0-9]+/g, "-")
48
+ .replace(/(^-|-$)/g, "");
49
+
50
+ const section = document.createElement("div");
51
+ section.id = sectionId;
52
+ heading.parentNode.insertBefore(section, heading);
53
+ section.appendChild(heading);
54
+ let nextElement = section.nextElementSibling;
55
+ while (nextElement && nextElement.tagName !== "H2") {
56
+ const elementToMove = nextElement;
57
+ nextElement = nextElement.nextElementSibling;
58
+ section.appendChild(elementToMove);
59
+ }
60
+ });
61
+
62
+ // Create TOC entries
63
+ h2Headings.forEach((heading, index) => {
64
+ const tocItem = template.cloneNode(true);
65
+ const link = tocItem.querySelector("a");
66
+ const sectionId = heading.parentElement.id;
67
+ link.href = "#" + sectionId;
68
+
69
+
70
+ // Bold numbered text
71
+ const number = document.createElement("strong");
72
+ number.textContent = index + 1 + ". ";
73
+
74
+ // Clear the link and add the number + text
75
+ link.innerHTML = "";
76
+ link.appendChild(number);
77
+ link.appendChild(document.createTextNode(heading.textContent));
78
+
79
+ // Add click handler for smooth scrolling
80
+ const clickHandler = (e) => {
81
+ e.preventDefault();
82
+
83
+ const targetSection = document.getElementById(sectionId);
84
+ if (targetSection) {
85
+ targetSection.scrollIntoView({ behavior: "smooth" });
86
+ // Focus on the section for accessibility (will only show outline for keyboard users due to CSS)
87
+ setTimeout(() => {
88
+ targetSection.focus();
89
+ }, 100);
90
+ }
91
+ };
92
+ addHandler(link, "click", clickHandler);
93
+
94
+ // Ensure sections are focusable for keyboard users but use CSS to control focus visibility
95
+ const targetSection = document.getElementById(sectionId);
96
+ if (targetSection) {
97
+ targetSection.setAttribute("tabindex", "-1");
98
+ // Use focus-visible to only show outline for keyboard focus
99
+ targetSection.style.outline = "none";
100
+ targetSection.style.setProperty("outline", "none", "important");
101
+ }
102
+
103
+ // Add item to the TOC list
104
+ tocList.appendChild(tocItem);
105
+ });
106
+
107
+ // Set up IntersectionObserver for active state (Webflow-style)
108
+ const sections = Array.from(h2Headings).map(heading => heading.parentElement);
109
+ const tocLinks = tocList.querySelectorAll('a');
110
+ let currentActive = null;
111
+
112
+ const updateActiveLink = () => {
113
+ const windowHeight = window.innerHeight;
114
+ const trigger = windowHeight * 0.75; // 25% from bottom
115
+
116
+ let newActive = null;
117
+
118
+ // Find the last section whose top is above the trigger point
119
+ for (let i = sections.length - 1; i >= 0; i--) {
120
+ const rect = sections[i].getBoundingClientRect();
121
+ if (rect.top <= trigger) {
122
+ newActive = sections[i].id;
123
+ break;
124
+ }
125
+ }
126
+
127
+ // Only update if active section changed
128
+ if (newActive !== currentActive) {
129
+ currentActive = newActive;
130
+
131
+ // Remove all is-active
132
+ tocLinks.forEach(link => link.classList.remove('is-active'));
133
+
134
+ // Add to current
135
+ if (currentActive) {
136
+ const activeLink = Array.from(tocLinks).find(link =>
137
+ link.getAttribute('href') === `#${currentActive}`
138
+ );
139
+ if (activeLink) {
140
+ activeLink.classList.add('is-active');
141
+ }
142
+ }
143
+ }
144
+ };
145
+
146
+ const observerOptions = {
147
+ rootMargin: '0px 0px -75% 0px',
148
+ threshold: 0
149
+ };
150
+
151
+ const observer = new IntersectionObserver(() => {
152
+ updateActiveLink();
153
+ }, observerOptions);
154
+
155
+ // Observe all sections
156
+ sections.forEach(section => observer.observe(section));
157
+ addObserver(observer);
158
+
159
+ // Also update on scroll for smoother tracking
160
+ const scrollHandler = () => {
161
+ if (cleanup.scrollTimeout) clearTimeout(cleanup.scrollTimeout);
162
+ cleanup.scrollTimeout = setTimeout(updateActiveLink, 50);
163
+ };
164
+ addHandler(window, 'scroll', scrollHandler);
165
+
166
+ });
167
+ }
168
+
169
+ setupRichTextAccessibility(addObserver, addHandler, cleanup);
170
+
171
+ return {
172
+ result: "toc initialized",
173
+ destroy: () => {
174
+ // Disconnect all observers
175
+ cleanup.observers.forEach(obs => obs.disconnect());
176
+ cleanup.observers.length = 0;
177
+
178
+ // Remove all event listeners
179
+ cleanup.handlers.forEach(({ element, event, handler, options }) => {
180
+ element.removeEventListener(event, handler, options);
181
+ });
182
+ cleanup.handlers.length = 0;
183
+
184
+ // Clear scroll timeout
185
+ if (cleanup.scrollTimeout) {
186
+ clearTimeout(cleanup.scrollTimeout);
187
+ cleanup.scrollTimeout = null;
188
+ }
189
+ }
190
+ };
191
+ }