1/** 2 * minCore Accessibility Module 3 * Native accessibility features that take WCAG 2.1 AAA seriously 4 * 5 * Components: 6 * - StyleSwitcher: Font size, line height, letter spacing, word spacing (the readability quartet) 7 * - ContrastSwitcher: 5 color schemes for every eye under the sun 8 * - EasytextSwitcher: 3-level language system (because not everyone speaks legalese) 9 * - FocusManager: Focus trap, skip links, focus restore (keyboard users are VIPs here) 10 * - AriaAnnouncer: Central ARIA live announcements (the town crier for screen readers) 11 * - ReducedMotion: Animation control (gentle on the vestibular system) 12 * 13 * IMPORTANT: All features work without JS too (Progressive Enhancement FTW) 14 * 15 * @version 2.0.0 16 * @since TYPO3 13 LTS 17 */ 18(function() { 19 'use strict'; 20 21 // Storage Keys 22 const STORAGE_PREFIX = 'mincore-a11y-'; 23 const STORAGE_KEYS = { 24 fontSize: STORAGE_PREFIX + 'fontsize', 25 lineHeight: STORAGE_PREFIX + 'lineheight', 26 letterSpacing: STORAGE_PREFIX + 'letterspacing', 27 wordSpacing: STORAGE_PREFIX + 'wordspacing', 28 contrast: STORAGE_PREFIX + 'contrast', 29 easytext: STORAGE_PREFIX + 'easytext', 30 reducedMotion: STORAGE_PREFIX + 'reducedmotion' 31 }; 32 33 // ========================================================================== 34 // AriaAnnouncer - The town crier for screen readers 35 // ========================================================================== 36 class AriaAnnouncer { 37 constructor() { 38 this.container = null; 39 this.init(); 40 } 41 42 init() { 43 // Create persistent live region container - always listening, always ready 44 this.container = document.createElement('div'); 45 this.container.id = 'mincore-aria-announcer'; 46 this.container.setAttribute('role', 'status'); 47 this.container.setAttribute('aria-live', 'polite'); 48 this.container.setAttribute('aria-atomic', 'true'); 49 this.container.className = 'mincore-sr-only'; 50 document.body.appendChild(this.container); 51 } 52 53 /** 54 * Announce message to screen readers - politely or assertively 55 * @param {string} message - Message to announce 56 * @param {string} priority - 'polite' (waits its turn) or 'assertive' (interrupts immediately) 57 */ 58 announce(message, priority = 'polite') { 59 if (!this.container) return; 60 61 this.container.setAttribute('aria-live', priority); 62 63 // Brief delay for screen reader compatibility - timing is everything 64 this.container.textContent = ''; 65 requestAnimationFrame(() => { 66 this.container.textContent = message; 67 }); 68 } 69 70 /** 71 * Important announcement - interrupts current output (use sparingly!) 72 * @param {string} message 73 */ 74 announceImportant(message) { 75 this.announce(message, 'assertive'); 76 } 77 } 78 79 // ========================================================================== 80 // ReducedMotion - Animations with an off switch 81 // ========================================================================== 82 class ReducedMotion { 83 constructor(announcer) { 84 this.announcer = announcer; 85 this.prefersReducedMotion = false; 86 this.userOverride = null; 87 this.init(); 88 } 89 90 init() { 91 // Check system preference - respect the user's wishes 92 this.mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)'); 93 this.prefersReducedMotion = this.mediaQuery.matches; 94 95 // Load user override - their choice trumps system settings 96 this.userOverride = this.loadSetting(STORAGE_KEYS.reducedMotion); 97 98 // Apply initial state - setting the stage 99 this.applyState(); 100 101 // Listen for system preference changes - stay in sync 102 this.mediaQuery.addEventListener('change', (e) => { 103 this.prefersReducedMotion = e.matches; 104 if (this.userOverride === null) { 105 this.applyState(); 106 } 107 }); 108 109 this.bindEvents(); 110 } 111 112 bindEvents() { 113 document.querySelectorAll('[data-mincore-reducedmotion]').forEach(button => { 114 button.addEventListener('click', (e) => { 115 e.preventDefault(); 116 this.toggle(); 117 }); 118 }); 119 } 120 121 toggle() { 122 const currentState = this.isReducedMotionActive(); 123 this.userOverride = !currentState; 124 this.saveSetting(STORAGE_KEYS.reducedMotion, this.userOverride.toString()); 125 this.applyState(); 126 127 const message = this.userOverride 128 ? 'Animations disabled' 129 : 'Animations enabled'; 130 this.announcer.announce(message); 131 } 132 133 isReducedMotionActive() { 134 if (this.userOverride !== null) { 135 return this.userOverride === true || this.userOverride === 'true'; 136 } 137 return this.prefersReducedMotion; 138 } 139 140 applyState() { 141 const isReduced = this.isReducedMotionActive(); 142 document.documentElement.classList.toggle('mincore-reduced-motion', isReduced); 143 document.documentElement.setAttribute('data-reduced-motion', isReduced.toString()); 144 } 145 146 loadSetting(key) { 147 try { 148 return localStorage.getItem(key); 149 } catch (e) { 150 return null; 151 } 152 } 153 154 saveSetting(key, value) { 155 try { 156 localStorage.setItem(key, value); 157 } catch (e) { 158 // localStorage not available 159 } 160 } 161 } 162 163 // ========================================================================== 164 // FocusManager - Keeping keyboard users on track 165 // ========================================================================== 166 class FocusManager { 167 constructor(announcer) { 168 this.announcer = announcer; 169 this.trapElement = null; 170 this.previousFocus = null; 171 this.focusableSelector = [ 172 'a[href]', 173 'button:not([disabled])', 174 'input:not([disabled]):not([type="hidden"])', 175 'select:not([disabled])', 176 'textarea:not([disabled])',
177 '[tabindex]:not([tabindex="-1"])', 178 '[contenteditable="true"]' 179 ].join(', '); 180 181 this.init(); 182 } 183 184 init() { 185 this.initSkipLinks(); 186 this.initFocusVisible(); 187 } 188 189 /** 190 * Initialize skip links - the express lane for keyboard users 191 */ 192 initSkipLinks() { 193 document.querySelectorAll('.mincore-skip-link, [data-mincore-skiplink]').forEach(link => { 194 link.addEventListener('click', (e) => { 195 const targetId = link.getAttribute('href')?.substring(1); 196 if (!targetId) return; 197 198 const target = document.getElementById(targetId); 199 if (target) { 200 e.preventDefault(); 201 this.focusElement(target); 202 this.announcer.announce('Navigation skipped'); 203 } 204 }); 205 }); 206 } 207 208 /** 209 * Focus-Visible polyfill for older browsers - because everyone deserves focus 210 */ 211 initFocusVisible() { 212 // Check if :focus-visible is supported 213 try { 214 document.querySelector(':focus-visible'); 215 } catch (e) { 216 // Fallback: Add .focus-visible class manually 217 document.body.addEventListener('keydown', (e) => { 218 if (e.key === 'Tab') { 219 document.body.classList.add('mincore-keyboard-nav'); 220 } 221 }); 222 223 document.body.addEventListener('mousedown', () => { 224 document.body.classList.remove('mincore-keyboard-nav'); 225 }); 226 } 227 } 228 229 /** 230 * Focus an element (adding tabindex if needed) 231 * @param {HTMLElement} element 232 */ 233 focusElement(element) { 234 if (!element) return; 235 236 // Scroll element into view - red carpet treatment 237 element.scrollIntoView({ behavior: 'smooth', block: 'start' }); 238 239 // Set tabindex if element is not natively focusable 240 if (!element.matches(this.focusableSelector)) { 241 element.setAttribute('tabindex', '-1'); 242 } 243 244 // Focus after short delay (for smooth scroll to complete) 245 setTimeout(() => { 246 element.focus({ preventScroll: true }); 247 }, 100); 248 } 249 250 /** 251 * Activate focus trap - no escape for the keyboard (until you press Escape) 252 * @param {HTMLElement} container 253 */ 254 trapFocus(container) { 255 if (!container) return; 256 257 this.previousFocus = document.activeElement; 258 this.trapElement = container; 259 260 // Get all focusable elements in the container 261 const focusables = container.querySelectorAll(this.focusableSelector); 262 if (focusables.length === 0) return; 263 264 const firstFocusable = focusables[0]; 265 const lastFocusable = focusables[focusables.length - 1]; 266 267 // Event handler for Tab navigation - keeping focus where it belongs 268 this.trapHandler = (e) => { 269 if (e.key !== 'Tab') return; 270 271 if (e.shiftKey) { 272 if (document.activeElement === firstFocusable) { 273 e.preventDefault(); 274 lastFocusable.focus(); 275 } 276 } else { 277 if (document.activeElement === lastFocusable) { 278 e.preventDefault(); 279 firstFocusable.focus(); 280 } 281 } 282 }; 283 284 container.addEventListener('keydown', this.trapHandler); 285 286 // Focus the first element - welcome to the party 287 firstFocusable.focus(); 288 } 289 290 /** 291 * Release focus trap - freedom at last 292 */ 293 releaseFocus() { 294 if (this.trapElement && this.trapHandler) { 295 this.trapElement.removeEventListener('keydown', this.trapHandler); 296 } 297 298 this.trapElement = null; 299 this.trapHandler = null; 300
301 // Restore previous focus - back to where you came from 302 if (this.previousFocus && this.previousFocus.focus) { 303 this.previousFocus.focus(); 304 } 305 306 this.previousFocus = null; 307 } 308 } 309 310 // ========================================================================== 311 // StyleSwitcher - Typography controls for the discerning reader 312 // ========================================================================== 313 class StyleSwitcher { 314 constructor(announcer) { 315 this.announcer = announcer; 316 317 // Configuration - because one size doesn't fit all 318 this.config = { 319 fontSize: { 320 steps: ['normal', 'large', 'xlarge', 'xxlarge'], 321 increments: [100, 125, 150, 175], 322 labels: { 323 'normal': 'Normal (100%)', 324 'large': 'Large (125%) - for those who appreciate bigger things', 325 'xlarge': 'Extra large (150%) - because size matters', 326 'xxlarge': 'Maximum (175%) - go big or go home' 327 } 328 }, 329 lineHeight: { 330 steps: ['normal', 'relaxed', 'loose'], 331 values: [1.5, 1.75, 2.0], 332 labels: { 333 'normal': 'Normal', 334 'relaxed': 'Relaxed - room to breathe', 335 'loose': 'Loose - wide open spaces' 336 } 337 }, 338 letterSpacing: { 339 steps: ['normal', 'wide', 'wider'], 340 values: [0, 0.05, 0.1], 341 labels: { 342 'normal': 'Normal', 343 'wide': 'Wide - giving letters personal space', 344 'wider': 'Extra wide - social distancing for fonts' 345 } 346 }, 347 wordSpacing: { 348 steps: ['normal', 'wide'], 349 values: [0, 0.1], 350 labels: { 351 'normal': 'Normal', 352 'wide': 'Wide - words need breathing room too' 353 } 354 } 355 }; 356 357 // Current settings - user preferences we actually remember 358 this.currentSettings = { 359 fontSize: this.loadSetting(STORAGE_KEYS.fontSize) || 'normal', 360 lineHeight: this.loadSetting(STORAGE_KEYS.lineHeight) || 'normal', 361 letterSpacing: this.loadSetting(STORAGE_KEYS.letterSpacing) || 'normal', 362 wordSpacing: this.loadSetting(STORAGE_KEYS.wordSpacing) || 'normal' 363 }; 364 365 this.init(); 366 } 367 368 init() { 369 this.applyAllSettings(); 370 this.bindEvents(); 371 } 372 373 bindEvents() { 374 // Font Size Buttons 375 document.querySelectorAll('[data-mincore-fontsize]').forEach(button => { 376 button.addEventListener('click', (e) => { 377 e.preventDefault(); 378 this.setSetting('fontSize', button.dataset.mincoreFontsize); 379 }); 380 }); 381 382 // Line Height Buttons 383 document.querySelectorAll('[data-mincore-lineheight]').forEach(button => { 384 button.addEventListener('click', (e) => { 385 e.preventDefault(); 386 this.setSetting('lineHeight', button.dataset.mincoreLineheight); 387 }); 388 }); 389 390 // Letter Spacing Buttons 391 document.querySelectorAll('[data-mincore-letterspacing]').forEach(button => { 392 button.addEventListener('click', (e) => { 393 e.preventDefault(); 394 this.setSetting('letterSpacing', button.dataset.minoreLetterspacing); 395 }); 396 }); 397 398 // Word Spacing Buttons 399 document.querySelectorAll('[data-mincore-wordspacing]').forEach(button => { 400 button.addEventListener('click', (e) => { 401 e.preventDefault(); 402 this.setSetting('wordSpacing', button.dataset.mincoreWordspacing); 403 }); 404 }); 405 406 // Reset Button 407 document.querySelectorAll('[data-mincore-style-reset]').forEach(button => { 408 button.addEventListener('click', (e) => { 409 e.preventDefault(); 410 this.resetAll(); 411 }); 412 }); 413 } 414 415 setSetting(type, value) { 416 const config = this.config[type]; 417 if (!config || !config.steps.includes(value)) return; 418 419 this.currentSettings[type] = value; 420 this.saveSetting(STORAGE_KEYS[type], value); 421 this.applySetting(type, value); 422 this.updateButtons(type); 423 424 const label = config.labels[value] || value; 425 this.announcer.announce(`${this.getTypeLabel(type)}: ${label}`); 426 } 427 428 getTypeLabel(type) { 429 const labels = { 430 fontSize: 'Font size', 431 lineHeight: 'Line height', 432 letterSpacing: 'Letter spacing', 433 wordSpacing: 'Word spacing' 434 }; 435 return labels[type] || type; 436 } 437 438 applySetting(type, value) { 439 const html = document.documentElement; 440 const config = this.config[type]; 441 442 // Remove old classes - out with the old 443 config.steps.forEach(step => { 444 html.classList.remove(`mincore-${type.toLowerCase()}-${step}`); 445 }); 446 447 // Add new class - in with the new 448 html.classList.add(`mincore-${type.toLowerCase()}-${value}`); 449 450 // Set CSS custom property - the magic behind the scenes 451 const index = config.steps.indexOf(value); 452 if (config.increments) { 453 html.style.setProperty(`--mincore-${type.toLowerCase()}`, config.increments[index] + '%'); 454 } else if (config.values) { 455 const unit = type === 'lineHeight' ? '' : 'em'; 456 html.style.setProperty(`--mincore-${type.toLowerCase()}`, config.values[index] + unit); 457 } 458 } 459 460 applyAllSettings() {
461 Object.keys(this.currentSettings).forEach(type => { 462 this.applySetting(type, this.currentSettings[type]); 463 this.updateButtons(type); 464 }); 465 } 466 467 updateButtons(type) { 468 const attributeName = `data-mincore-${type.toLowerCase()}`; 469 document.querySelectorAll(`[${attributeName}]`).forEach(button => { 470 const isActive = button.getAttribute(attributeName) === this.currentSettings[type]; 471 button.setAttribute('aria-pressed', isActive.toString()); 472 button.classList.toggle('is-active', isActive); 473 }); 474 } 475 476 resetAll() { 477 Object.keys(this.currentSettings).forEach(type => { 478 this.currentSettings[type] = 'normal'; 479 this.saveSetting(STORAGE_KEYS[type], 'normal'); 480 this.applySetting(type, 'normal'); 481 this.updateButtons(type); 482 }); 483 484 this.announcer.announce('All typography settings reset'); 485 } 486 487 loadSetting(key) { 488 try { 489 return localStorage.getItem(key); 490 } catch (e) { 491 return null; 492 } 493 } 494 495 saveSetting(key, value) { 496 try { 497 localStorage.setItem(key, value); 498 } catch (e) { 499 // localStorage not available 500 } 501 } 502 } 503 504 // ========================================================================== 505 // ContrastSwitcher - 5 color schemes for every eye under the sun 506 // ========================================================================== 507 class ContrastSwitcher { 508 constructor(announcer) { 509 this.announcer = announcer; 510 511 // Configuration (5 Themes) - pick your visual poison 512 this.themes = { 513 'default': { 514 label: 'System default', 515 description: 'Follows your system preference' 516 }, 517 'high-contrast-light': { 518 label: 'High contrast (light)', 519 description: 'Black text on white - classic for a reason' 520 }, 521 'high-contrast-dark': { 522 label: 'High contrast (dark)', 523 description: 'White text on black - easy on the eyes at night' 524 }, 525 'inverted': { 526 label: 'Inverted', 527 description: 'Colors flipped - because sometimes different is better' 528 }, 529 'sepia': { 530 label: 'Sepia (eye-friendly)', 531 description: 'Warm tones for marathon reading sessions' 532 } 533 }; 534 535 this.themeOrder = ['default', 'high-contrast-light', 'high-contrast-dark', 'inverted', 'sepia']; 536 this.currentTheme = this.loadSetting(STORAGE_KEYS.contrast) || 'default'; 537 this.autoDetect = true; 538 539 this.init(); 540 } 541 542 init() { 543 // System-Praeferenz erkennen 544 this.detectSystemPreference(); 545 546 // Theme anwenden 547 this.applyTheme(this.currentTheme); 548 549 // Events binden 550 this.bindEvents(); 551 552 // Listen for system preference changes - stay in sync 553 this.watchSystemPreference(); 554 } 555 556 detectSystemPreference() { 557 const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; 558 const prefersContrast = window.matchMedia('(prefers-contrast: more)').matches; 559 560 if (this.currentTheme === 'default' && this.autoDetect) { 561 // Set data attribute for system preference 562 document.documentElement.setAttribute( 563 'data-system-theme', 564 prefersDark ? 'dark' : 'light' 565 ); 566 document.documentElement.setAttribute( 567 'data-system-contrast', 568 prefersContrast ? 'high' : 'normal' 569 ); 570 } 571 } 572 573 watchSystemPreference() { 574 window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => { 575 if (this.currentTheme === 'default') { 576 document.documentElement.setAttribute('data-system-theme', e.matches ? 'dark' : 'light'); 577 } 578 }); 579 580 window.matchMedia('(prefers-contrast: more)').addEventListener('change', (e) => { 581 if (this.currentTheme === 'default') { 582 document.documentElement.setAttribute('data-system-contrast', e.matches ? 'high' : 'normal'); 583 } 584 }); 585 } 586 587 bindEvents() { 588 // Direct theme buttons - one click, new look 589 document.querySelectorAll('[data-mincore-contrast]').forEach(button => { 590 button.addEventListener('click', (e) => { 591 e.preventDefault(); 592 const theme = button.dataset.mincoreContrast; 593 this.setTheme(theme); 594 }); 595 }); 596 597 // Cycle button - round and round we go 598 document.querySelectorAll('[data-mincore-contrast-cycle]').forEach(button => { 599 button.addEventListener('click', (e) => { 600 e.preventDefault();
601 this.cycleTheme(); 602 }); 603 }); 604 605 // High-Contrast Toggle 606 document.querySelectorAll('[data-mincore-contrast-toggle]').forEach(button => { 607 button.addEventListener('click', (e) => { 608 e.preventDefault(); 609 this.toggleHighContrast(); 610 }); 611 }); 612 } 613 614 setTheme(theme) { 615 if (!this.themes[theme]) return; 616 617 this.currentTheme = theme; 618 this.saveSetting(STORAGE_KEYS.contrast, theme); 619 this.applyTheme(theme); 620 this.updateButtons(); 621 622 this.announcer.announce(`Color scheme: ${this.themes[theme].label}`); 623 } 624 625 cycleTheme() { 626 const currentIndex = this.themeOrder.indexOf(this.currentTheme); 627 const nextIndex = (currentIndex + 1) % this.themeOrder.length; 628 this.setTheme(this.themeOrder[nextIndex]); 629 } 630 631 toggleHighContrast() { 632 if (this.currentTheme === 'default') { 633 // Switch to high contrast (based on system theme preference) 634 const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; 635 this.setTheme(prefersDark ? 'high-contrast-dark' : 'high-contrast-light'); 636 } else { 637 this.setTheme('default'); 638 } 639 } 640 641 applyTheme(theme) { 642 const html = document.documentElement; 643 644 // Remove old theme classes - clean slate 645 this.themeOrder.forEach(t => { 646 html.classList.remove(`mincore-contrast-${t}`); 647 }); 648 649 // Add new theme class - fresh coat of paint 650 html.classList.add(`mincore-contrast-${theme}`); 651 652 // Set data attribute 653 html.setAttribute('data-contrast', theme); 654 655 // Update system preference if default 656 if (theme === 'default') { 657 this.detectSystemPreference(); 658 } 659 } 660 661 updateButtons() { 662 document.querySelectorAll('[data-mincore-contrast]').forEach(button => { 663 const isActive = button.dataset.mincoreContrast === this.currentTheme; 664 button.setAttribute('aria-pressed', isActive.toString()); 665 button.classList.toggle('is-active', isActive); 666 }); 667 668 // Update toggle button states 669 document.querySelectorAll('[data-mincore-contrast-toggle]').forEach(button => { 670 const isHighContrast = this.currentTheme.includes('high-contrast'); 671 button.setAttribute('aria-pressed', isHighContrast.toString()); 672 button.classList.toggle('is-active', isHighContrast); 673 }); 674 } 675 676 loadSetting(key) { 677 try { 678 return localStorage.getItem(key); 679 } catch (e) { 680 return null; 681 } 682 } 683 684 saveSetting(key, value) { 685 try { 686 localStorage.setItem(key, value); 687 } catch (e) { 688 // localStorage not available 689 } 690 } 691 } 692 693 // ========================================================================== 694 // EasytextSwitcher - Because not everyone speaks legalese 695 // ========================================================================== 696 class EasytextSwitcher { 697 constructor(announcer) { 698 this.announcer = announcer; 699 700 this.levels = { 701 'normal': { 702 label: 'Standard', 703 description: 'Originaler Text', 704 field: 'bodytext' 705 }, 706 'flesch': { 707 label: 'Kurz & klar', 708 description: 'Flesch-Index >= 70, max. 15 Woerter pro Satz', 709 field: 'minTexter' 710 }, 711 'easy': { 712 label: 'Einfache Sprache', 713 description: 'Nach Regelwerk fuer Leichte Sprache', 714 field: 'minEasy' 715 } 716 }; 717 718 this.levelOrder = ['normal', 'flesch', 'easy']; 719 this.currentLevel = this.loadSetting(STORAGE_KEYS.easytext) || 'normal'; 720 721 this.init(); 722 } 723 724 init() { 725 this.applyLevel(this.currentLevel); 726 this.bindEvents(); 727 } 728 729 bindEvents() { 730 // Direct level buttons - choose your reading level 731 document.querySelectorAll('[data-mincore-easytext]').forEach(button => { 732 button.addEventListener('click', (e) => { 733 e.preventDefault(); 734 const level = button.dataset.mincoreEasytext; 735 this.setLevel(level); 736 }); 737 738 // Tastaturnavigation 739 button.addEventListener('keydown', (e) => { 740 this.handleKeyboard(e, button); 741 }); 742 }); 743 } 744 745 handleKeyboard(e, button) { 746 const buttons = Array.from(document.querySelectorAll('[data-mincore-easytext]')); 747 const currentIndex = buttons.indexOf(button); 748 749 switch (e.key) { 750 case 'ArrowRight': 751 case 'ArrowDown': 752 e.preventDefault(); 753 const nextIndex = (currentIndex + 1) % buttons.length; 754 buttons[nextIndex].focus(); 755 buttons[nextIndex].click(); 756 break; 757 758 case 'ArrowLeft': 759 case 'ArrowUp': 760 e.preventDefault(); 761 const prevIndex = (currentIndex - 1 + buttons.length) % buttons.length; 762 buttons[prevIndex].focus(); 763 buttons[prevIndex].click(); 764 break; 765 766 case 'Home': 767 e.preventDefault(); 768 buttons[0].focus(); 769 buttons[0].click(); 770 break; 771 772 case 'End': 773 e.preventDefault(); 774 buttons[buttons.length - 1].focus(); 775 buttons[buttons.length - 1].click(); 776 break; 777 } 778 } 779 780 setLevel(level) { 781 if (!this.levels[level]) return; 782 783 this.currentLevel = level; 784 this.saveSetting(STORAGE_KEYS.easytext, level); 785 this.applyLevel(level); 786 this.updateContent(); 787 this.updateButtons(); 788 789 this.announcer.announce(`Language level: ${this.levels[level].label}`); 790 } 791 792 applyLevel(level) { 793 const html = document.documentElement; 794 795 // Remove old level classes 796 this.levelOrder.forEach(l => { 797 html.classList.remove(`mincore-easytext-${l}`); 798 }); 799 800 // Add new level class 801 html.classList.add(`mincore-easytext-${level}`); 802 803 // Set data attribute 804 html.setAttribute('data-easytext', level); 805 } 806 807 updateContent() { 808 // All text containers with variants - show the right version 809 document.querySelectorAll('[data-mincore-textcontainer]').forEach(container => { 810 this.levelOrder.forEach(level => { 811 const textElement = container.querySelector(`[data-mincore-text="${level}"]`); 812 if (textElement) { 813 textElement.hidden = level !== this.currentLevel; 814 textElement.setAttribute('aria-hidden', (level !== this.currentLevel).toString()); 815 } 816 }); 817 }); 818 819 // Individual text elements (without container) 820 this.levelOrder.forEach(level => { 821 document.querySelectorAll(`[data-mincore-text="${level}"]:not([data-mincore-textcontainer] *)`).forEach(el => { 822 el.hidden = level !== this.currentLevel; 823 el.setAttribute('aria-hidden', (level !== this.currentLevel).toString()); 824 }); 825 }); 826 } 827 828 updateButtons() { 829 document.querySelectorAll('[data-mincore-easytext]').forEach(button => { 830 const isActive = button.dataset.mincoreEasytext === this.currentLevel; 831 button.setAttribute('aria-pressed', isActive.toString()); 832 button.classList.toggle('is-active', isActive); 833 }); 834 } 835 836 loadSetting(key) { 837 try { 838 return localStorage.getItem(key); 839 } catch (e) { 840 return null; 841 } 842 } 843 844 saveSetting(key, value) { 845 try { 846 localStorage.setItem(key, value); 847 } catch (e) { 848 // localStorage not available 849 } 850 } 851 } 852 853 // ========================================================================== 854 // Initialization 855 // ==========================================================================
856 function init() { 857 if (document.readyState === 'loading') { 858 document.addEventListener('DOMContentLoaded', initModules); 859 } else { 860 initModules(); 861 } 862 } 863 864 function initModules() { 865 // Central announcer for all modules - one voice to rule them all 866 const announcer = new AriaAnnouncer(); 867 868 // Initialize modules and make them globally available 869 window.mincoreAccessibility = { 870 announcer: announcer, 871 reducedMotion: new ReducedMotion(announcer), 872 focusManager: new FocusManager(announcer), 873 styleSwitcher: new StyleSwitcher(announcer), 874 contrastSwitcher: new ContrastSwitcher(announcer), 875 easytextSwitcher: new EasytextSwitcher(announcer) 876 }; 877 878 // Backward Compatibility 879 window.mincoreStyleSwitcher = window.mincoreAccessibility.styleSwitcher; 880 window.mincoreContrastSwitcher = window.mincoreAccessibility.contrastSwitcher; 881 window.mincoreEasytextSwitcher = window.mincoreAccessibility.easytextSwitcher; 882 883 // Mark JS as loaded (for NoScript fallback styling) 884 document.documentElement.classList.add('mincore-js-loaded'); 885 document.documentElement.classList.remove('mincore-no-js'); 886 887 // Custom event for integration - let others know we're ready 888 document.dispatchEvent(new CustomEvent('mincore:accessibility:ready', { 889 detail: window.mincoreAccessibility 890 })); 891 } 892 893 // Auto-init 894 init(); 895 896})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.