@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.
- package/autoInit/accessibility/README.md +94 -0
- package/autoInit/accessibility/accessibility.js +52 -0
- package/autoInit/accessibility/functions/blog-remover/README.md +61 -0
- package/autoInit/accessibility/functions/blog-remover/blog-remover.js +31 -0
- package/autoInit/accessibility/functions/click-forwarding/README.md +60 -0
- package/autoInit/accessibility/functions/click-forwarding/click-forwarding.js +82 -0
- package/autoInit/accessibility/functions/dropdown/README.md +212 -0
- package/autoInit/accessibility/functions/dropdown/dropdown.js +167 -0
- package/autoInit/accessibility/functions/list-accessibility/README.md +56 -0
- package/autoInit/accessibility/functions/list-accessibility/list-accessibility.js +23 -0
- package/autoInit/accessibility/functions/text-synchronization/README.md +62 -0
- package/autoInit/accessibility/functions/text-synchronization/text-synchronization.js +101 -0
- package/autoInit/accessibility/functions/toc/README.md +79 -0
- package/autoInit/accessibility/functions/toc/toc.js +191 -0
- package/autoInit/accessibility/functions/year-replacement/README.md +54 -0
- package/autoInit/accessibility/functions/year-replacement/year-replacement.js +43 -0
- package/autoInit/button/README.md +122 -0
- package/autoInit/counter/README.md +274 -0
- package/autoInit/{counter.js → counter/counter.js} +20 -5
- package/autoInit/form/README.md +338 -0
- package/autoInit/{form.js → form/form.js} +44 -29
- package/autoInit/navbar/README.md +366 -0
- package/autoInit/site-settings/README.md +218 -0
- package/autoInit/smooth-scroll/README.md +386 -0
- package/autoInit/transition/README.md +301 -0
- package/autoInit/{transition.js → transition/transition.js} +13 -2
- package/index.js +7 -7
- package/package.json +1 -1
- package/autoInit/accessibility.js +0 -786
- /package/autoInit/{button.js → button/button.js} +0 -0
- /package/autoInit/{site-settings.js → site-settings/site-settings.js} +0 -0
- /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
|
+
}
|