@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
@@ -1,786 +0,0 @@
1
- export function init() {
2
- // Centralized cleanup tracking
3
- const cleanup = {
4
- observers: [],
5
- handlers: [],
6
- scrollTimeout: 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
- function setupBlogListCleanup() {
16
- const wrappers = document.querySelectorAll('[data-site-blog="wrapper"]');
17
-
18
- wrappers.forEach(wrapper => {
19
- // Check if wrapper has the delete-if-no-list config
20
- const shouldDelete = wrapper.getAttribute('data-site-blog-config') === 'delete-if-no-list';
21
-
22
- if (!shouldDelete) {
23
- return;
24
- }
25
-
26
- // Check if there's a descendant with data-site-blog="list"
27
- const hasList = wrapper.querySelector('[data-site-blog="list"]') !== null;
28
-
29
- // Delete wrapper if it doesn't have a list
30
- if (!hasList) {
31
- wrapper.remove();
32
- }
33
- });
34
- }
35
-
36
- function setupGeneralAccessibility() {
37
- setupListAccessibility();
38
- setupRemoveListAccessibility();
39
- setupFAQAccessibility(addHandler);
40
- setupConvertToSpan();
41
- setupYearReplacement();
42
- setupPreventDefault(addHandler);
43
- setupRichTextAccessibility(addObserver, addHandler, cleanup);
44
- setupSummaryAccessibility(addHandler);
45
- setupCustomValuesReplacement();
46
- setupClickForwarding(addHandler);
47
- setupTextSynchronization(addObserver);
48
- setupBlogListCleanup();
49
- }
50
-
51
- function setupListAccessibility() {
52
- const listElements = document.querySelectorAll('[data-hs-a11y="list"]');
53
- const listItemElements = document.querySelectorAll('[data-hs-a11y="list-item"]');
54
-
55
- listElements.forEach(element => {
56
- element.setAttribute('role', 'list');
57
- });
58
-
59
- listItemElements.forEach(element => {
60
- element.setAttribute('role', 'listitem');
61
- });
62
- }
63
-
64
- function setupRemoveListAccessibility() {
65
- const containers = document.querySelectorAll('[data-hs-a11y="remove-list"]');
66
-
67
- containers.forEach(container => {
68
- // Remove role="list" and role="listitem" from container and all descendants
69
- const elementsWithListRoles = container.querySelectorAll('[role="list"], [role="listitem"]');
70
- elementsWithListRoles.forEach(element => {
71
- element.removeAttribute('role');
72
- });
73
-
74
- // Also remove from container itself if it has these roles
75
- if (container.getAttribute('role') === 'list' || container.getAttribute('role') === 'listitem') {
76
- container.removeAttribute('role');
77
- }
78
-
79
- // Convert semantic lists to divs in container and all descendants
80
- const listsToConvert = container.querySelectorAll('ul, ol, li');
81
- listsToConvert.forEach(listElement => {
82
- const newDiv = document.createElement('div');
83
-
84
- // Copy all attributes except role
85
- Array.from(listElement.attributes).forEach(attr => {
86
- if (attr.name !== 'role') {
87
- newDiv.setAttribute(attr.name, attr.value);
88
- }
89
- });
90
-
91
- // Move all child nodes
92
- while (listElement.firstChild) {
93
- newDiv.appendChild(listElement.firstChild);
94
- }
95
-
96
- // Replace the element
97
- listElement.parentNode.replaceChild(newDiv, listElement);
98
- });
99
-
100
- // Convert container itself if it's a semantic list
101
- if (container.tagName.toLowerCase() === 'ul' || container.tagName.toLowerCase() === 'ol' || container.tagName.toLowerCase() === 'li') {
102
- const newDiv = document.createElement('div');
103
-
104
- // Copy all attributes except data-hs-a11y and role
105
- Array.from(container.attributes).forEach(attr => {
106
- if (attr.name !== 'data-hs-a11y' && attr.name !== 'role') {
107
- newDiv.setAttribute(attr.name, attr.value);
108
- }
109
- });
110
-
111
- // Move all child nodes
112
- while (container.firstChild) {
113
- newDiv.appendChild(container.firstChild);
114
- }
115
-
116
- // Replace the container
117
- container.parentNode.replaceChild(newDiv, container);
118
- } else {
119
- // Just remove the attribute if container isn't a semantic list
120
- }
121
- });
122
- }
123
-
124
- function setupFAQAccessibility(addHandler) {
125
- const faqContainers = document.querySelectorAll('[data-hs-a11y="faq-wrap"]');
126
-
127
- faqContainers.forEach((container, index) => {
128
- const button = container.querySelector('[data-hs-a11y="faq-btn"]');
129
- const contentWrapper = container.querySelector('[data-hs-a11y="faq-content"]');
130
-
131
- if (!button || !contentWrapper) return;
132
-
133
- const buttonId = `faq-button-${index}`;
134
- const contentId = `faq-content-${index}`;
135
-
136
- button.setAttribute('id', buttonId);
137
- button.setAttribute('aria-expanded', 'false');
138
- button.setAttribute('aria-controls', contentId);
139
-
140
- contentWrapper.setAttribute('id', contentId);
141
- contentWrapper.setAttribute('aria-hidden', 'true');
142
- contentWrapper.setAttribute('role', 'region');
143
- contentWrapper.setAttribute('aria-labelledby', buttonId);
144
-
145
- function toggleFAQ() {
146
- const isOpen = button.getAttribute('aria-expanded') === 'true';
147
-
148
- button.setAttribute('aria-expanded', !isOpen);
149
- contentWrapper.setAttribute('aria-hidden', isOpen);
150
- }
151
-
152
- addHandler(button, 'click', toggleFAQ);
153
- });
154
- }
155
-
156
- function setupConvertToSpan() {
157
- const containers = document.querySelectorAll('[data-hs-a11y="convert-span"]');
158
-
159
- containers.forEach(container => {
160
- const skipTags = [
161
- 'span', 'a', 'button', 'input', 'textarea', 'select', 'img', 'video', 'audio',
162
- 'iframe', 'object', 'embed', 'canvas', 'svg', 'form', 'table', 'thead', 'tbody',
163
- 'tr', 'td', 'th', 'ul', 'ol', 'li', 'dl', 'dt', 'dd', 'h1', 'h2', 'h3', 'h4',
164
- 'h5', 'h6', 'script', 'style', 'link', 'meta', 'title', 'head', 'html', 'body'
165
- ];
166
-
167
- // Convert all child elements first
168
- const elementsToConvert = container.querySelectorAll('*');
169
-
170
- elementsToConvert.forEach(element => {
171
- const tagName = element.tagName.toLowerCase();
172
-
173
- if (!skipTags.includes(tagName)) {
174
- const newSpan = document.createElement('span');
175
-
176
- // Copy all attributes except data-hs-a11y
177
- Array.from(element.attributes).forEach(attr => {
178
- if (attr.name !== 'data-hs-a11y') {
179
- newSpan.setAttribute(attr.name, attr.value);
180
- }
181
- });
182
-
183
- // Move all child nodes
184
- while (element.firstChild) {
185
- newSpan.appendChild(element.firstChild);
186
- }
187
-
188
- // Replace the element
189
- element.parentNode.replaceChild(newSpan, element);
190
- }
191
- });
192
-
193
- // Convert the container itself to span
194
- const containerTagName = container.tagName.toLowerCase();
195
- if (!skipTags.includes(containerTagName)) {
196
- const newSpan = document.createElement('span');
197
-
198
- // Copy all attributes except data-hs-a11y
199
- Array.from(container.attributes).forEach(attr => {
200
- if (attr.name !== 'data-hs-a11y') {
201
- newSpan.setAttribute(attr.name, attr.value);
202
- }
203
- });
204
-
205
- // Move all child nodes
206
- while (container.firstChild) {
207
- newSpan.appendChild(container.firstChild);
208
- }
209
-
210
- // Replace the container
211
- container.parentNode.replaceChild(newSpan, container);
212
- } else {
213
- // Just remove the attribute if container shouldn't be converted
214
- }
215
- });
216
- }
217
-
218
- function setupYearReplacement() {
219
- const currentYear = new Date().getFullYear().toString();
220
- const monthNames = ['January', 'February', 'March', 'April', 'May', 'June',
221
- 'July', 'August', 'September', 'October', 'November', 'December'];
222
- const currentMonth = monthNames[new Date().getMonth()];
223
-
224
- const walker = document.createTreeWalker(
225
- document.body,
226
- NodeFilter.SHOW_TEXT,
227
- {
228
- acceptNode: (node) => {
229
- return (node.textContent.includes('{{year}}') || node.textContent.includes('{{month}}'))
230
- ? NodeFilter.FILTER_ACCEPT
231
- : NodeFilter.FILTER_SKIP;
232
- }
233
- }
234
- );
235
-
236
- const textNodes = [];
237
- let node;
238
- while (node = walker.nextNode()) {
239
- textNodes.push(node);
240
- }
241
-
242
- textNodes.forEach(textNode => {
243
- let newText = textNode.textContent.replace(/\{\{year\}\}/gi, currentYear);
244
- newText = newText.replace(/\{\{month\}\}/gi, currentMonth);
245
- if (newText !== textNode.textContent) {
246
- textNode.textContent = newText;
247
- }
248
- });
249
- }
250
-
251
- function setupPreventDefault(addHandler) {
252
- const elements = document.querySelectorAll('[data-hs-a11y="prevent-default"]');
253
-
254
- elements.forEach(element => {
255
- // Prevent click
256
- const clickHandler = (e) => {
257
- e.preventDefault();
258
- e.stopPropagation();
259
- return false;
260
- };
261
- addHandler(element, 'click', clickHandler);
262
-
263
- // Prevent keyboard activation
264
- const keydownHandler = (e) => {
265
- if (e.key === 'Enter' || e.key === ' ') {
266
- e.preventDefault();
267
- e.stopPropagation();
268
- return false;
269
- }
270
- };
271
- addHandler(element, 'keydown', keydownHandler);
272
-
273
- // Additional prevention for anchor links
274
- if (element.tagName.toLowerCase() === 'a') {
275
- // Remove or modify href to prevent scroll
276
- const originalHref = element.getAttribute('href');
277
- if (originalHref && (originalHref === '#' || originalHref.startsWith('#'))) {
278
- element.setAttribute('data-original-href', originalHref);
279
- element.removeAttribute('href');
280
- element.setAttribute('role', 'button');
281
- element.setAttribute('tabindex', '0');
282
- }
283
- }
284
- });
285
- }
286
-
287
- function setupSummaryAccessibility(addHandler) {
288
- const summaryContainers = document.querySelectorAll('[data-hs-a11y="summary-wrap"]');
289
-
290
- summaryContainers.forEach((container, index) => {
291
- const button = container.querySelector('[data-hs-a11y="summary-btn"]');
292
- const contentWrapper = container.querySelector('[data-hs-a11y="summary-content"]');
293
-
294
- if (!button || !contentWrapper) {
295
- return;
296
- }
297
-
298
- const buttonId = `summary-button-${index}`;
299
- const contentId = `summary-content-${index}`;
300
-
301
- // Get original button text from first text node only
302
- const walker = document.createTreeWalker(
303
- button,
304
- NodeFilter.SHOW_TEXT,
305
- null,
306
- false
307
- );
308
-
309
- let firstTextNode = walker.nextNode();
310
- while (firstTextNode && !firstTextNode.textContent.trim()) {
311
- firstTextNode = walker.nextNode();
312
- }
313
-
314
- const originalButtonText = firstTextNode ? firstTextNode.textContent.trim() : button.textContent.trim();
315
-
316
- // Function to update all text nodes in button
317
- function updateButtonText(newText) {
318
- // Find all text nodes and update them
319
- const walker = document.createTreeWalker(
320
- button,
321
- NodeFilter.SHOW_TEXT,
322
- null,
323
- false
324
- );
325
-
326
- const textNodes = [];
327
- let node;
328
- while (node = walker.nextNode()) {
329
- if (node.textContent.trim()) {
330
- textNodes.push(node);
331
- }
332
- }
333
-
334
- textNodes.forEach(textNode => {
335
- textNode.textContent = newText;
336
- });
337
- }
338
-
339
- button.setAttribute('id', buttonId);
340
- button.setAttribute('aria-expanded', 'false');
341
- button.setAttribute('aria-controls', contentId);
342
- button.setAttribute('aria-label', 'View Summary');
343
-
344
- contentWrapper.setAttribute('id', contentId);
345
- contentWrapper.setAttribute('aria-hidden', 'true');
346
- contentWrapper.setAttribute('role', 'region');
347
- contentWrapper.setAttribute('aria-labelledby', buttonId);
348
-
349
- // Summary is closed by default - no need to check initial state
350
-
351
- function toggleSummary() {
352
- const isOpen = button.getAttribute('aria-expanded') === 'true';
353
-
354
- if (isOpen) {
355
- // Closing
356
- button.setAttribute('aria-expanded', 'false');
357
- button.setAttribute('aria-label', 'View Summary');
358
- updateButtonText(originalButtonText);
359
- contentWrapper.setAttribute('aria-hidden', 'true');
360
- } else {
361
- // Opening
362
- button.setAttribute('aria-expanded', 'true');
363
- button.setAttribute('aria-label', 'Close Summary');
364
- updateButtonText('Close');
365
- contentWrapper.setAttribute('aria-hidden', 'false');
366
- }
367
- }
368
-
369
- addHandler(button, 'click', toggleSummary);
370
- });
371
- }
372
-
373
- function setupCustomValuesReplacement() {
374
- const customValuesList = document.querySelector('[data-hs-a11y="custom-values-list"]');
375
-
376
- if (!customValuesList) {
377
- return;
378
- }
379
-
380
- // Collect all custom values data
381
- const customValues = {};
382
- const descendants = customValuesList.getElementsByTagName('*');
383
-
384
- Array.from(descendants).forEach(descendant => {
385
- const nameElement = descendant.querySelector('[data-hs-a11y="custom-values-name"]');
386
- const valueElement = descendant.querySelector('[data-hs-a11y="custom-values-value"]');
387
-
388
- if (nameElement && valueElement) {
389
- const name = nameElement.textContent.trim();
390
- const value = valueElement.textContent.trim();
391
-
392
- if (name && value) {
393
- customValues[name] = value;
394
- }
395
- }
396
- });
397
-
398
- // If no custom values found, exit early
399
- if (Object.keys(customValues).length === 0) {
400
- return;
401
- }
402
-
403
- // Replace text content efficiently using TreeWalker
404
- const walker = document.createTreeWalker(
405
- document.body,
406
- NodeFilter.SHOW_TEXT,
407
- {
408
- acceptNode: (node) => {
409
- // Check if any custom value names exist in the text content
410
- const text = node.textContent;
411
- for (const name in customValues) {
412
- if (text.includes(`{{${name}}}`)) {
413
- return NodeFilter.FILTER_ACCEPT;
414
- }
415
- }
416
- return NodeFilter.FILTER_SKIP;
417
- }
418
- }
419
- );
420
-
421
- const textNodes = [];
422
- let node;
423
- while (node = walker.nextNode()) {
424
- textNodes.push(node);
425
- }
426
-
427
- // Replace text in collected nodes
428
- textNodes.forEach(textNode => {
429
- let newText = textNode.textContent;
430
- let hasChanges = false;
431
-
432
- for (const name in customValues) {
433
- const placeholder = `{{${name}}}`;
434
- if (newText.includes(placeholder)) {
435
- newText = newText.replace(new RegExp(placeholder, 'g'), customValues[name]);
436
- hasChanges = true;
437
- }
438
- }
439
-
440
- if (hasChanges) {
441
- textNode.textContent = newText;
442
- }
443
- });
444
-
445
- // Replace link hrefs
446
- const links = document.querySelectorAll('a[href]');
447
- links.forEach(link => {
448
- let href = link.getAttribute('href');
449
- let hasChanges = false;
450
-
451
- for (const name in customValues) {
452
- const placeholder = `{{${name}}}`;
453
- if (href.includes(placeholder)) {
454
- href = href.replace(new RegExp(placeholder, 'g'), customValues[name]);
455
- hasChanges = true;
456
- }
457
- }
458
-
459
- if (hasChanges) {
460
- link.setAttribute('href', href);
461
- }
462
- });
463
- }
464
-
465
- function setupClickForwarding(addHandler) {
466
- // Find all clickable elements (custom styled elements users click)
467
- const clickableElements = document.querySelectorAll('[data-hs-a11y*="clickable"]');
468
-
469
- clickableElements.forEach(clickableElement => {
470
- const attribute = clickableElement.getAttribute('data-hs-a11y');
471
-
472
- // Parse the attribute: "click-trigger-[identifier], clickable"
473
- const parts = attribute.split(',').map(part => part.trim());
474
-
475
- // Find the part with click-trigger and the part with clickable
476
- const triggerPart = parts.find(part => part.startsWith('click-trigger-'));
477
- const rolePart = parts.find(part => part === 'clickable');
478
-
479
- if (!triggerPart || !rolePart) {
480
- return;
481
- }
482
-
483
- // Extract identifier from "click-trigger-[identifier]"
484
- const identifier = triggerPart.replace('click-trigger-', '').trim();
485
-
486
- // Find the corresponding trigger element
487
- const triggerSelector = `[data-hs-a11y*="click-trigger-${identifier}"][data-hs-a11y*="trigger"]`;
488
- const triggerElement = document.querySelector(triggerSelector);
489
-
490
- if (!triggerElement) {
491
- return;
492
- }
493
-
494
- // Add click event listener to forward clicks
495
- const clickHandler = (event) => {
496
- // Prevent default behavior on the clickable element
497
- event.preventDefault();
498
- event.stopPropagation();
499
-
500
- // Trigger click on the target element
501
- triggerElement.click();
502
- };
503
- addHandler(clickableElement, 'click', clickHandler);
504
-
505
- // Also handle keyboard events for accessibility
506
- const keydownHandler = (event) => {
507
- if (event.key === 'Enter' || event.key === ' ') {
508
- event.preventDefault();
509
- event.stopPropagation();
510
- triggerElement.click();
511
- }
512
- };
513
- addHandler(clickableElement, 'keydown', keydownHandler);
514
-
515
- // Ensure clickable element is keyboard accessible
516
- if (!clickableElement.hasAttribute('tabindex')) {
517
- clickableElement.setAttribute('tabindex', '0');
518
- }
519
- if (!clickableElement.hasAttribute('role')) {
520
- clickableElement.setAttribute('role', 'button');
521
- }
522
- });
523
- }
524
-
525
- function setupTextSynchronization(addObserver) {
526
- // Find all original elements (source of truth)
527
- const originalElements = document.querySelectorAll('[data-hs-a11y*="original"]');
528
-
529
- originalElements.forEach(originalElement => {
530
- const attribute = originalElement.getAttribute('data-hs-a11y');
531
-
532
- // Parse the attribute: "match-text-[identifier], original"
533
- const parts = attribute.split(',').map(part => part.trim());
534
-
535
- // Find the part with match-text and the part with original
536
- const textPart = parts.find(part => part.startsWith('match-text-'));
537
- const rolePart = parts.find(part => part === 'original');
538
-
539
- if (!textPart || !rolePart) {
540
- return;
541
- }
542
-
543
- // Extract identifier from "match-text-[identifier]"
544
- const identifier = textPart.replace('match-text-', '').trim();
545
-
546
- // Find all corresponding match elements
547
- const matchSelector = `[data-hs-a11y*="match-text-${identifier}"][data-hs-a11y*="match"]`;
548
- const matchElements = document.querySelectorAll(matchSelector);
549
-
550
- if (matchElements.length === 0) {
551
- return;
552
- }
553
-
554
- // Function to synchronize text and aria-label
555
- function synchronizeContent() {
556
- const originalText = originalElement.textContent;
557
- const originalAriaLabel = originalElement.getAttribute('aria-label');
558
-
559
- matchElements.forEach(matchElement => {
560
- // Copy text content
561
- matchElement.textContent = originalText;
562
-
563
- // Synchronize aria-label
564
- if (originalAriaLabel) {
565
- // If original has aria-label, copy it to match
566
- matchElement.setAttribute('aria-label', originalAriaLabel);
567
- } else {
568
- // If original has no aria-label, remove it from match (keep in sync)
569
- if (matchElement.hasAttribute('aria-label')) {
570
- matchElement.removeAttribute('aria-label');
571
- }
572
- }
573
- });
574
- }
575
-
576
- // Initial synchronization
577
- synchronizeContent();
578
-
579
- // Set up MutationObserver to watch for changes
580
- const observer = new MutationObserver((mutations) => {
581
- let shouldSync = false;
582
-
583
- mutations.forEach((mutation) => {
584
- if (mutation.type === 'childList' ||
585
- mutation.type === 'characterData' ||
586
- (mutation.type === 'attributes' && mutation.attributeName === 'aria-label')) {
587
- shouldSync = true;
588
- }
589
- });
590
-
591
- if (shouldSync) {
592
- synchronizeContent();
593
- }
594
- });
595
-
596
- // Observe text changes and attribute changes
597
- observer.observe(originalElement, {
598
- childList: true,
599
- subtree: true,
600
- characterData: true,
601
- attributes: true,
602
- attributeFilter: ['aria-label']
603
- });
604
-
605
- addObserver(observer);
606
- });
607
- }
608
-
609
- function setupRichTextAccessibility(addObserver, addHandler, cleanup) {
610
- const contentAreas = document.querySelectorAll('[data-hs-a11y="rich-content"]');
611
- const tocLists = document.querySelectorAll('[data-hs-a11y="rich-toc"]');
612
-
613
- contentAreas.forEach((contentArea) => {
614
- // Since there's only 1 content area and 1 TOC list per page, use the first TOC list
615
- const tocList = tocLists[0];
616
-
617
- if (!tocList) {
618
- return;
619
- }
620
-
621
- if (tocList.children.length === 0) {
622
- return;
623
- }
624
-
625
- const template = tocList.children[0].cloneNode(true);
626
- // Remove is-active class from template if it exists
627
- const templateLink = template.querySelector("a");
628
- if (templateLink) {
629
- templateLink.classList.remove('is-active');
630
- }
631
-
632
- // Clear all original TOC items
633
- tocList.innerHTML = "";
634
-
635
- const h2Headings = contentArea.querySelectorAll("h2");
636
-
637
-
638
- // Create sections and wrap content
639
- h2Headings.forEach((heading) => {
640
- const sectionId = heading.textContent
641
- .toLowerCase()
642
- .replace(/[^a-z0-9]+/g, "-")
643
- .replace(/(^-|-$)/g, "");
644
-
645
- const section = document.createElement("div");
646
- section.id = sectionId;
647
- heading.parentNode.insertBefore(section, heading);
648
- section.appendChild(heading);
649
- let nextElement = section.nextElementSibling;
650
- while (nextElement && nextElement.tagName !== "H2") {
651
- const elementToMove = nextElement;
652
- nextElement = nextElement.nextElementSibling;
653
- section.appendChild(elementToMove);
654
- }
655
- });
656
-
657
- // Create TOC entries
658
- h2Headings.forEach((heading, index) => {
659
- const tocItem = template.cloneNode(true);
660
- const link = tocItem.querySelector("a");
661
- const sectionId = heading.parentElement.id;
662
- link.href = "#" + sectionId;
663
-
664
-
665
- // Bold numbered text
666
- const number = document.createElement("strong");
667
- number.textContent = index + 1 + ". ";
668
-
669
- // Clear the link and add the number + text
670
- link.innerHTML = "";
671
- link.appendChild(number);
672
- link.appendChild(document.createTextNode(heading.textContent));
673
-
674
- // Add click handler for smooth scrolling
675
- const clickHandler = (e) => {
676
- e.preventDefault();
677
-
678
- const targetSection = document.getElementById(sectionId);
679
- if (targetSection) {
680
- targetSection.scrollIntoView({ behavior: "smooth" });
681
- // Focus on the section for accessibility (will only show outline for keyboard users due to CSS)
682
- setTimeout(() => {
683
- targetSection.focus();
684
- }, 100);
685
- }
686
- };
687
- addHandler(link, "click", clickHandler);
688
-
689
- // Ensure sections are focusable for keyboard users but use CSS to control focus visibility
690
- const targetSection = document.getElementById(sectionId);
691
- if (targetSection) {
692
- targetSection.setAttribute("tabindex", "-1");
693
- // Use focus-visible to only show outline for keyboard focus
694
- targetSection.style.outline = "none";
695
- targetSection.style.setProperty("outline", "none", "important");
696
- }
697
-
698
- // Add item to the TOC list
699
- tocList.appendChild(tocItem);
700
- });
701
-
702
- // Set up IntersectionObserver for active state (Webflow-style)
703
- const sections = Array.from(h2Headings).map(heading => heading.parentElement);
704
- const tocLinks = tocList.querySelectorAll('a');
705
- let currentActive = null;
706
-
707
- const updateActiveLink = () => {
708
- const windowHeight = window.innerHeight;
709
- const trigger = windowHeight * 0.75; // 25% from bottom
710
-
711
- let newActive = null;
712
-
713
- // Find the last section whose top is above the trigger point
714
- for (let i = sections.length - 1; i >= 0; i--) {
715
- const rect = sections[i].getBoundingClientRect();
716
- if (rect.top <= trigger) {
717
- newActive = sections[i].id;
718
- break;
719
- }
720
- }
721
-
722
- // Only update if active section changed
723
- if (newActive !== currentActive) {
724
- currentActive = newActive;
725
-
726
- // Remove all is-active
727
- tocLinks.forEach(link => link.classList.remove('is-active'));
728
-
729
- // Add to current
730
- if (currentActive) {
731
- const activeLink = Array.from(tocLinks).find(link =>
732
- link.getAttribute('href') === `#${currentActive}`
733
- );
734
- if (activeLink) {
735
- activeLink.classList.add('is-active');
736
- }
737
- }
738
- }
739
- };
740
-
741
- const observerOptions = {
742
- rootMargin: '0px 0px -75% 0px',
743
- threshold: 0
744
- };
745
-
746
- const observer = new IntersectionObserver(() => {
747
- updateActiveLink();
748
- }, observerOptions);
749
-
750
- // Observe all sections
751
- sections.forEach(section => observer.observe(section));
752
- addObserver(observer);
753
-
754
- // Also update on scroll for smoother tracking
755
- const scrollHandler = () => {
756
- if (cleanup.scrollTimeout) clearTimeout(cleanup.scrollTimeout);
757
- cleanup.scrollTimeout = setTimeout(updateActiveLink, 50);
758
- };
759
- addHandler(window, 'scroll', scrollHandler);
760
-
761
- });
762
- }
763
-
764
- setupGeneralAccessibility();
765
-
766
- return {
767
- result: "accessibility initialized",
768
- destroy: () => {
769
- // Disconnect all observers
770
- cleanup.observers.forEach(obs => obs.disconnect());
771
- cleanup.observers.length = 0;
772
-
773
- // Remove all event listeners
774
- cleanup.handlers.forEach(({ element, event, handler, options }) => {
775
- element.removeEventListener(event, handler, options);
776
- });
777
- cleanup.handlers.length = 0;
778
-
779
- // Clear scroll timeout
780
- if (cleanup.scrollTimeout) {
781
- clearTimeout(cleanup.scrollTimeout);
782
- cleanup.scrollTimeout = null;
783
- }
784
- }
785
- };
786
- }