PageSourceSearch

https://www.rambomemorialhealthcenter.org/js/A11Y-Helper.js

js rambomemorialhealthcenter.org collected 2026-10-02 00:13:03 UTC 35,374 bytes, 944 lines download raw bytes

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.