1(function ($, win, doc) { 2 'use strict'; 3 4 // ========================== 5 // Config - Revised 2/27/2026 6 // ========================== 7 8 var cfg = { 9 scope: doc, // container to process 10 features: { 11 fixLabels: true, // add hidden <label> or aria-label to inputs/selects 12 nameIconLinks: true, // add names to icon-only links/buttons 13 labelInName: true, // ensure accessible name contains visible text 14 fixTouchTargets: true, // enforce min target size & spacing 15 fixContrast: false, // enforce WCAG contrast with flip/pill strategy 16 brInsideAnchors: true, // add aria-hidden to <br> inside anchors/buttons 17 ensureH1: true, // inject one <h1> (sr-only) if page has none 18 normalizeHeadings: true, // normalize heading tags, proper order on the page 19 fixAriaValidity: true, 20 addAccessibleNameToIframes: true, 21 fixTables: true, 22 fixNestedAsides: true, 23 fixNestedInteractive: false, // need to work on this, error comes from easy responsive tabs, check to make sure we don't have extra divs try to fix the page first 24 findPdfLinks: false 25 26 }, 27 touch: { MIN: 24, MIN_MOBILE: 28, SPACING: 8 }, 28 flags: { fixed: 'data-a11y-fixed', ttfixed: 'data-ttfixed' } 29 }; 30 31 // ======================= 32 // Utilities 33 // ======================= 34 function debounce(fn, ms){ var t; return function(){ clearTimeout(t); t=setTimeout(fn, ms||120); }; } 35 function px(n){ return parseFloat(n)||0; } 36 function toRGB(c){ 37 if(!c) return null; c=c.trim(); 38 if(c.startsWith('rgb')){ var m=c.match(/[\d.]+/g); return [ +m[0], +m[1], +m[2] ]; } 39 if(c[0]==='#'){ 40 if(c.length===4) return [1,2,3].map(function(i){ return parseInt(c[i]+c[i],16); }); 41 if(c.length===7) return [parseInt(c.substr(1,2),16),parseInt(c.substr(3,2),16),parseInt(c.substr(5,2),16)]; 42 } 43 return null; 44 } 45 function lin(x){ x/=255; return x<=.03928 ? x/12.92 : Math.pow((x+.055)/1.055,2.4); } 46 function lum(rgb){ return 0.2126*lin(rgb[0]) + 0.7152*lin(rgb[1]) + 0.0722*lin(rgb[2]); } 47 function contrast(a,b){ var L1=Math.max(lum(a),lum(b)), L2=Math.min(lum(a),lum(b)); return (L1+0.05)/(L2+0.05); } 48 function isTransparent(v){ return !v || v==='transparent' || v==='rgba(0, 0, 0, 0)' || v==='rgba(0,0,0,0)'; } 49 function effectiveBg(el){ 50 var node=el; 51 while(node && node.nodeType===1){ 52 var cs=getComputedStyle(node); 53 if(cs.backgroundImage && cs.backgroundImage!=='none') return {type:'image', color: cs.backgroundColor}; 54 if(!isTransparent(cs.backgroundColor)) return {type:'color', color: cs.backgroundColor}; 55 node=node.parentElement; 56 } 57 return {type:'color', color:'#ffffff'}; 58 } 59 function isLarge($el){ 60 var cs=getComputedStyle($el[0]); 61 var size=px(cs.fontSize), wt=cs.fontWeight; 62 var bold=(parseInt(wt,10)>=700)||/bold/i.test(wt); 63 return size>=24 || (bold && size>=19); 64 } 65 function visibleText($el){ 66 if (!$el || !$el.length) return ''; 67 68 // Clone so we can safely manipulate 69 var node = $el[0].cloneNode(true); 70 var $c = $(node); 71 72 // Remove non-text contributors 73 $c.find('script, style, noscript, template').remove(); 74 $c.find('[aria-hidden="true"], [hidden], [inert], [role="presentation"], .sr-only, .visually-hidden').remove(); 75 76 // Special handling for decorative wrappers (i/svg/icon classes): 77 // - If they contain text (e.g., <i class="fa"><span>Andrew</span></i>), unwrap them to keep the text 78 // - If they are purely decorative, remove them 79 $c.find('i, svg, [role="img"], [class*="icon"], [class^="fa"], [class*=" bi-"]').each(function(){ 80 var $e = $(this); 81 var hasText = $.trim($e.text()).length > 0; // checks nested spans too 82 if (hasText){ 83 $e.replaceWith($e.contents()); // keep the inner text (e.g., span) 84 } else { 85 $e.remove(); 86 } 87 }); 88 89 // Remove <img> entirely (alt text shouldnât count for visibleText) 90 $c.find('img').remove(); 91 92 // Flatten spans but keep their text 93 $c.find('span').each(function(){ 94 $(this).replaceWith($(this).contents()); 95 }); 96 97 // Turn <br> into space so words donât merge 98 $c.find('br').replaceWith(' '); 99 100 // Use innerText (respects CSS visibility) with fallback 101 var raw = ($c[0].innerText != null ? $c[0].innerText : $c.text()) || ''; 102 return raw.replace(/\s+/g, ' ').trim(); 103 } 104 105 // Replace tag utility: keeps attributes & inner HTML 106 function replaceTag($el, newTag){ 107 if(!$el.length || $el.prop('tagName').toLowerCase() === newTag.toLowerCase()) return $el; 108 var $new = $('<'+newTag+'>'); 109 // copy attributes 110 $.each($el[0].attributes, function(){ $new.attr(this.name, this.value); }); 111 // move children 112 $new.html($el.html()); 113 $el.replaceWith($new); 114 return $new; 115 } 116
117 // Create/ensure an accessible name for a region 118 function ensureRegionName($el){ 119 // Prefer an in-flow heading 120 var $h = $el.find('h1,h2,h3,h4,h5,h6').first(); 121 if($h.length){ 122 // If the heading lacks an id, give it one and reference it 123 if(!$h.attr('id')) $h.attr('id','rg-h-'+Math.random().toString(36).slice(2)); 124 $el.attr('aria-labelledby', $h.attr('id')); 125 // No explicit role needed; a <section> with an accessible name is exposed as a region 126 return; 127 } 128 // Otherwise, use a label from nearby text or a fallback 129 var label = $.trim($el.data('label') || $el.attr('title') || 'Related information'); 130 $el.attr('aria-label', label); 131 } 132 133 function labelFromHref(href){ 134 if(!href) return ''; 135 href=href.trim(); 136 if(/^mailto:/i.test(href)) return 'Email ' + href.replace(/^mailto:/i,''); 137 if(/^tel:/i.test(href)) return 'Call ' + href.replace(/^tel:/i,''); 138 try{ 139 var host = href.replace(/^https?:\/\//i,'').split('/')[0].toLowerCase(); 140 var map = { 'facebook.com':'Facebook','m.facebook.com':'Facebook','instagram.com':'Instagram','www.instagram.com':'Instagram', 141 'twitter.com':'Twitter','x.com':'X','linkedin.com':'LinkedIn','youtube.com':'YouTube','youtu.be':'YouTube', 142 'tiktok.com':'TikTok','pinterest.com':'Pinterest' }; 143 if(map[host]) return map[host]; 144 var path = href.split('#')[0].split('?')[0].split('/');
145 var last = decodeURIComponent((path.filter(Boolean).pop()||'').replace(/[-_]/g,' ')).trim(); 146 return last || host || 'Link'; 147 }catch(e){ return 'Link'; } 148 } 149 150 function decorateIcons($el){ 151 if(cfg.features.brInsideAnchors){ $el.find('br').attr('aria-hidden','true'); } 152 $el.find('svg').attr({'aria-hidden':'true','focusable':'false'}); 153 $el.find('img').each(function(){ var $img=$(this); if(!$img.attr('alt')) $img.attr('alt',''); $img.attr('aria-hidden','true'); }); 154 } 155 156 157 // ======================================================= 158 // Fix 1: Labels for inputs/selects - Not In Cognito Forms 159 // ======================================================= 160 function fixLabels(scope){ 161 if(!cfg.features.fixLabels) return; 162 scope = scope || document; 163 164 $('input, select, textarea', scope).each(function (i) { 165 var $el = $(this); 166 167 // â Skip Cognito forms (form class contains "cog-cognito") 168 if ($el.closest('form[class*="cog-cognito" i], .cog-cognito').length) { 169 $el.attr(cfg.flags.fixed,'1'); 170 return; 171 } 172 173 if ($el.attr(cfg.flags.fixed)) return; 174 175 var type = ($el.attr('type') || '').toLowerCase(); 176 if (type === 'hidden' || type === 'submit' || type === 'reset' || type === 'image') { 177 $el.attr(cfg.flags.fixed,'1'); 178 return; 179 } 180 181 if ($el.attr('aria-label') || $el.attr('aria-labelledby')) { 182 $el.attr(cfg.flags.fixed,'1'); 183 return; 184 } 185 if ($el.attr('id') && $('label[for="' + $el.attr('id') + '"]', scope).length) { 186 $el.attr(cfg.flags.fixed,'1'); 187 return; 188 } 189 190 if (!$el.attr('id')) $el.attr('id', 'autoLabel-' + i); 191 192 var tag = $el.prop('tagName').toLowerCase(); 193 var name = $el.attr('name') || tag + '-' + i; 194 var text = (type === 'checkbox' || type === 'radio') ? 'Option ' + (i + 1) 195 : (tag === 'select') ? 'Select ' + name 196 : name.replace(/[-_]/g, ' '); 197 198 $('<label>', { 'for': $el.attr('id'), 'class': 'sr-only', text: text }).insertBefore($el); 199 $el.attr(cfg.flags.fixed,'1'); 200 }); 201 202 $('select', scope).each(function (i) { 203 var $sel = $(this); 204 205 // â Skip Cognito selects too 206 if ($sel.closest('form[class*="cog-cognito" i], .cog-cognito').length) return; 207 208 if (!$sel.attr('aria-label') && !$sel.attr('aria-labelledby')) { 209 $sel.attr('aria-label', 'Select option ' + (i + 1)); 210 } 211 }); 212 } 213 214 215 // ============================================== 216 // Fix 2: Icon-only links/buttons & Label-in-Name 217 // ============================================== 218 function fixControls(scope){ 219 var SELECTOR = 'a[href], button, [role="button"], [role="link"]'; 220 $(SELECTOR, scope || doc).each(function(){ 221 var $el = $(this); 222 223 // If ancestor is aria-hidden, the element fails regardlessâunhide ancestors 224 $el.parents('[aria-hidden="true"]').removeAttr('aria-hidden'); 225 226 // Add role="button" where appropriate (non-native, no existing role) 227 // Do NOT override native semantics: 228 // - <button> is already a button 229 // - <a href> is already a link 230 // - elements with a role already set should be left alone 231 if (!$el.is('button') && !$el.is('a[href]') && !$el.attr('role')) { 232 $el.attr('role', 'button'); 233 // Optional but recommended for keyboard access if it's clickable: 234 if (!$el.is(':focusable') && !$el.attr('tabindex')) { 235 $el.attr('tabindex', '0'); 236 } 237 // Optional: if it has aria-disabled, map to disabled behavior elsewhere 238 } 239 240 decorateIcons($el); 241 242 var txt = visibleText($el); 243 var aria = ($el.attr('aria-label')||'').trim(); 244 var labelledby = ($el.attr('aria-labelledby')||'').trim(); 245 246 if (txt.length){ 247 if (labelledby) $el.removeAttr('aria-labelledby'); 248 if (aria && aria.toLowerCase().indexOf(txt.toLowerCase()) === -1){ 249 $el.attr('aria-label', txt + ' â ' + aria); 250 } 251 } else { 252 if (!aria && !labelledby){ 253 var derived = $el.is('a') ? labelFromHref($el.attr('href')) : 'Button'; 254 $el.attr('aria-label', derived || 'Control'); 255 } 256 } 257 });
258 } 259 260 // =================================== 261 // Fix 3: Touch targets (mobile-aware) 262 // =================================== 263 function minTouch(){ return (win.matchMedia && win.matchMedia('(max-width: 900px)').matches) ? cfg.touch.MIN_MOBILE : cfg.touch.MIN; } 264 265 var TARGETS = [ 266 'a[href]','button', 267 'input[type="button"]','input[type="submit"]','input[type="reset"]', 268 'input[type="checkbox"]','input[type="radio"]', 269 '[role="button"]','[role="link"]','.btn','.badge','.chip','.tag' 270 ].join(','); 271 272function sizeUp($el) { 273 var need = minTouch(); 274 275 // Measure the element itself 276 var rect = $el[0].getBoundingClientRect(); 277 var left = rect.left, top = rect.top, right = rect.right, bottom = rect.bottom; 278 279 // Check pseudo-elements (::before and ::after) 280 ['before', 'after'].forEach(function(pseudo) { 281 var style = window.getComputedStyle($el[0], '::' + pseudo); 282 if (!style) return; 283 284 var content = style.getPropertyValue('content'); 285 // skip if none or empty 286 if (!content || content === 'none' || content === 'normal' || content === '""') return; 287 288 // get size and position relative to the element 289 var display = style.getPropertyValue('display'); 290 var width = parseFloat(style.getPropertyValue('width')) || 0; 291 var height = parseFloat(style.getPropertyValue('height')) || 0; 292 var marginLeft = parseFloat(style.getPropertyValue('margin-left')) || 0; 293 var marginRight = parseFloat(style.getPropertyValue('margin-right')) || 0; 294 var marginTop = parseFloat(style.getPropertyValue('margin-top')) || 0; 295 var marginBottom = parseFloat(style.getPropertyValue('margin-bottom')) || 0; 296 297 // Adjust approximate bounds if pseudo has size 298 if (width > 0 || height > 0) { 299 left -= marginLeft; 300 right += width + marginRight; 301 top -= marginTop; 302 bottom += height + marginBottom; 303 } 304 }); 305 306 var w = right - left;
307 var h = bottom - top; 308 309 // If total visual area meets target size, skip fixing 310 if (w >= need && h >= need) return false; 311 312 // Handle small checkboxes/radios by enlarging label hit area 313 if ($el.is('input[type="checkbox"], input[type="radio"]')) { 314 var id = $el.attr('id'); 315 var $lab = id ? $('label[for="' + id + '"]') : $(); 316 if ($lab.length) { 317 $lab.addClass('tt-label-hit tt-separated'); 318 if ($.trim($lab.text()).length === 0) 319 $lab.append('<span class="sr-only">Option</span>'); 320 return true; 321 } 322 } 323 324 // Fallback: pad the element to meet touch guidelines 325 $el.addClass('tt-target-fixed tt-padded tt-separated'); 326 327 var lh = parseInt($el.css('line-height'), 10); 328 if (!isNaN(lh) && lh < need) $el.css('line-height', need + 'px'); 329 330 return true; 331} 332 333 function ensureSeparation($el){ 334 var $prev = $el.prevAll(TARGETS + ':visible').first(); 335 if ($prev.length){ $prev.addClass('tt-separated'); $el.addClass('tt-separated'); } 336 var prevBlock = $el.prev(':visible'); 337 if (prevBlock.length && prevBlock.is(TARGETS)) { $el.addClass('tt-separated-vert'); prevBlock.addClass('tt-separated-vert'); } 338 } 339 function fixTouchTargets(scope){ 340 if(!cfg.features.fixTouchTargets) return; 341 $(TARGETS, scope || doc).filter(':visible').each(function(){ 342 var $el = $(this); 343 if ($el.attr(cfg.flags.ttfixed)) return; 344 if (sizeUp($el)) ensureSeparation($el); 345 $el.attr(cfg.flags.ttfixed,'1'); 346 }); 347 } 348 349 // ======================= 350 // Fix 4: Contrast (AA) 351 // ======================= 352 function fixContrast(scope){ 353 if(!cfg.features.fixContrast) return; 354 var TEXT = 'a[href],button,[role="button"],input[type="button"],input[type="submit"],input[type="reset"],label,small,span,p,li,dt,dd,figcaption,th,td,.btn,.badge,.tag,.chip'; 355 $(TEXT, scope||doc).each(function(){ 356 var $el=$(this); 357 if($el.is('[data-no-contrast-fix]') || !$el.is(':visible')) return; 358 359 var cs=getComputedStyle($el[0]); 360 var fg=toRGB(cs.color); if(!fg) return; 361 362 var bg=effectiveBg($el[0]); var bgRGB=toRGB(bg.color); 363 var required = isLarge($el) ? 3.0 : 4.5; 364 365 if(bgRGB){ 366 var current=contrast(fg,bgRGB); 367 if(current>=required) return; 368 369 var black=[17,17,17], white=[255,255,255]; 370 var cB=contrast(black,bgRGB), cW=contrast(white,bgRGB); 371 if(cB>=required || cW>=required){ 372 $el.css('color', cW>=cB ? '#fff' : '#111'); 373 if($el.is('a')) $el.addClass('a11y-contrast-link'); 374 return; 375 } 376 } 377 var useWhite = !bgRGB ? true : (lum(bgRGB)<0.5); 378 $el.addClass('a11y-contrast-pill').css({ color: useWhite?'#fff':'#000', background: useWhite?'rgba(0,0,0,.55)':'rgba(255,255,255,.65)' }); 379 if($el.is('a')) $el.addClass('a11y-contrast-link'); 380 }); 381 } 382 383 // ======================= 384 // Fix 5: Headings 385 // ======================= 386 function ensureH1(scope){ 387 if(!cfg.features.ensureH1) return; 388 if($('h1', scope).length===0){ 389 var pageTitle = $('title').text() || location.pathname.split('/').pop().replace(/[-_]/g,' '); 390 $('body').prepend('<h1 class="sr-only">'+pageTitle+'</h1>'); 391 } 392 } 393 394 // Safe tag swap that preserves attributes, classes, id, and inner HTML 395 function replaceTag($el, newTag){ 396 if (!$el.length || $el.prop('tagName').toLowerCase() === newTag.toLowerCase()) return $el; 397 var $new = $('<' + newTag + '/>'); 398 // copy attributes 399 $.each($el[0].attributes, function(){ $new.attr(this.name, this.value); }); 400 // move children 401 $new.html($el.html()); 402 $el.replaceWith($new); 403 return $new; 404 } 405 406 /** 407 * Normalize heading levels *within* sectional containers independently. 408 * - Base level defaults to H2 (typical when site masthead already uses H1). 409 * - Only fix forward jumps > 1 (e.g., H2âH5 becomes H3). 410 * - Same-level (H3âH3) and upward (H4âH3) never get âincreasedâ. 411 * - Opt-out any subtree with [data-no-heading-fix]. 412 */ 413 function normalizeHeadings(scope, options){ 414 if (!cfg?.features?.normalizeHeadings) return; 415 scope = scope || document; 416 var opt = $.extend({ baseLevel: 2 }, options || {}); 417 418 // Containers to treat independently; add/remove to fit your CMS 419 var CONTAINERS = 'main, article, section, footer, .content, .container, .row, .col, .panel, .card, .portlet, .zone'; 420 421 function hasAnyHeadingClass($el){ 422 var cls = ($el.attr('class') || ''); 423 return /(^|\s)h[1-6](\s|$)/.test(cls) || /(^|\s)heading-[1-6](\s|$)/.test(cls); 424 } 425 426 function ensureHeadingClasses($el, actualLevel){ 427 // â If *any* h# / heading-# class already exists, do not add/replace anything 428 if (hasAnyHeadingClass($el)) return; 429 430 var c1 = 'h' + actualLevel, c2 = 'heading-' + actualLevel; 431 $el.addClass(c1).addClass(c2); 432 } 433 434 $(CONTAINERS, scope) 435 .not('[data-no-heading-fix], [data-no-heading-fix] *') 436 .each(function(){ 437 var $container = $(this); 438 439 var lastLevel = null; // last *effective* level we emitted in this container 440 441 $container.find('h2,h3,h4,h5,h6').each(function(){ 442 var $h = $(this); 443 if ($h.closest('[data-no-heading-fix]').length) return; // explicit opt-out 444 if (!$h.is(':visible')) return; 445 446 var actual = parseInt(this.tagName.slice(1), 10); 447 var target = actual; 448 449 // Establish baseline per container 450 if (lastLevel === null){ 451 if (actual > opt.baseLevel) target = opt.baseLevel; 452 453 if (target !== actual){ 454 $h = replaceTag($h, 'h' + target); 455 // Only add the "original level" classes if no heading classes exist yet 456 ensureHeadingClasses($h, actual); 457 } 458 459 lastLevel = target; 460 return; 461 } 462 463 // If heading jumps forward more than one (e.g., H2 â H5), trim to last+1 464 if (actual - lastLevel > 1){ 465 target = lastLevel + 1; 466 } else { 467 target = actual; 468 } 469 470 if (target !== actual){ 471 $h = replaceTag($h, 'h' + target); 472 // Only add the "original level" classes if no heading classes exist yet 473 ensureHeadingClasses($h, actual); 474 } 475 476 lastLevel = target; 477 });
478 }); 479 } 480 481 // ======================= 482 // Fix 6: ARIA Roles 483 // ======================= 484 function fixAriaValidity(scope){ 485 if(!cfg.features.fixAriaValidity) return; 486 scope = scope || document; 487 488 $('[aria-expanded], [aria-pressed], [aria-checked], [aria-hidden="true"], a', scope).each(function(){ 489 var $el = $(this); 490 var tag = this.tagName.toLowerCase(); 491 var isAnchor = (tag === 'a'); 492 var hasHref = $el.is('a[href]'); 493 var role = ($el.attr('role') || '').toLowerCase(); 494 495 // Detect inline or jQuery click handlers 496 var hasClick = !!($._data && $._data(this, 'events') && $._data(this, 'events').click) || !!this.onclick; 497 498 // Focusability check 499 var focusable = $el.is('a[href], button, input, select, textarea, [tabindex]'); 500 501 // Skip native valid interactive controls 502 if ($el.is('button, input, select, textarea, summary')) return; 503 504 // --- SPECIAL CASE: <a> with onclick but NO href => make it a button --- 505 if (isAnchor && !hasHref && hasClick) { 506 // Promote to button semantics (donât add href â avoids page jump) 507 $el.attr('role','button'); 508 if (!$el.is('[tabindex]')) $el.attr('tabindex','0'); 509 510 // Clean up ARIA that doesn't belong on buttons 511 // (we'll keep aria-expanded/aria-pressed if present since they're valid for buttons) 512 // aria-checked is NOT valid for buttons 513 if ($el.is('[aria-checked]')) $el.removeAttr('aria-checked'); 514 515 // Ensure it's not hidden-from-AT if focusable 516 if ($el.attr('aria-hidden') === 'true') $el.removeAttr('aria-hidden'); 517 518 // Done with this element; proceed to next 519 return; 520 } 521 522 // Determine semantic type for other elements 523 var elementType = null; 524 if (hasHref || role === 'link') { 525 elementType = 'link'; 526 } else if ( 527 hasClick || 528 /^(button|switch|menuitem|checkbox|radio|tab)$/i.test(role) || 529 $el.is('[aria-expanded], [aria-pressed], [aria-checked]') 530 ) { 531 elementType = 'button'; 532 } 533 534 // Apply appropriate role/fix 535 if (elementType === 'link') { 536 $el.attr('role','link'); 537 if (!$el.is('[tabindex]')) $el.attr('tabindex','0'); 538 // Links should not expose button-only states 539 $el.removeAttr('aria-pressed aria-checked'); 540 } 541 else if (elementType === 'button') { 542 $el.attr('role','button'); 543 if (!$el.is('[tabindex]')) $el.attr('tabindex','0'); 544 // aria-checked is not for generic buttons (only checkboxes/switches) 545 if ($el.is('[aria-checked]') && !$el.is('[role="checkbox"], [role="switch"], [role="menuitemcheckbox"]')) { 546 $el.removeAttr('aria-checked'); 547 } 548 } 549 else { 550 // Not interactive â strip illegal ARIA states 551 $el.removeAttr('aria-expanded aria-pressed aria-checked'); 552 } 553 554 // aria-hidden elements must not be focusable/interactive 555 if ($el.attr('aria-hidden') === 'true' && focusable) { 556 $el.removeAttr('aria-hidden'); 557 } 558 }); 559 560 // Keyboard support for custom buttons (Space/Enter activates) 561 $(document) 562 .off('keydown.a11yfix') 563 .on('keydown.a11yfix', '[role="button"]', function(e){ 564 if (e.key === ' ' || e.key === 'Enter' || e.keyCode === 32 || e.keyCode === 13){ 565 e.preventDefault(); 566 $(this).trigger('click'); 567 } 568 }); 569 } 570 571 // ======================= 572 // Fix 7: I-Frame Names 573 // ======================= 574 /** 575 * Add accessible names to iframes by using the closest text ABOVE each iframe. 576 * - Prefers nearest preceding H1âH6 577 * - Then any preceding element with meaningful text 578 * - Then first heading in the same section/container 579 * - Then page <h1> or document.title 580 * - Optional: if iframe has data-form-name, that wins 581 * 582 * Usage: addAccessibleNameToIframes('iframe'); // or a narrower selector 583 */ 584 function addAccessibleNameToIframes(selector){ 585 if(!cfg.features.addAccessibleNameToIframes) return; 586 selector = selector || 'iframe'; 587 588 // Helper: get trimmed visible text from an element 589 function getText($el){ 590 if (!$el || !$el.length || !$el.is(':visible')) return ''; 591 return $.trim($el.clone().find('script,style,noscript').remove().end().text().replace(/\s+/g,' ')); 592 } 593 594 // Helper: derive a good name from context 595 function deriveName($iframe){ 596 // 0) explicit override 597 var explicit = $iframe.attr('data-form-name'); 598 if (explicit && $.trim(explicit).length) return $.trim(explicit); 599 600 // 1) nearest heading ABOVE 601 var $prevHeading = $iframe.prevAll('h1,h2,h3,h4,h5,h6').filter(':visible').first(); 602 var txt = getText($prevHeading); 603 if (txt) return txt; 604 605 // 2) nearest preceding element with meaningful text 606 var $prevWithText = $iframe.prevAll().filter(function(){ 607 return getText($(this)).length >= 3; // at least a few chars 608 }).first(); 609 txt = getText($prevWithText); 610 if (txt) return txt; 611 612 // 3) heading in same container/section 613 var $container = $iframe.closest('section, article, .section, .container, .panel, .card, main, .content, .wrap, .wrapper'); 614 if ($container.length){ 615 var $firstH = $container.find('h1,h2,h3,h4,h5,h6').filter(':visible').first(); 616 txt = getText($firstH); 617 if (txt) return txt; 618 } 619 620 // 4) page <h1> or document.title 621 txt = getText($('h1').first()) || $.trim(document.title || ''); 622 if (txt) return txt; 623 624 // 5) last-resort fallback from src 625 var src = ($iframe.attr('src')||'').split('#')[0].split('?')[0];
626 var last = decodeURIComponent((src.split('/').filter(Boolean).pop()||'').replace(/[-_]/g,' ')).trim(); 627 return last || 'Embedded content'; 628 } 629 630 $(selector).each(function(){ 631 var $if = $(this); 632 633 // Skip if already named well 634 var existingTitle = $.trim(($if.attr('title')||'')); 635 if (existingTitle) return; 636 637 // Donât hide a focusable frame 638 $if.removeAttr('aria-hidden').parents('[aria-hidden="true"]').removeAttr('aria-hidden'); 639 640 var name = deriveName($if); 641 // Set both title (primary) and aria-label (backup) 642 $if.attr('title', name); 643 if (!$.trim($if.attr('aria-label')||'')) { 644 $if.attr('aria-label', name); 645 } 646 }); 647 } 648 649 // ======================= 650 // Fix 8: HTML Tables 651 // ======================= 652 /** 653 * Promote visual "title rows" (TH/TD with colspan) into <caption>, 654 * and ensure tables have proper caption + header associations. 655 */ 656 function fixTables(scope){ 657 if(!cfg.features.fixTables) return; 658 scope = scope || document; 659 660 $('table', scope).each(function(i){ 661 var $table = $(this); 662 if ($table.attr('data-a11y-table-fixed')) return; 663 664 // --- find candidate caption row (first row, single cell spanning multiple columns) 665 var $firstRow = $table.find('tr').first(); 666 if (!$firstRow.length) return; 667 668 var $cells = $firstRow.children('th,td'); 669 if ($cells.length === 1 || $cells.first().attr('colspan') >= 2){ 670 var $cell = $cells.first(); 671 var text = $.trim($cell.text()); 672 673 // create a real caption if text exists and no caption is already present 674 if (text.length && !$table.find('> caption').length){ 675 var $caption = $('<caption/>').text(text); 676 $table.prepend($caption); 677 } 678 679 // remove that fake heading row so screen readers don't double-announce it 680 $firstRow.remove(); 681 } 682 683 // --- if no caption but table has summary heading right before it, use that 684 if (!$table.find('> caption').length){ 685 var $prevHeading = $table.prevAll('h1,h2,h3,h4,h5,h6,p,strong').first(); 686 var headText = $.trim($prevHeading.text()); 687 if (headText.length){ 688 $table.prepend($('<caption/>').text(headText)); 689 } 690 } 691 692 // --- basic header association fallback 693 // ensure all first-row th are scoped properly 694 $table.find('tr:first-child th').attr('scope','col'); 695 $table.find('tr th:first-child').attr('scope','row'); 696 697 $table.attr('data-a11y-table-fixed','1'); 698 }); 699 } 700 701 // ====================================== 702 // Fix 9: Demote nested <aside> landmarks 703 // ====================================== 704 function fixNestedAsides(scope){ 705 if(!cfg.features.fixNestedAsides) return; 706 scope = scope || document; 707 708 // Any aside living inside another landmark (main/header/footer/nav/aside/region) 709 var NESTED_ASIDES = [ 710 'main aside','header aside','footer aside','nav aside', 711 '[role="main"] aside','[role="banner"] aside','[role="contentinfo"] aside', 712 '[role="navigation"] aside','[role="region"] aside','[role="complementary"] aside', 713 'aside aside' 714 ].join(','); 715 716 $(NESTED_ASIDES, scope).each(function(){ 717 var $aside = $(this); 718 if ($aside.attr('data-aside-fixed')) return; 719 720 // Convert to <section> (non-landmark) and keep classes/ids/styles 721 var $section = replaceTag($aside, 'section'); 722 723 // Remove any explicit landmark roles that might have been set 724 $section.removeAttr('role'); 725 726 // Ensure itâs a named region (either aria-label or aria-labelledby via heading) 727 ensureRegionName($section); 728 729 // Mark done 730 $section.attr('data-aside-fixed','1'); 731 }); 732 } 733 734 // =============================== 735 // Fix 12: Fix Nested Interactives 736 // =============================== 737 // Outer interactive containers weâll scan 738 const OUTER = 'a[href], button, [role="button"], [role="link"]'; 739 740 // Inner interactive items we cannot nest 741 const INNER = [ 742 'a[href]','button', 743 'input[type="button"]','input[type="submit"]','input[type="reset"]', 744 'input[type="checkbox"]','input[type="radio"]','input[type="text"]','input[type="search"]','input[type="email"]','input[type="tel"]','input[type="number"]','input[type="url"]','input[type="password"]', 745 'select','textarea', 746 '[role="button"]','[role="link"]' 747 ].join(','); 748 749 // Tiny CSS (optional): demoted items keep look, donât trap focus 750 const style = document.createElement('style'); 751 style.textContent = '.a11y-demoted{cursor:inherit!important} .a11y-demoted[aria-disabled="true"]{pointer-events:auto}'; 752 document.head.appendChild(style); 753 754 function isFormControl($el){ return $el.is('button, input, select, textarea'); } 755 756 // Turn inner into non-interactive but delegate clicks to the outer 757 function demoteInner($inner, $outer){ 758 if ($inner.is('a')){ 759 const $span = $('<span/>', {'class': $inner.attr('class') || ''}).append($inner.contents()); 760 $inner.replaceWith($span); 761 $inner = $span; 762 } else { 763 $inner.removeAttr('href role tabindex'); 764 } 765 $inner.attr('aria-disabled','true').addClass('a11y-demoted') 766 .off('.a11yNest')
767 .on('click.a11yNest keydown.a11yNest', function(e){ 768 if (e.type === 'keydown'){ 769 if (e.key !== 'Enter' && e.key !== ' ' && e.keyCode !== 13 && e.keyCode !== 32) return; 770 e.preventDefault(); 771 } 772 $outer.trigger('click'); 773 }); 774 } 775 776 // Demote outer wrapper; keep inner control as the only interactive thing 777 function demoteOuter($outer, $inner){ 778 if ($outer.is('a')){ 779 const $wrap = $('<div/>', {'class': $outer.attr('class') || ''}); 780 // copy non-interactive attributes 781 $.each($outer[0].attributes, function(){ 782 if (!/^(href|role|tabindex)$/i.test(this.name)) $wrap.attr(this.name, this.value); 783 }); 784 $wrap.append($outer.contents()); 785 $outer.replaceWith($wrap); 786 $outer = $wrap; 787 } else { 788 $outer.removeAttr('href role tabindex'); 789 } 790 $outer.addClass('a11y-demoted').attr('aria-disabled','true'); 791 792 // Ensure custom button is keyboard reachable 793 if ($inner.is('[role="button"]') && !$inner.is('button') && !$inner.is('[tabindex]')) { 794 $inner.attr('tabindex','0'); 795 } 796 } 797 798 function fixNestedInteractive(scope){ 799 if(!cfg.features.fixNestedInteractive) return; 800 $(OUTER, scope || document).each(function(){ 801 let $outer = $(this); 802 if (!$outer.is(':visible')) return; 803 if ($outer.closest('[data-no-nesting-fix]').length) return; 804 805 $outer.find(INNER).each(function(){ 806 let $inner = $(this); 807 if (this === $outer[0]) return; 808 if ($inner.closest('[data-no-nesting-fix]').length) return; 809 810 // Prefer keeping real form controls as interactive 811 if (isFormControl($inner) || $inner.is('.btn, .button, .primary-action')) { 812 demoteOuter($outer, $inner); 813 } else { 814 demoteInner($inner, $outer); 815 } 816 }); 817 818 // Keyboard shim for custom role="button" 819 if ($outer.is('[role="button"]') && !$outer.is('button')){ 820 if (!$outer.is('[tabindex]')) $outer.attr('tabindex','0'); 821 $outer.off('keydown.a11yShim').on('keydown.a11yShim', function(e){ 822 if (e.key === ' ' || e.key === 'Enter' || e.keyCode === 32 || e.keyCode === 13){ 823 e.preventDefault(); $(this).trigger('click'); 824 } 825 }); 826 } 827 // role="link" without href â focusable 828 if ($outer.is('[role="link"]') && !$outer.is('a[href]') && !$outer.is('[tabindex]')){ 829 $outer.attr('tabindex','0'); 830 } 831 }); 832 } 833 834 // ============================================================ 835 // Fix 13: Find PDF Links Setup for Native Pdf / Text Downloads 836 // ============================================================ 837 function findPdfLinks(scope){ 838 if(!cfg.features.findPdfLinks) return; 839 $('a[href$=".pdf"], a[href*=".pdf?"], button[data-href$=".pdf"], button[data-href*=".pdf?"]').each(function(){ 840 var $btn = $(this); 841 if ($btn.attr('data-processed')) return; // prevent duplicates 842 $btn.attr('data-processed','1'); 843 844 var href = $btn.attr('href') || $btn.data('href'); 845 if (!href) return; 846 847 // Convert to absolute URL 848 var abs = new URL(href, window.location.origin).href; 849 850 // Get original classes and HTML (preserves spans/icons) 851 var originalClasses = $btn.attr('class') || ''; 852 var originalHTML = $btn.html() || 'Download PDF'; 853 854 // Build the form 855 var $form = $('<form>', { 856 method: 'POST', 857 action: 'https://www.itrackllc.com/PDFToText/', 858 class: 'pdf-to-text-form', 859 css: { display: 'inline-block', marginLeft: '0.5rem' } 860 }).append( 861 $('<input>', { 862 type: 'hidden', 863 name: 'pdfURL', 864 value: abs 865 }), 866 $('<button>', { 867 type: 'submit', 868 class: originalClasses, 869 html: originalHTML + ' <span style="font-weight:400;">(As Text)</span>' 870 }) 871 ); 872 873 // Insert the new form after the original button/link 874 $btn.after($form); 875 }); 876 } 877 878 // ======================= 879 // Runner & observers 880 // ======================= 881 function run(scope){ 882 // --- One-time element accessibility tweaks run this last --- 883 if (!$('body').attr('data-a11y-init')) { 884 $('body').attr('data-a11y-init', '1'); // prevents rerun 885 $('[role]').removeAttr('role'); 886 $('[aria-multiselectable]').removeAttr('aria-multiselectable'); 887 // Hide visual preloader from screen readers 888 $('.preloader').attr('aria-hidden', 'true'); 889 890 // Improve "Back to top" link/button 891 $('.ui-to-top') 892 .attr({ 893 'aria-label': 'Go To Top Of Page', 894 'role': 'button' 895 }) 896 .detach() 897 .appendTo('footer'); // Move to end of footer for logical reading order 898 899 // Fix Tabs, Accordions, Arrows 900 $(".resp-accordion").each(function() { 901 $(this).attr("role","button"); 902 }) 903 $(".resp-tab-content").each(function() { 904 $(this).attr("role","tabpanel"); 905 $(this).attr("id",$(this).attr("aria-labelledby")); 906 }) 907 $(".resp-arrow").each(function() { 908 $(this).attr("role","button"); 909 $(this).attr("aria-label","Open Tab"); 910 }) 911 findPdfLinks(scope); 912 913 console.log("iTrack LLC A11Y Helpers Applied"); 914 } 915 916 addAccessibleNameToIframes('iframe'); 917 918 scope = scope || cfg.scope; 919 fixLabels(scope); 920 if(cfg.features.nameIconLinks || cfg.features.labelInName){ fixControl
920s(scope); } 921 fixTouchTargets(scope); 922 ensureH1(scope); 923 normalizeHeadings(scope); 924 fixContrast(scope); 925 fixAriaValidity(scope); 926 fixTables(scope); 927 fixNestedAsides(scope); 928 fixNestedInteractive(scope); 929 930 } 931 932 // Initial run (DOM ready) 933 $(function(){ run(doc); }); 934 935 // Debounced observer for dynamic content 936 if(win.MutationObserver){ 937 var rerun = debounce(function(){ run(doc); }, 150); 938 new MutationObserver(rerun).observe(doc.documentElement, { childList:true, subtree:true }); 939 } 940 941 // Re-run on resize (contrast/touch recalculation) 942 $(win).on('resize', debounce(function(){ run(doc); }, 150)); 943 944})(jQuery, window, document);
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.