1/* ============================================================================ 2 wcag-remediation.js 3 ---------------------------------------------------------------------------- 4 WCAG 2.2 AA remediation (Sugamya audit) â additive, progressive-enhancement 5 JavaScript companion to wcag-remediation.css. Loaded last in _Layout.cshtml. 6 7 Everything here only ADDS accessibility attributes to existing markup; it 8 never changes behaviour or layout, and the page works without it. 9 10 To revert: delete this file and remove its <script> from _Layout.cshtml. 11 ============================================================================ */ 12(function () { 13 'use strict'; 14 15 /* ------------------------------------------------------------------------ 16 jQuery 4 compatibility shim 17 ------------------------------------------------------------------------ 18 The site upgraded to jquery-4.0.0, which REMOVED long-deprecated helpers 19 ($.isArray, $.trim, $.isFunction, $.type, $.browser, and the .bind()/ 20 .unbind()/.delegate()/.size() instance methods). The old jquery.fancybox 21 library (Customer Care / Branch Locator / "Write to us" / board-of-directors 22 popups across the site) calls these and throws "$.isArray is not a function", 23 so those dialogs NEVER OPEN on the current site. Restore the removed helpers 24 so the popups work again â this both fixes a live site regression and is a 25 prerequisite for the Home Sr30 dialog focus-trap (a dialog must open to trap). 26 Runs immediately (jQuery is already loaded by the time this script runs). 27 ------------------------------------------------------------------------ */ 28 (function jqueryCompatShim() { 29 var $ = window.jQuery; 30 if (!$) { return; } 31 if (typeof $.isArray !== 'function') { $.isArray = Array.isArray; } 32 if (typeof $.isFunction !== 'function') { $.isFunction = function (o) { return typeof o === 'function'; }; } 33 if (typeof $.isWindow !== 'function') { $.isWindow = function (o) { return o != null && o === o.window; }; } 34 if (typeof $.trim !== 'function') { $.trim = function (s) { return s == null ? '' : String(s).replace(/^[\s\xA0]+|[\s\xA0]+$/g, ''); }; } 35 if (typeof $.type !== 'function') { 36 $.type = function (o) { 37 if (o == null) { return o + ''; } 38 return (typeof o === 'object' || typeof o === 'function') 39 ? Object.prototype.toString.call(o).slice(8, -1).toLowerCase() 40 : typeof o; 41 }; 42 } 43 if (typeof $.browser === 'undefined') { 44 var ua = navigator.userAgent.toLowerCase(); 45 $.browser = { msie: /msie|trident/.test(ua), version: '0', webkit: /webkit/.test(ua), mozilla: /firefox/.test(ua), safari: /safari/.test(ua) }; 46 } 47 if ($.fn) { 48 if (typeof $.fn.bind !== 'function') { $.fn.bind = function (t, d, f) { return this.on(t, null, d, f); }; } 49 if (typeof $.fn.unbind !== 'function') { $.fn.unbind = function (t, f) { return this.off(t, f); }; } 50 if (typeof $.fn.delegate !== 'function') { $.fn.delegate = function (s, t, d, f) { return this.on(t, s, d, f); }; } 51 if (typeof $.fn.undelegate !== 'function') { $.fn.undelegate = function (s, t, f) { return arguments.length ? this.off(t, s, f) : this.off('.'); }; } 52 if (typeof $.fn.size !== 'function') { $.fn.size = function () { return this.length; }; } 53 if (typeof $.fn.andSelf !== 'function' && typeof $.fn.addBack === 'function') { $.fn.andSelf = $.fn.addBack; } 54 } 55 })(); 56 57 /* ------------------------------------------------------------------------ 58 BATCH B3 â Link Purpose for links opening a new window/tab 59 WCAG 2.4.4 Link Purpose (In Context) (A) 60 ------------------------------------------------------------------------ 61 Many target="_blank" links (footer, header, "Need Help?" section, social 62 icons) give no warning that they open a new window. We append an 63 accessible "(opens in a new window)" cue to each link's accessible name, 64 and add rel="noopener" for security. Visible text is unchanged. 65 ------------------------------------------------------------------------ */ 66 // Derive a readable name for an icon-only link from its URL host 67 // (e.g. facebook.com -> "Facebook"). Covers the social-media icons. 68 var HOST_NAMES = { 69 'facebook.com': 'Facebook', 70 'twitter.com': 'Twitter', 71 'x.com': 'X', 72 'instagram.com': 'Instagram', 73 'linkedin.com': 'LinkedIn', 74 'youtube.com': 'YouTube' 75 }; 76 function nameFromHref(a) { 77 var host; 78 try { host = new URL(a.href).hostname.replace(/^www\./, ''); } catch (e) { return ''; } 79 if (HOST_NAMES[host]) { return HOST_NAMES[host]; } 80 for (var key in HOST_NAMES) { 81 if (HOST_NAMES.hasOwnProperty(key) && host.indexOf(key) !== -1) { 82 return HOST_NAMES[key]; 83 } 84 } 85 return ''; 86 } 87 88 function enhanceExternalLinks() { 89 var links = document.querySelectorAll('a[target="_blank"]'); 90 Array.prototype.forEach.call(links, function (a) { 91 // Security hardening for target="_blank". 92 var rel = a.getAttribute('rel') || ''; 93 if (rel.indexOf('noopener') === -1) { 94 a.setAttribute('rel', (rel + ' noopener').trim()); 95 } 96 // Don't double-annotate. 97 var existing = a.getAttribute('aria-label') || ''; 98 if (/opens? in (a )?new (window|tab)/i.test(existing)) { 99 return; 100 } 101 // Pick the best existing accessible name; for icon-only links 102 // (no text/label/title) derive one from the URL (4.1.2 Name). 103 var name = existing || 104 (a.textContent || '').replace(/\s+/g, ' ').trim() || 105 a.getAttribute('title') || 106 nameFromHref(a); 107 if (!name) { 108 return; // truly unnameable â leave for manual review 109 } 110 a.setAttribute('aria-label', name + ' (opens in a new window)'); 111 }); 112 } 113 114 /* ------------------------------------------------------------------------ 115 BATCH B4 â News ticker Pause/Play control 116 WCAG 2.2.2 Pause, Stop, Hide (A) 117 ------------------------------------------------------------------------ 118 Wires the keyboard-operable button rendered by _Marquee.cshtml. The 119 scrolling itself is CSS; this just toggles the .is-paused state and keeps 120 the button's accessible name / pressed state in sync. 121 ------------------------------------------------------------------------ */ 122 /* The ticker scroll is a CSS animation over a fixed DURATION, so with a lot 123 of news items the pixel-speed becomes very fast. The original <marquee> 124 scrolled at a CONSTANT pixel rate. Re-derive the animation duration from 125 the content width so the pace matches the old marquee (~70 px/sec), 126 regardless of how much content there is. */ 127 var TICKER_PX_PER_SEC = 70; // matches the original <marquee> default pace 128 function setTickerSpeed() { 129 Array.prototype.forEach.call(document.querySelectorAll('.wcag-ticker-track'), function (track) { 130 // track holds two copies of the content; one copy = half its width, 131 // which is exactly the distance the -50% animation travels. 132 var contentWidth = track.scrollWidth / 2; 133 if (contentWidth > 0) { 134 track.style.animationDuration = Math.max(10, Math.round(contentWidth / TICKER_PX_PER_SEC)) + 's'; 135 } 136 }); 137 } 138 139 function initTickerControls() { 140 var tickers = document.querySelectorAll('.wcag-ticker'); 141 Array.prototype.forEach.call(tickers, function (ticker) { 142 var btn = ticker.querySelector('.wcag-ticker-btn'); 143 if (!btn || btn.dataset.wcagBound) { return; } 144 btn.dataset.wcagBound = '1'; 145 btn.addEventListener('click', function () { 146 var paused = ticker.classList.toggle('is-paused'); 147 btn.setAttribute('aria-pressed', paused ? 'true' : 'false'); 148 btn.setAttribute('aria-label', paused ? 'Play news ticker' : 'Pause news ticker'); 149 }); 150 }); 151 } 152 153 /* ------------------------------------------------------------------------
154 BATCH C1 â Accessible names for <select> dropdowns 155 WCAG 4.1.2 Name, Role, Value (A) 156 ------------------------------------------------------------------------ 157 Product Finder, Locate-Us (state/city/branch), customer type and the 158 language switcher are <select>s with NO programmatic name. Screen readers 159 announce them only as "combo box". We give each an aria-label derived from 160 its placeholder option (or its id), without changing anything visible. 161 ------------------------------------------------------------------------ */ 162 function titleCase(s) { 163 return s.toLowerCase().replace(/\b\w/g, function (c) { return c.toUpperCase(); }); 164 } 165 function selectHasName(s) { 166 if (s.getAttribute('aria-label') || s.getAttribute('aria-labelledby') || s.getAttribute('title')) { 167 return true; 168 } 169 return !!(s.id && document.querySelector('label[for="' + (window.CSS && CSS.escape ? CSS.escape(s.id) : s.id) + '"]')); 170 } 171 function deriveSelectLabel(s) { 172 if (s.id === 'selectLocale') { return 'Select language'; } 173 var opt = (s.options && s.options[0]) ? (s.options[0].text || '').trim() : ''; 174 var t = opt.replace(/[-ââ]/g, ' ').replace(/\s+/g, ' ').trim(); 175 if (/select/i.test(t) && t.length <= 40) { return titleCase(t); } 176 var base = (s.id || s.name || 'option') 177 .replace(/Dropdown$/i, '') 178 .replace(/^ddl/i, '') 179 .replace(/Category1$/, 'Category') 180 .replace(/([a-z])([A-Z])/g, '$1 $2') 181 .replace(/[_-]+/g, ' ') 182 .trim(); 183 return 'Select ' + titleCase(base); 184 } 185 function initSelectLabels() { 186 var selects = document.querySelectorAll('select'); 187 Array.prototype.forEach.call(selects, function (s) { 188 if (selectHasName(s)) { return; } 189 s.setAttribute('aria-label', deriveSelectLabel(s)); 190 }); 191 } 192 193 /* ------------------------------------------------------------------------ 194 BATCH C2 â Carousel regions + Prev/Next controls 195 WCAG 4.1.2 Name, Role, Value (A) + 2.1.1 Keyboard (A) 196 ------------------------------------------------------------------------ 197 - Bootstrap banner controls (<a>): add Prev/Next accessible names. 198 - Owl carousel controls (<div>): not focusable -> make them real buttons 199 (role=button, tabindex=0, Enter/Space activation) and name them. 200 - Carousels themselves: add a region role + label (from a nearby heading). 201 Re-run a few times because Owl builds its nav asynchronously after init. 202 ------------------------------------------------------------------------ */ 203 function nearestHeadingText(el) { 204 var sec = el.closest('section, .section, [class*="-section"]') || el.parentElement; 205 for (var i = 0; i < 4 && sec; i++) { 206 var h = sec.querySelector('h1, h2, h3, h4'); 207 if (h && h.textContent.trim()) { return h.textContent.trim().replace(/\s+/g, ' ').slice(0, 40); } 208 sec = sec.parentElement; 209 } 210 return ''; 211 } 212 function enhanceCarousels() { 213 // Bootstrap banner/dialog prev/next controls (these DO navigate) -> name 214 // them. Auditor wants the "Go to Previous/Next Slide" wording (Home Sr70). 215 Array.prototype.forEach.call( 216 document.querySelectorAll('.carousel-control, .carousel-control-prev, .carousel-control-next'), 217 function (c) { 218 if (!c.getAttribute('aria-label')) { 219 c.setAttribute('aria-label', /prev|left/i.test(c.className) ? 'Go to Previous Slide' : 'Go to Next Slide'); 220 } 221 } 222 ); 223 // Carousel region -> name ONLY a #themeSlider that ACTUALLY rotates (more 224 // than one slide, e.g. the 22-slide home banner). Inner-page banners use 225 // the same #themeSlider markup but hold a SINGLE slide, so announcing them 226 // as a "carousel" is misleading â the auditor flagged "unnecessary carousel 227 // region ... applicable to all pages" (About Us New row). For those, strip 228 // the carousel semantics. The .owl-carousel grids and #popupCarousel are 229 // handled elsewhere. 230 Array.prototype.forEach.call(document.querySelectorAll('#themeSlider'), function (c) { 231 var slides = c.querySelectorAll('.carousel-item, .item'); 232 if (slides.length <= 1) { 233 c.removeAttribute('aria-roledescription');
234 if (c.getAttribute('role') === 'region') { c.removeAttribute('role'); } 235 return; 236 } 237 if (!c.getAttribute('aria-label')) { 238 var name = nearestHeadingText(c); 239 c.setAttribute('role', 'region'); 240 c.setAttribute('aria-roledescription', 'carousel'); 241 c.setAttribute('aria-label', (name ? name + ' carousel' : 'Banner carousel')); 242 } 243 }); 244 // NOTE: Owl arrows (.owl-prev/.owl-next) are hidden by the site and do 245 // not navigate (all items shown at once), so they are intentionally not 246 // turned into focus stops. Content cards remain Tab-reachable. 247 } 248 249 /* ------------------------------------------------------------------------ 250 BATCH R2-CT â In-page content tabs: full WAI-ARIA Tabs pattern 251 WCAG 4.1.2 / 1.3.1 / 2.1.1 (About Us Sr30/66/67/68; also interest-rates) 252 ------------------------------------------------------------------------ 253 Content tab strips (<ul class="nav nav-tabs"> with data-toggle / data-bs- 254 toggle="tab" anchors) carry NO ARIA and switch via Bootstrap + hover only. 255 Add role=tablist/tab/tabpanel, aria-selected + aria-controls, roving 256 tabindex and arrow-key navigation. The header mega-menu (#nav-tab) is 257 handled by enhanceHeaderMenuTabs and skipped here. 258 ------------------------------------------------------------------------ */ 259 function enhanceContentTabs() { 260 Array.prototype.forEach.call(document.querySelectorAll('ul.nav-tabs, .nav.nav-tabs'), function (list) { 261 if (list.id === 'nav-tab' || list.dataset.wcagCTabs) { return; } 262 var links = Array.prototype.slice.call(list.querySelectorAll('a[data-toggle="tab"], a[data-bs-toggle="tab"]')); 263 if (!links.length) { return; } 264 list.dataset.wcagCTabs = '1'; 265 list.setAttribute('role', 'tablist'); 266 var tabs = links.map(function (a) { 267 var href = a.getAttribute('href') || ''; 268 var panel = href.charAt(0) === '#' && href.length > 1 ? document.getElementById(href.slice(1)) : null; 269 a.setAttribute('role', 'tab'); 270 ensureId(a); 271 var li = a.closest('li'); 272 if (li) { li.setAttribute('role', 'presentation'); } 273 if (panel) { 274 a.setAttribute('aria-controls', panel.id); 275 panel.setAttribute('role', 'tabpanel'); 276 panel.setAttribute('aria-labelledby', a.id); 277 if (!panel.hasAttribute('tabindex')) { panel.setAttribute('tabindex', '0'); } 278 } 279 return { a: a, panel: panel, li: li }; 280 }); 281 function isActive(t) { 282 if (t.li && t.li.classList.contains('active')) { return true; } 283 if (t.a.classList.contains('active')) { return true; } 284 if (t.panel && (t.panel.classList.contains('show') || t.panel.classList.contains('active'))) { return true; } 285 return false; 286 } 287 function sync() { 288 var anyActive = tabs.some(isActive);
289 tabs.forEach(function (t, i) { 290 var on = anyActive ? isActive(t) : i === 0; 291 t.a.setAttribute('aria-selected', on ? 'true' : 'false'); 292 t.a.setAttribute('tabindex', on ? '0' : '-1'); 293 }); 294 } 295 // Switch panels MANUALLY. The markup uses Bootstrap-3/4 data-toggle="tab" 296 // but the site loads Bootstrap 5 (which only wires data-bs-toggle), so a 297 // plain click doesn't switch the panel â the site relies on hover only. 298 // Toggling the classes ourselves makes tabs work by keyboard AND click, 299 // independent of Bootstrap version, and keeps aria-selected in sync. 300 function activate(t, focus) { 301 tabs.forEach(function (x) { 302 var on = x === t; 303 x.a.setAttribute('aria-selected', on ? 'true' : 'false'); 304 x.a.setAttribute('tabindex', on ? '0' : '-1'); 305 x.a.classList.toggle('active', on); 306 if (x.li) { x.li.classList.toggle('active', on); } 307 if (x.panel) { 308 x.panel.classList.toggle('active', on); 309 x.panel.classList.toggle('show', on); 310 } 311 }); 312 if (focus) { try { t.a.focus(); } catch (e) {} } 313 }
314 tabs.forEach(function (t, i) { 315 t.a.addEventListener('click', function (e) { e.preventDefault(); activate(t, false); }); 316 t.a.addEventListener('keydown', function (e) { 317 var next = null; 318 switch (e.key) { 319 case 'ArrowRight': case 'ArrowDown': next = tabs[(i + 1) % tabs.length]; break; 320 case 'ArrowLeft': case 'ArrowUp': next = tabs[(i - 1 + tabs.length) % tabs.length]; break; 321 case 'Home': next = tabs[0]; break; 322 case 'End': next = tabs[tabs.length - 1]; break; 323 case 'Enter': case ' ': case 'Spacebar': e.preventDefault(); activate(t, true); return; 324 default: return; 325 } 326 e.preventDefault(); 327 activate(next, true); 328 }); 329 }); 330 sync(); 331 // The site also switches panels on hover (about_us.js / tabchange.js); 332 // keep aria-selected in sync after those class changes. 333 list.addEventListener('mouseover', function () { setTimeout(sync, 60); }); 334 }); 335 } 336 337 /* ------------------------------------------------------------------------ 338 BATCH C4 â Descriptive names for vague "Know more" / "Read more" links 339 WCAG 4.1.2 Name, Role, Value (A) + 2.4.4 Link Purpose 340 ------------------------------------------------------------------------ 341 Many cards have a "Know more" / "Read more" control whose text gives no 342 context. We append the card's heading via aria-label ("Know more about 343 <scheme>"), leaving the visible text unchanged. 344 ------------------------------------------------------------------------ */ 345 var VAGUE = /^(know more|read more|learn more|more|click here|details|view more)$/i; 346 function cardHeadingFor(el) { 347 var card = el.closest('.card, [class*="card"], [class*="single-content"], [class*="scheme"], li, article, .col-lg-3, .col-lg-4, .col-md-3, .col-md-4'); 348 if (card) { 349 var h = card.querySelector('h1,h2,h3,h4,h5,h6,.title,[class*="title"],[class*="heading"]'); 350 if (h && h.textContent.trim()) { return h.textContent.trim(); } 351 } 352 // walk back to a preceding heading 353 var n = el; 354 for (var i = 0; i < 30 && n; i++) { 355 n = n.previousElementSibling || (n.parentElement); 356 if (!n || n === document.body) { break; } 357 if (/^H[1-6]$/.test(n.tagName) && n.textContent.trim()) { return n.textContent.trim(); } 358 var hh = n.querySelector && n.querySelector('h1,h2,h3,h4,h5,h6'); 359 if (hh && hh.textContent.trim()) { return hh.textContent.trim(); } 360 } 361 return ''; 362 } 363 function enhanceVagueLinks() { 364 Array.prototype.forEach.call(document.querySelectorAll('a, button'), function (el) { 365 if (el.getAttribute('aria-label')) { return; } 366 var txt = (el.textContent || '').replace(/\s+/g, ' ').trim(); 367 if (!VAGUE.test(txt)) { return; } 368 var ctx = cardHeadingFor(el).replace(/\s+/g, ' ').slice(0, 60); 369 if (ctx) { el.setAttribute('aria-label', txt + ' about ' + ctx); } 370 }); 371 } 372 373 /* ------------------------------------------------------------------------ 374 BATCH C3 â Dialog/popup name + "has popup" hint 375 WCAG 4.1.2 Name, Role, Value (A) 376 ------------------------------------------------------------------------ 377 The on-load popup has role="dialog" but no aria-modal and no accessible 378 name; dialog triggers don't announce that they open a popup. We add those. 379 (Full focus-trap for the custom Customer Care dialog is tracked separately 380 as C3b â it needs the dialog's open/close hook, which is in compiled JS.) 381 ------------------------------------------------------------------------ */ 382 var idSeq = 0; 383 function ensureId(el) { 384 if (!el.id) { el.id = 'wcag-id-' + (++idSeq); } 385 return el.id; 386 } 387 function enhanceDialogs() { 388 Array.prototype.forEach.call(document.querySelectorAll('[role="dialog"], .modal'), function (dlg) { 389 if (!dlg.getAttribute('role')) { dlg.setAttribute('role', 'dialog'); } 390 if (!dlg.getAttribute('aria-modal')) { dlg.setAttribute('aria-modal', 'true'); } 391 if (!dlg.getAttribute('aria-label') && !dlg.getAttribute('aria-labelledby')) { 392 var h = dlg.querySelector('.modal-title, h1, h2, h3, h4'); 393 if (h && h.textContent.trim()) { 394 dlg.setAttribute('aria-labelledby', ensureId(h)); 395 } else { 396 dlg.setAttribute('aria-label', 'Information dialog'); 397 } 398 } 399 }); 400 // Triggers that open a dialog -> announce the popup behaviour. Scope to 401 // ACTUAL modal toggles only. (Do NOT use the broad [data-target^="#"]:
402 // carousel indicators/controls also use data-target/data-slide-to, and 403 // tagging them aria-haspopup="dialog" made screen readers wrongly say 404 // "opens dialog" on every slide dot â Home audit New row.) 405 Array.prototype.forEach.call( 406 document.querySelectorAll('[data-toggle="modal"], [data-bs-toggle="modal"]'), 407 function (t) { 408 if (!t.getAttribute('aria-haspopup')) { t.setAttribute('aria-haspopup', 'dialog'); } 409 } 410 ); 411 } 412 413 /* ------------------------------------------------------------------------ 414 BATCH C3b â Focus trap for the fancybox dialogs (Customer Care etc.) 415 WCAG 2.1.1 / 2.4.3 (Home Sr30) 416 ------------------------------------------------------------------------ 417 The "Customer Care"/"Write to us"/"Branch Locator" links open a jQuery 418 fancybox popup, but keyboard focus stays on the page behind it. Library- 419 agnostic fix: watch for the fancybox container appearing, move focus into 420 it, trap Tab within it, close on Escape, and restore focus to the trigger 421 on close. No change to the site's fancybox init. 422 ------------------------------------------------------------------------ */ 423 function focusablesIn(c) { 424 return Array.prototype.slice.call(c.querySelectorAll( 425 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])' 426 )).filter(function (e) { return e.offsetParent !== null; }); 427 } 428 // Intercept inline-content popup triggers (a.fancybox1/.various/.fancybox with 429 // href="#someId") and show them in a proper accessible dialog. This bypasses 430 // the site's fancybox (broken under jQuery 4) and adds a real focus trap for 431 // Customer Care / Branch Locator / Write to us (Home Sr30). 432 function enhanceInlineDialogs() { 433 var links = document.querySelectorAll( 434 'a.fancybox1[href^="#"], a.various[href^="#"], a.fancybox[href^="#"]' 435 ); 436 Array.prototype.forEach.call(links, function (link) { 437 if (link.dataset.wcagInlineDlg) { return; } 438 var id = (link.getAttribute('href') || '').slice(1); 439 var target = id && document.getElementById(id); 440 if (!target) { return; } // only inline-content dialogs 441 link.dataset.wcagInlineDlg = '1'; 442 link.setAttribute('aria-haspopup', 'dialog'); 443 // Give a vague "Read More" trigger a descriptive name from the person it 444 // opens (About Us Sr39). The member name lives in a .bod_name div (not a 445 // heading), and it's reliably present inside the dialog CONTENT (target), 446 // so read it from there. Set authoritatively (override any generic label 447 // enhanceVagueLinks may have derived from a section heading). 448 var txt = (link.textContent || '').replace(/\s+/g, ' ').trim(); 449 if (/^(read more|more|view more|details)$/i.test(txt)) { 450 var nameEl = (link.closest('.bod_box') || target).querySelector('.bod_name') 451 || target.querySelector('.modal-title, h1, h2, h3, h4, .title'); 452 var nm = nameEl ? nameEl.textContent.replace(/\s+/g, ' ').trim() : ''; 453 if (nm) { link.setAttribute('aria-label', txt + ' about ' + nm); } 454 } 455 // Capture phase + stopImmediatePropagation to beat fancybox's handler. 456 link.addEventListener('click', function (e) { 457 e.preventDefault(); 458 e.stopImmediatePropagation(); 459 openInlineDialog(target, link); 460 }, true); 461 }); 462 } 463 function openInlineDialog(target, returnEl) { 464 var overlay = document.createElement('div'); 465 overlay.className = 'wcag-modal-overlay'; 466 var dialog = document.createElement('div'); 467 dialog.className = 'wcag-modal'; 468 dialog.setAttribute('role', 'dialog'); 469 dialog.setAttribute('aria-modal', 'true'); 470 var h = target.querySelector('h1,h2,h3,h4,h5,h6,.modal-title,.bod_name,.title'); 471 dialog.setAttribute('aria-label', (h && h.textContent.replace(/\s+/g, ' ').trim()) || 'Dialog'); 472 var close = document.createElement('button'); 473 close.type = 'button'; 474 close.className = 'wcag-modal-close'; 475 close.setAttribute('aria-label', 'Close'); 476 close.innerHTML = '×'; 477 dialog.appendChild(close); 478 var content = target.cloneNode(true); // clone so the original stays put 479 content.removeAttribute('id'); 480 content.style.display = 'block'; 481 dialog.appendChild(content); 482 overlay.appendChild(dialog); 483 document.body.appendChild(overlay); 484 485 function focusables() { return focusablesIn(dialog); } 486 try { (focusables()[0] || close).focus(); } catch (e) {} 487 function onKey(e) { 488 if (e.key === 'Escape') { e.preventDefault(); teardown(); return; } 489 if (e.key !== 'Tab') { return; } 490 var f = focusables(); 491 if (!f.length) { e.preventDefault(); close.focus(); return; } 492 var first = f[0], last = f[f.length - 1]; 493 if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } 494 else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } 495 else if (!dialog.contains(document.activeElement)) { e.preventDefault(); first.focus(); } 496 } 497 function teardown() { 498 document.removeEventListener('keydown', onKey, true); 499 if (overlay.parentNode) { overlay.parentNode.removeChild(overlay); } 500 if (returnEl && returnEl.focus) { try { returnEl.focus(); } catch (e) {} } 501 } 502 close.addEventListener('click', teardown); 503 overlay.addEventListener('mousedown', function (e) { if (e.target === overlay) { teardown(); } }); 504 document.addEventListener('keydown', onKey, true); 505 } 506 function enhanceFancyboxDialogs() { 507 if (document.body.dataset.wcagFbxObs) { return; } 508 document.body.dataset.wcagFbxObs = '1'; 509 var FBX = '.fancybox-wrap, .fancybox-inner, .fancybox-content, .fancybox__content, #fancybox-content'; 510 var active = null, returnEl = null, keyHandler = null; 511 function openTrap(box) { 512 if (active === box) { return; } 513 active = box; returnEl = document.activeElement; 514 if (!box.getAttribute('role')) { box.setAttribute('role', 'dialog'); } 515 box.setAttribute('aria-modal', 'true'); 516 if (!box.hasAttribute('tabindex')) { box.setAttribute('tabindex', '-1'); } 517 var f = focusablesIn(box); 518 try { (f[0] || box).focus(); } catch (e) {} 519 keyHandler = function (e) { 520 if (e.key === 'Tab') {
521 var items = focusablesIn(box); 522 if (!items.length) { e.preventDefault(); box.focus(); return; } 523 var first = items[0], last = items[items.length - 1]; 524 if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } 525 else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } 526 else if (!box.contains(document.activeElement)) { e.preventDefault(); first.focus(); } 527 } else if (e.key === 'Escape') { 528 var cb = document.querySelector('#fancybox-close, .fancybox-close, .fancybox-item-close, .fancybox-button--close, .fancybox-close-small'); 529 if (cb) { cb.click(); } 530 } 531 }; 532 document.addEventListener('keydown', keyHandler, true); 533 } 534 function closeTrap() { 535 if (!active) { return; } 536 if (keyHandler) { document.removeEventListener('keydown', keyHandler, true); } 537 active = null; keyHandler = null; 538 if (returnEl && returnEl.focus) { try { returnEl.focus(); } catch (e) {} } 539 returnEl = null; 540 } 541 try { 542 new MutationObserver(function () { 543 var box = document.querySelector(FBX); 544 if (box && box.offsetParent !== null) { openTrap(box); } 545 else if (active) { closeTrap(); } 546 }).observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); 547 } catch (e) { /* */ } 548 } 549 550 /* ------------------------------------------------------------------------ 551 BATCH D (safety net) â name any unlabeled form field from its placeholder 552 WCAG 3.3.2 / 4.1.2 553 ------------------------------------------------------------------------ 554 The Get-in-Touch form now has real <label>s (in markup). This catches any 555 remaining placeholder-only fields on other forms (career, complaint, ...) 556 without editing each page: if a field has no programmatic name, use its 557 placeholder as an aria-label. Visible appearance is unchanged. 558 ------------------------------------------------------------------------ */ 559 function labelUnlabeledFields() { 560 var SKIP = { hidden: 1, submit: 1, button: 1, reset: 1, image: 1 }; 561 Array.prototype.forEach.call(document.querySelectorAll('input, textarea'), function (el) { 562 var type = (el.getAttribute('type') || 'text').toLowerCase(); 563 if (SKIP[type]) { return; } 564 if (el.getAttribute('aria-label') || el.getAttribute('aria-labelledby') || el.getAttribute('title')) { return; } 565 if (el.id && document.querySelector('label[for="' + (window.CSS && CSS.escape ? CSS.escape(el.id) : el.id) + '"]')) { return; } 566 if (el.closest('label')) { return; } 567 var ph = (el.getAttribute('placeholder') || '').trim(); 568 if (ph) { el.setAttribute('aria-label', ph); return; } 569 // Last resort: derive a readable name from the id/name attribute. 570 var raw = el.id || el.name || ''; 571 if (raw) { 572 var lbl = raw 573 .replace(/^Form[_-]?/i, '') 574 .replace(/[_-]+/g, ' ') 575 .replace(/([a-z])([A-Z])/g, '$1 $2') 576 .replace(/(\D)(\d)/g, '$1 $2') 577 .replace(/\s+/g, ' ') 578 .trim(); 579 if (lbl) { el.setAttribute('aria-label', titleCase(lbl)); } 580 } 581 }); 582 } 583 584 /* ------------------------------------------------------------------------ 585 BATCH R2-FORM â Form landmark, required state, error focus & announcement 586 WCAG 4.1.2 / 3.3.2 / 3.3.1 / 2.4.3 / 4.1.3 (Forms tab) 587 ------------------------------------------------------------------------ 588 The site's forms (career, complaint, DNDâ¦) use a bare <form> with no 589 accessible name (so it isn't announced as a form landmark), no aria-required 590 on required fields, and error messages that aren't announced / don't take 591 focus. This adds all of that generically; per-form heading/label markup is 592 fixed in the templates. 593 ------------------------------------------------------------------------ */ 594 function enhanceForms() { 595 Array.prototype.forEach.call(document.querySelectorAll('form'), function (form) { 596 if (form.dataset.wcagForm) { return; } 597 // Only real data-entry forms (skip the header search etc.). 598 if (!form.querySelector('input:not([type="hidden"]), select, textarea')) { return; } 599 if (form.closest('header, footer, #uw-main')) { return; } 600 form.dataset.wcagForm = '1'; 601 602 // (1) Form landmark â give it an accessible name (4.1.2). 603 if (!form.getAttribute('aria-label') && !form.getAttribute('aria-labelledby') && !form.getAttribute('title') && form.getAttribute('role') !== 'form') { 604 var name = ''; 605 var hIn = form.querySelector('h1, h2, h3, [role="heading"]'); 606 if (hIn && hIn.textContent.trim()) { name = hIn.textContent.trim(); } 607 if (!name) { 608 var prev = form.previousElementSibling; 609 for (var i = 0; i < 5 && prev; i++) { 610 var ph = (prev.matches && prev.matches('h1,h2,h3')) ? prev : (prev.querySelector && prev.querySelector('h1,h2,h3')); 611 if (ph && ph.textContent.trim()) { name = ph.textContent.trim(); break; } 612 prev = prev.previousElementSibling; 613 } 614 } 615 form.setAttribute('aria-label', (name ? name.replace(/\s+/g, ' ').slice(0, 60) + ' form' : 'Form')); 616 } 617 618 // (2) Programmatically associate stray <label>s (no `for`, not wrapping an 619 // input) with their field â the site's labels omit `for` (1.3.1/3.3.2). 620 var GRP = '.form-group, .field, .col-md-6, .col-md-12, .col-lg-6, .col-lg-4, li, p, td, div'; 621 Array.prototype.forEach.call(form.querySelectorAll('label'), function (lab) { 622 if (lab.getAttribute('for') || lab.querySelector('input, select, textarea')) { return; } 623 var field = null, n = lab.nextElementSibling; 624 for (var i = 0; i < 4 && n; i++) {
625 if (n.matches && n.matches('input:not([type="hidden"]), select, textarea')) { field = n; break; } 626 var q = n.querySelector && n.querySelector('input:not([type="hidden"]), select, textarea'); 627 if (q) { field = q; break; } 628 n = n.nextElementSibling; 629 } 630 if (!field) { var g = lab.closest(GRP); if (g) { field = g.querySelector('input:not([type="hidden"]), select, textarea'); } } 631 if (field) { 632 if (!field.id) { field.id = 'wcag-fld-' + (++idSeq); } 633 lab.setAttribute('for', field.id); 634 } 635 }); 636 637 // (3) aria-required from `required`/jQuery-val/a visible asterisk (3.3.2). 638 var hasRequired = false; 639 Array.prototype.forEach.call(form.querySelectorAll('input, select, textarea'), function (f) { 640 var t = (f.getAttribute('type') || 'text').toLowerCase(); 641 if (t === 'hidden' || t === 'submit' || t === 'button' || t === 'reset' || f.hasAttribute('aria-required')) { return; } 642 var req = f.required || f.hasAttribute('data-val-required') || f.getAttribute('aria-required') === 'true'; 643 if (!req) { 644 var lab = null; 645 if (f.id) { lab = document.querySelector('label[for="' + (window.CSS && CSS.escape ? CSS.escape(f.id) : f.id) + '"]'); } 646 var grp = f.closest(GRP); 647 // asterisk inside the label OR a .required-star/.req marker in the group 648 if (lab && /[*â±]/.test(lab.textContent)) { req = true; } 649 else if (grp && (grp.querySelector('.required-star, .wcag-req, .req') || (grp.querySelector('label') && /[*â±]/.test(grp.querySelector('label').textContent)))) { req = true; } 650 } 651 if (req) { f.setAttribute('aria-required', 'true'); hasRequired = true; } 652 }); 653 654 // (4) Inject a visible "* = required" instruction at the top if the form has 655 // required fields but no such note (3.3.2). 656 if (hasRequired && !form.querySelector('.wcag-form-required-note') && !/required field/i.test(form.textContent)) { 657 var note = document.createElement('p'); 658 note.className = 'wcag-form-required-note'; 659 note.innerHTML = 'Fields marked with an asterisk (<span aria-hidden="true">*</span>) are required.'; 660 var af = form.querySelector('input, select, textarea'); 661 var anchor = af ? (af.closest(GRP) || af) : form.firstElementChild; 662 form.insertBefore(note, form.firstChild); 663 } 664 665 // (5) Announce validation errors (4.1.3) â scope live regions to this form. 666 var summary = form.querySelector('.validation-summary-errors, [data-valmsg-summary="true"], [asp-validation-summary]'); 667 if (summary && !summary.getAttribute('role')) { summary.setAttribute('role', 'alert'); summary.setAttribute('aria-live', 'assertive'); } 668 Array.prototype.forEach.call( 669 form.querySelectorAll('.field-validation-error, .field-validation-valid, span.text-danger, span[data-valmsg-for]'), 670 function (el) { 671 if (!el.getAttribute('role') && !el.getAttribute('aria-live')) { el.setAttribute('aria-live', 'polite'); } 672 } 673 ); 674 675 // (4) On submit, move focus to the first invalid field (3.3.1 / 2.4.3). 676 form.addEventListener('submit', function () { 677 setTimeout(function () { 678 var bad = form.querySelector('.input-validation-error, [aria-invalid="true"], :invalid:not([type="hidden"]):not(fieldset)'); 679 if (bad && bad.focus) { try { bad.focus(); } catch (e) {} } 680 }, 60); 681 }); 682 }); 683 } 684 685 /* ------------------------------------------------------------------------ 686 BATCH E â Decorative image alt + stray ticker headings 687 WCAG 1.1.1 Non-text Content (A) + 1.3.1 Info and Relationships (A) 688 ------------------------------------------------------------------------ 689 - Images with NO alt that are clearly decorative (svg shapes/paths/icons, 690 spacers, logos) get alt="" (logo gets a real name). Content photos are 691 left alone â they need human-written alt (flagged, not faked). 692 - The news ticker's DB content wraps each item in <h6>; those aren't real 693 headings, so we strip the heading role (role="presentation"). 694 ------------------------------------------------------------------------ */ 695 var DECORATIVE = /shape|path|icon|decor|spacer|arrow|dots|pattern|bg[-_]|background|ellipse|circle|line\b/; 696 function enhanceImages() { 697 Array.prototype.forEach.call(document.querySelectorAll('img:not([alt])'), function (img) { 698 var src = (img.getAttribute('src') || '').toLowerCase(); 699 if (/logo/.test(src)) { img.setAttribute('alt', 'Cosmos Co-op Bank'); return; } 700 // Treat decorative SVGs / icon-shapes / spacers as decorative. 701 if (/\.svg(\?|$)/.test(src) || DECORATIVE.test(src) || DECORATIVE.test((img.className || '').toLowerCase())) { 702 img.setAttribute('alt', ''); 703 return; 704 } 705 // Content raster image: derive a name from its card heading or the 706 // link that wraps it; if there is no usable context it is almost 707 // certainly decorative, so use alt="" (never leave alt missing). 708 var ctx = cardHeadingFor(img).replace(/\s+/g, ' ').trim(); 709 if (ctx) { img.setAttribute('alt', ctx.slice(0, 90)); return; } 710 var a = img.closest('a'); 711 if (a) { 712 var t = (a.getAttribute('aria-label') || a.textContent || '').replace(/\s+/g, ' ').trim(); 713 if (t && !VAGUE.test(t)) { img.setAttribute('alt', t.slice(0, 90)); return; } 714 } 715 img.setAttribute('alt', ''); 716 }); 717 } 718 /* Guarantee every page has an h1 (1.3.1 / 2.4.6). On inner pages the banner 719 title is already promoted to level 1; this is the fallback for pages the 720 banner fix doesn't cover (homepage, special layouts): inject a visually 721 hidden h1 built from the page title. */ 722 function ensureH1() { 723 if (document.querySelector('h1, [role="heading"][aria-level="1"]')) { return; } 724 var main = document.querySelector('main#main-content, [role="main"]') || document.body; 725 var name = (document.title || '').replace(/\s*\|\s*Cosmos.*$/i, '').trim(); 726 if (!name || /^home$/i.test(name)) { name = 'Cosmos Co-op Bank'; } 727 var h1 = document.createElement('h1'); 728 h1.textContent = name; 729 h1.className = 'wcag-sr-only'; 730 main.insertBefore(h1, main.firstChild); 731 } 732 /* Empty headings (e.g. the homepage carousel's <h3> </h3> spacers) 733 confuse screen readers. Demote any heading with no text/image to 734 presentation. WCAG 1.3.1. */ 735 function demoteEmptyHeadings() { 736 Array.prototype.forEach.call( 737 document.querySelectorAll('h1, h2, h3, h4, h5, h6'), 738 function (h) { 739 var txt = (h.textContent || '').replace(/Â /g, ' ').trim(); 740 if (!txt && !h.querySelector('img, svg, [aria-label]')) { 741 h.setAttribute('role', 'presentation'); 742 } 743 } 744 ); 745 } 746 747 function demoteStrayTickerHeadings() { 748 Array.prototype.forEach.call( 749 document.querySelectorAll('.wcag-ticker h1, .wcag-ticker h2, .wcag-ticker h3, .wcag-ticker h4, .wcag-ticker h5, .wcag-ticker h6'), 750 function (h) { 751 h.setAttribute('role', 'presentation'); 752 } 753 ); 754 } 755 756 /* Inner-page banner: (1) the breadcrumb is read AFTER the page heading even 757 though it sits visually above it -> wrong reading sequence (1.3.2). The 758 breadcrumb is position:absolute, so moving it earlier in the DOM fixes the 759 reading order with no visual change. (2) the banner title is the page's 760 primary heading but is marked up as <h3>; expose it to AT as level 1 so 761 every inner page has an h1 (1.3.1) without changing its visual size. */ 762 function fixBannerHeadingsAndOrder() { 763 Array.prototype.forEach.call(document.querySelectorAll('.inner-pg-banner'), function (banner) { 764 var bc = banner.querySelector(':scope > .breadcrumbs-div'); 765 var carousel = banner.querySelector(':scope > .carousel, :scope > .carousel.slide');
766 if (bc && carousel && 767 (bc.compareDocumentPosition(carousel) & Node.DOCUMENT_POSITION_PRECEDING)) { 768 banner.insertBefore(bc, banner.firstChild); 769 } 770 // Promote the banner title to an h1 for assistive tech (visual size kept). 771 if (!document.querySelector('h1')) { 772 var title = banner.querySelector('.inner-pg-banner-text-wrap h1, .inner-pg-banner-text-wrap h2, .inner-pg-banner-text-wrap h3, .carousel-caption h1, .carousel-caption h2, .carousel-caption h3'); 773 if (title && !title.getAttribute('role')) { 774 title.setAttribute('role', 'heading'); 775 title.setAttribute('aria-level', '1'); 776 } 777 } 778 }); 779 } 780 781 /* ------------------------------------------------------------------------ 782 BATCH F1 â Breadcrumb navigation landmark + current page (4.1.2) 783 BATCH F2 â Descriptive "Enquire Now about <topic>" links (2.4.4 / 4.1.2) 784 ------------------------------------------------------------------------ */ 785 function enhanceBreadcrumbs() { 786 Array.prototype.forEach.call(document.querySelectorAll('.breadcrumbs-div, .breadcrumbs-inner-div'), function (bc) { 787 var ol = bc.querySelector('ol.breadcrumb, ul.breadcrumb, .breadcrumb'); 788 if (!ol) { return; } 789 var landmark = ol.closest('nav, [role="navigation"]') || bc; 790 if (!landmark.getAttribute('role') && landmark.tagName !== 'NAV') { 791 landmark.setAttribute('role', 'navigation'); 792 } 793 if (!landmark.getAttribute('aria-label')) { 794 landmark.setAttribute('aria-label', 'Breadcrumb'); 795 } 796 var active = ol.querySelector('.active a, .breadcrumb-item.active a, li.active a'); 797 if (active && !active.getAttribute('aria-current')) { 798 active.setAttribute('aria-current', 'page'); 799 } 800 }); 801 } 802 function enhanceEnquireLinks() { 803 var topicEl = document.querySelector('.inner-pg-banner-text-wrap h1, .inner-pg-banner-text-wrap [aria-level="1"], h1'); 804 var topic = ((topicEl ? topicEl.textContent : '') || (document.title || '').replace(/\s*\|.*$/, '')).replace(/\s+/g, ' ').trim(); 805 Array.prototype.forEach.call(document.querySelectorAll('a, button'), function (a) { 806 var txt = (a.textContent || '').replace(/\s+/g, ' ').trim(); 807 if (/^enquire now$/i.test(txt) && !a.getAttribute('aria-label')) { 808 a.setAttribute('aria-label', topic ? 'Enquire Now about ' + topic : 'Enquire Now'); 809 } 810 }); 811 } 812 813 /* ------------------------------------------------------------------------ 814 BATCH F4 â Accordion headers keyboard-operable 815 WCAG 2.1.1 Keyboard (A) 816 ------------------------------------------------------------------------ 817 Accordions (.acc_head / smk-accordion) toggle on click only. Make the 818 header a real button: focusable, Enter/Space activates, aria-expanded 819 reflects state. 820 ------------------------------------------------------------------------ */ 821 function isAccordionOpen(head) { 822 var sec = head.parentElement; 823 return (head.classList.contains('acc_active') || 824 (sec && (sec.classList.contains('acc_active') || sec.classList.contains('open') || sec.classList.contains('active')))); 825 } 826 function enhanceAccordions() { 827 var heads = document.querySelectorAll('.acc_head, .accordion_header, .smk-accordion .head, [class*="accordion"] > .acc_head'); 828 Array.prototype.forEach.call(heads, function (h) { 829 if (h.dataset.wcagAcc) { return; } 830 h.dataset.wcagAcc = '1'; 831 if (!h.getAttribute('role')) { h.setAttribute('role', 'button'); } 832 if (!h.hasAttribute('tabindex')) { h.setAttribute('tabindex', '0'); } 833 h.setAttribute('aria-expanded', String(isAccordionOpen(h))); 834 h.addEventListener('keydown', function (e) { 835 if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') { 836 e.preventDefault(); 837 h.click(); 838 } 839 }); 840 h.addEventListener('click', function () { 841 setTimeout(function () { h.setAttribute('aria-expanded', String(isAccordionOpen(h))); }, 60); 842 }); 843 }); 844 } 845 846 /* ------------------------------------------------------------------------ 847 BATCH G â Carousel keyboard + slide announcements 848 WCAG 2.1.1 Keyboard (A) + 4.1.3 Status Changes (AA) + 2.2.2
848(A, focus pause) 849 ------------------------------------------------------------------------ 850 - Bootstrap slide indicators (<li data-slide-to>) and Owl dots are not 851 focusable -> make them real buttons (Enter/Space activate). 852 - Announce "Slide X of N" via a hidden live region on slide change. 853 - Pause the auto-rotating banner while it (or its controls) has keyboard 854 focus, and resume on blur. 855 ------------------------------------------------------------------------ */ 856 function makeKbdButton(el, label) { 857 if (el.getAttribute('role') !== 'button') { el.setAttribute('role', 'button'); } 858 if (!el.hasAttribute('tabindex')) { el.setAttribute('tabindex', '0'); } 859 if (label && !el.getAttribute('aria-label')) { el.setAttribute('aria-label', label); } 860 if (!el.dataset.wcagKbd) { 861 el.dataset.wcagKbd = '1'; 862 el.addEventListener('keydown', function (e) { 863 if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') { 864 e.preventDefault(); 865 el.click(); 866 } 867 }); 868 } 869 } 870 function enhanceCarouselKeyboard() { 871 // Bootstrap indicators: roving-tabindex tablist (ONE tab stop, arrow keys 872 // move between slides) â avoids dumping 22 tab stops into the page. 873 // Indicator dots are decorative navigation, redundant with the prev/next 874 // controls. Previously we exposed them as role=tablist/tab, which made the 875 // screen reader announce "tab, slide 1" (R2 re-audit 4.1.2 New â flagged on 876 // ALL pages). Remove the carousel roles/labels and hide the dot group from 877 // assistive tech; keyboard users navigate via the labelled prev/next controls. 878 Array.prototype.forEach.call(document.querySelectorAll('.carousel-indicators'), function (group) { 879 if (group.dataset.wcagIndHidden) { return; } 880 group.dataset.wcagIndHidden = '1'; 881 group.setAttribute('aria-hidden', 'true'); 882 group.removeAttribute('role'); 883 group.removeAttribute('aria-label'); 884 Array.prototype.forEach.call(group.querySelectorAll('li, [data-slide-to], [data-bs-slide-to]'), function (li) { 885 li.removeAttribute('role'); 886 li.removeAttribute('aria-label'); 887 li.removeAttribute('aria-selected'); 888 li.removeAttribute('tabindex'); 889 }); 890 }); 891 // Owl dots: few, simple buttons are fine. 892 Array.prototype.forEach.call(document.querySelectorAll('.owl-page, .owl-dot'), function (d, i) { 893 if (d.offsetParent !== null) { makeKbdButton(d, 'Go to item ' + (i + 1)); } 894 }); 895 } 896 // Wire a Bootstrap carousel for manual-only slide announcements + focus pause. 897 // Used for BOTH the banner (#themeSlider) and the notice dialog (#popupCarousel, 898 // Home Sr74 â announce the slide when the user hits prev/next). 899 function wireCarouselAnnounce(ts) { 900 if (!ts || ts.dataset.wcagLive) { return; } 901 ts.dataset.wcagLive = '1'; 902 var slides = ts.querySelectorAll('.item, .carousel-item'); 903 if (!slides.length) { return; } 904 var live = document.createElement('div'); 905 live.className = 'wcag-sr-only'; 906 live.setAttribute('aria-live', 'polite'); 907 live.setAttribute('aria-atomic', 'true'); 908 ts.appendChild(live); 909 // Announce ONLY when the user manually navigates (prev/next/indicator/ 910 // keyboard). Auto-rotation must NOT announce, or the screen reader keeps 911 // interrupting with "Slide 14 of 22, Slide 15 of 22..." while the user is 912 // reading elsewhere (Home audit 4.1.3 New row). A manualNav flag is set on 913 // real interaction and consumed by the slide-change handler. 914 var manualNav = false, manualTimer = null; 915 function flagManual() { 916 manualNav = true; 917 if (manualTimer) { clearTimeout(manualTimer); } 918 manualTimer = setTimeout(function () { manualNav = false; }, 1500); 919 } 920 Array.prototype.forEach.call( 921 ts.querySelectorAll('.carousel-control, .carousel-control-prev, .carousel-control-next, .carousel-indicators [data-sli
921de-to], .carousel-indicators [data-bs-slide-to]'), 922 function (c) { 923 c.addEventListener('click', flagManual); 924 c.addEventListener('keydown', function (e) { 925 if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar' || e.key.indexOf('Arrow') === 0) { flagManual(); } 926 }); 927 } 928 ); 929 function announce() { 930 if (!manualNav) { return; } // suppress auto-rotation chatter 931 manualNav = false; 932 var a = ts.querySelector('.item.active, .carousel-item.active'); 933 var idx = Array.prototype.indexOf.call(slides, a) + 1; 934 if (idx <= 0) { return; } 935 // Prefer the active slide's own description (image alt / heading) so the 936 // dialog announces the slide's content, not just a number (Sr74). 937 var img = a.querySelector('img[alt]'), h = a.querySelector('h1,h2,h3,h4,.title'); 938 var label = (img && img.getAttribute('alt').trim()) || (h && h.textContent.trim()) || ''; 939 live.textContent = (label ? label + ', ' : '') + 'slide ' + idx + ' of ' + slides.length; 940 } 941 if (window.jQuery) { 942 try { window.jQuery(ts).on('slid.bs.carousel', announce); } catch (e) { /* */ } 943 } 944 // Pause auto-rotation while keyboard focus is inside the carousel (2.2.2) 945 // so it can't announce/rotate under the user. Resume when focus leaves. 946 function pause() { if (window.jQuery) { try { window.jQuery(ts).carousel('pause'); } catch (e) {} } } 947 function resume() { if (!ts.contains(document.activeElement) && window.jQuery) { try { window.jQuery(ts).carousel('cycle'); } catch (e) {} } } 948 ts.addEventListener('focusin', pause); 949 ts.addEventListener('focusout', resume); 950 } 951 function enhanceBannerCarousel() { 952 wireCarouselAnnounce(document.getElementById('themeSlider')); 953 wireCarouselAnnounce(document.getElementById('popupCarousel')); 954 } 955 956 /* ------------------------------------------------------------------------ 957 BATCH H â Promote section-label divs to real headings 958 WCAG 1.3.1 Info and Relationships (A) 959 ------------------------------------------------------------------------ 960 Several section labels are styled <div>s, not headings ("Ideal for 961 people", "Good to Know", Digital World items, product feature titles). 962 Expose them to assistive tech as the right heading level (no visual 963 change). Levels chosen to sit under the page h1 / section h2. 964 ------------------------------------------------------------------------ */ 965 function promoteSectionHeadings() { 966 var MAP = [ 967 // "Ideal for people" / "Good to Know" are top-level right-column section 968 // headings under the product h1 â the auditor wants level 2 (product pages). 969 ['.right-box-base-heading', '2'], 970 ['.dw-heading', '3'], 971 ['.bluband-products-details-heading', '3'], 972 ['.blueband-products-details-heading', '3'] 973 ]; 974 MAP.forEach(function (m) { 975 Array.prototype.forEach.call(document.querySelectorAll(m[0]), function (el) { 976 if (!el.getAttribute('role') && (el.textContent || '').trim()) { 977 el.setAttribute('role', 'heading'); 978 el.setAttribute('aria-level', m[1]); 979 } 980 }); 981 }); 982 } 983 984 /* ------------------------------------------------------------------------ 985 Single-item lists -> remove list semantics (WCAG 1.3.1) 986 ------------------------------------------------------------------------ 987 Auditors flag a <ul>/<ol> that wraps exactly ONE <li> as "unnecessary list 988 markup" (e.g. product "Ideal for people"/"Good to Know" with one entry). 989 role="presentation" strips the "list, 1 item" announcement while keeping the 990 content readable. Scoped to MAIN content, skipping functional lists (nav, 991 breadcrumb, carousel indicators, the social list, and anything already given 992 a role such as a tablist/menu). 993 ------------------------------------------------------------------------ */ 994 // Remove <br> that sit as DIRECT children of a <ul>/<ol> â invalid markup an 995 // auditor flags as "list has children which are not allowed: br" (e.g. the 996 // Nodal Office address list). Harmless to strip; keeps the list valid. 997 function removeStrayListBreaks() { 998 var root = document.getElementById('main-content') || document.body; 999 Array.prototype.forEach.call(root.querySelectorAll('ul, ol'), function (list) {
1000 Array.prototype.slice.call(list.children).forEach(function (c) { 1001 if (c.tagName === 'BR') { list.removeChild(c); } 1002 }); 1003 }); 1004 } 1005 function demoteSingleItemLists() { 1006 var root = document.getElementById('main-content') || document.body; 1007 Array.prototype.forEach.call(root.querySelectorAll('ul, ol'), function (list) { 1008 if (list.getAttribute('role')) { return; } 1009 if (list.classList.contains('wcag-social-list')) { return; } 1010 if (list.closest('nav, [role="navigation"], .breadcrumb, .carousel-indicators, .nav-tabs, [role="tablist"], [role="menu"]')) { return; } 1011 var items = Array.prototype.filter.call(list.children, function (c) { return c.tagName === 'LI'; }); 1012 if (items.length === 1) { 1013 list.setAttribute('role', 'presentation'); 1014 items[0].setAttribute('role', 'presentation'); 1015 } 1016 }); 1017 } 1018 1019 /* ------------------------------------------------------------------------ 1020 BATCH R2-S â Service page heading levels (Services tab, 1.3.1) 1021 ------------------------------------------------------------------------ 1022 The service body is DB-authored HTML (@Html.Raw(Model.Description)) with 1023 CMS classes, so these heading-level bugs can't be fixed in the template â 1024 but we can correct the levels the screen reader announces at runtime: 1025 - the banner service title is already the single h1 (fixBannerHeadingsAndOrder); 1026 - "Our Services"/"Service Rules" are authored as <h1 class="section-title-h-one"> 1027 â demote to level 2 (only the page title may be h1); 1028 - the service tagline ("Forex made easy for you!") is an <h3> inside 1029 .pro-det-sub-title â the auditor wants it at level 2, directly under h1. 1030 Scoped to these exact CMS classes, so pages without them are untouched. 1031 ------------------------------------------------------------------------ */ 1032 function fixServiceContentHeadings() { 1033 var banner = document.querySelector('.inner-pg-banner'); 1034 if (!banner) { return; } 1035 // "Our Services"/"Service Rules" are DB-authored as real <h1> tags. Because 1036 // they are h1 TAGS, ensureH1()/fixBannerHeadingsAndOrder() think the page 1037 // already has an h1 and skip promoting the banner title â which would leave 1038 // NO level-1 heading once we demote them. So ONLY when such content <h1>s 1039 // exist, promote the banner service title to level 1 and demote the content 1040 // h1s to level 2 (exactly one h1 = the page title). Scoped this way so other 1041 // page types, where fixBannerHeadingsAndOrder already handles the title, are 1042 // left untouched. 1043 var contentH1s = document.querySelectorAll('.section-title-h-one'); 1044 if (contentH1s.length) { 1045 var bannerTitle = banner.querySelector('.inner-pg-banner-text-wrap h1, .inner-pg-banner-text-wrap h2, .inner-pg-banner-text-wrap h3, .carousel-caption h3, .carousel-caption h2'); 1046 if (bannerTitle) { 1047 bannerTitle.setAttribute('role', 'heading'); 1048 bannerTitle.setAttribute('aria-level', '1'); 1049 } 1050 Array.prototype.forEach.call(contentH1s, function (el) { 1051 if ((el.textContent || '').trim()) { 1052 el.setAttribute('role', 'heading'); 1053 el.setAttribute('aria-level', '2'); 1054 } 1055 }); 1056 } 1057 Array.prototype.forEach.call( 1058 document.querySelectorAll('.pro-det-sub-title h1, .pro-det-sub-title h2, .pro-det-sub-title h3, .pro-det-sub-title h4, .pro-det-sub-title h5, .pro-det-sub-title h6'), 1059 function (el) { 1060 el.setAttribute('role', 'heading'); 1061 el.setAttribute('aria-level', '2'); 1062 } 1063 ); 1064 } 1065 1066 function init() { 1067 try { 1068 promoteSectionHeadings(); 1069 } catch (e) { /* never break the page */ } 1070 try { 1071 fixServiceContentHeadings(); 1072 } catch (e) { /* never break the page */ } 1073 try { 1074 removeStrayListBreaks(); 1075 } catch (e) { /* never break the page */ } 1076 try { 1077 demoteSingleItemLists(); 1078 } catch (e) { /* never break the page */ } 1079 try { 1080 enhanceCarouselKeyboard(); 1081 enhanceBannerCarousel(); 1082 setTimeout(enhanceCarouselKeyboard, 800); 1083 } catch (e) { /* never break the page */ } 1084 try { 1085 enhanceAccordions(); 1086 } catch (e) { /* never break the page */ } 1087 try { 1088 enhanceBreadcrumbs(); 1089 } catch (e) { /* never break the page */ } 1090 try { 1091 enhanceEnquireLinks(); 1092 } catch (e) { /* never break the page */ } 1093 try { 1094 enhanceImages(); 1095 } catch (e) { 1096 /* never let remediation throw and break the page */ 1097 } 1098 try { 1099 demoteStrayTickerHeadings(); 1100 } catch (e) { 1101 /* never let remediation throw and break the page */ 1102 } 1103 try { 1104 demoteEmptyHeadings(); 1105 } catch (e) { 1106 /* never let remediation throw and break the page */ 1107 } 1108 try { 1109 fixBannerHeadingsAndOrder(); 1110 } catch (e) { 1111 /* never let remediation throw and break the page */ 1112 } 1113 try { 1114 ensureH1(); 1115 } catch (e) { 1116 /* never let remediation throw and break the page */ 1117 } 1118 try { 1119 enhanceDialogs(); 1120 } catch (e) { 1121 /* never let remediation throw and break the page */ 1122 } 1123 try { 1124 enhanceInlineDialogs(); 1125 } catch (e) { 1126 /* never let remediation throw and break the page */ 1127 } 1128 try { 1129 enhanceFancyboxDialogs(); 1130 } catch (e) { 1131 /* never let remediation throw and break the page */ 1132 } 1133 try { 1134 enhanceForms(); 1135 } catch (e) { 1136 /* never let remediation throw and break the page */ 1137 } 1138 try { 1139 labelUnlabeledFields(); 1140 } catch (e) { 1141 /* never let remediation throw and break the page */ 1142 } 1143 try { 1144 enhanceExternalLinks(); 1145 } catch (e) { 1146 /* never let remediation throw and break the page */ 1147 } 1148 try { 1149 initTickerControls(); 1150 setTickerSpeed(); 1151 // Re-measure after late layout (fonts/images) and on resize. 1152 setTimeout(setTickerSpeed, 1200); 1153 if (window.addEventListener) { 1154 var tkT; 1155 window.addEventListener('resize', function () { 1156 clearTimeout(tkT); 1157 tkT = setTimeout(setTickerSpeed, 250); 1158 }); 1159 } 1160 } catch (e) { 1161 /* never let remediation throw and break the page */ 1162 } 1163 try { 1164 initSelectLabels(); 1165 } catch (e) { 1166 /* never let remediation throw and break the page */ 1167 } 1168 try { 1169 enhanceCarousels(); 1170 // Owl builds nav after its own init; re-apply shortly after. 1171 setTimeout(enhanceCarousels, 600); 1172 setTimeout(enhanceCarousels, 1800); 1173 } catch (e) { 1174 /* never let remediation throw and break the page */ 1175 } 1176 try { 1177 enhanceContentTabs(); 1178 } catch (e) { 1179 /* never let remediation throw and break the page */ 1180 } 1181 try { 1182 enhanceVagueLinks(); 1183 setTimeout(enhanceVagueLinks, 1800); 1184 } catch (e) { 1185 /* never let remediation throw and break the page */ 1186 } 1187 try { 1188 enhanceHeaderMenuTabs(); 1189 } catch (e) { /* never break the page */ } 1190 try { 1191 initAccessibilityWidget(); 1192 // Accessibility.js builds/updates the widget after its own init. 1193 setTimeout(initAccessibilityWidget, 800); 1194 } catch (e) { /* never break the page */ } 1195 try { 1196 enhanceMenuDialog(); 1197 } catch (e) { /* never break the page */ } 1198 } 1199 1200 /* ======================================================================== 1201 BATCH R2-H â Header mega-menu tabs: full WAI-ARIA tabs keyboard pattern 1202 WCAG 1.3.1, 4.1.2, 2.1.1 (Header-Footer Sr9/Sr11 + New tab-state rows) 1203 ------------------------------------------------------------------------ 1204 The markup already carries role="tablist" / role="tab" (on the <a>) / 1205 role="tabpanel", and the <li> now have role="presentation". This adds the
1206 missing behaviour: a single tab stop (roving tabindex), arrow-key 1207 navigation, aria-selected kept in sync, and strips any aria-expanded that 1208 Bootstrap's tab plugin leaves on the tabs (not valid in the Tabs pattern). 1209 ======================================================================== */ 1210 function enhanceHeaderMenuTabs() { 1211 var tablist = document.getElementById('nav-tab'); 1212 if (!tablist || tablist.dataset.wcagTabs) return; 1213 var tabs = Array.prototype.slice.call(tablist.querySelectorAll('[role="tab"]')); 1214 if (!tabs.length) return; 1215 tablist.dataset.wcagTabs = '1'; 1216 1217 // Each tab panel wraps its product links in a <nav>; unlabeled, they become 1218 // several anonymous "navigation" landmarks a screen reader can't tell apart 1219 // (Home audit New row). Name each from its tab so they are distinct. 1220 Array.prototype.forEach.call(document.querySelectorAll('#nav-tabContent [role="tabpanel"]'), function (panel) { 1221 var nav = panel.querySelector('nav'); 1222 if (nav && !nav.getAttribute('aria-label')) { 1223 var lbId = panel.getAttribute('aria-labelledby'); 1224 var tab = lbId && document.getElementById(lbId); 1225 var nm = tab ? tab.textContent.replace(/\s+/g, ' ').trim() : ''; 1226 nav.setAttribute('aria-label', nm ? nm + ' products' : 'Products'); 1227 } 1228 }); 1229 1230 function selected(tab) { return tab.getAttribute('aria-selected') === 'true'; } 1231 function syncRoving() {
1232 tabs.forEach(function (t) { 1233 // aria-expanded is not part of the Tabs pattern â remove it. 1234 t.removeAttribute('aria-expanded'); 1235 t.setAttribute('tabindex', selected(t) ? '0' : '-1'); 1236 }); 1237 } 1238 function activate(tab, focus) { 1239 tabs.forEach(function (t) { t.setAttribute('aria-selected', t === tab ? 'true' : 'false'); }); 1240 syncRoving(); 1241 if (focus) tab.focus(); 1242 // Let the site's Bootstrap tab plugin actually switch the panel. 1243 if (window.jQuery) { try { window.jQuery(tab).tab('show'); } catch (e) {} } 1244 } 1245 syncRoving(); 1246 // Keep aria-selected correct if the panel is switched by mouse/Bootstrap. 1247 tabs.forEach(function (tab) { 1248 tab.addEventListener('click', function () { setTimeout(syncRoving, 0); }); 1249 tab.addEventListener('keydown', function (e) { 1250 var i = tabs.indexOf(tab), n = tabs.length, next = null; 1251 switch (e.key) { 1252 case 'ArrowRight': case 'ArrowDown': next = tabs[(i + 1) % n]; break; 1253 case 'ArrowLeft': case 'ArrowUp': next = tabs[(i - 1 + n) % n]; break; 1254 case 'Home': next = tabs[0]; break; 1255 case 'End': next = tabs[n - 1]; break; 1256 default: return; 1257 } 1258 e.preventDefault(); 1259 activate(next, true); 1260 }); 1261 }); 1262 } 1263 1264 /* ======================================================================== 1265 BATCH R2-H â Accessibility Options widget semantics 1266 WCAG 4.1.2 / 2.1.1 (Header-Footer Sr35/Sr38/Sr77/Sr78/Sr79/Sr80 + New) 1267 ------------------------------------------------------------------------ 1268 Accessibility.js (re-enabled) provides the widget FUNCTIONALITY. This 1269 layer fixes the SEMANTICS the auditor flagged without touching that logic: 1270 (1) each feature button's accessible name is set from its visible label 1271 (they all wrongly shared aria-label="Enable the UserWay screen reader"); 1272 (2) aria-pressed is mirrored from the widget's own "tick"/active state via a 1273 MutationObserver, so screen readers hear the true on/off state; 1274 (3) the div-based trigger and close controls get keyboard activation 1275 (Enter/Space) â they already have role="button"/tabindex in markup. 1276 ======================================================================== */ 1277 function initAccessibilityWidget() { 1278 var widget = document.getElementById('uw-main'); 1279 if (!widget) return; 1280 1281 // (1)+(2) Feature buttons: correct name + live pressed state. 1282 var btns = widget.querySelectorAll('.uwaw-features__item__i'); 1283 Array.prototype.forEach.call(btns, function (btn) { 1284 var nameEl = btn.querySelector('.uwaw-features__item__name'); 1285 var name = nameEl ? nameEl.textContent.replace(/\s+/g, ' ').trim() : ''; 1286 if (name) { btn.setAttribute('aria-label', name); btn.removeAttribute('data-uw-reader-content'); } 1287 var tick = btn.querySelector('.tick-active, .uwaw-features__item__enabled'); 1288 function syncPressed() { 1289 var on = false; 1290 if (tick) { var d = window.getComputedStyle(tick).display; on = d !== 'none'; } 1291 if (btn.classList.contains('active') || btn.parentElement.classList.contains('active')) on = true; 1292 btn.setAttribute('aria-pressed', on ? 'true' : 'false'); 1293 } 1294 if (!btn.hasAttribute('aria-pressed')) btn.setAttribute('aria-pressed', 'false'); 1295 syncPressed(); 1296 if (tick && !btn.dataset.wcagPressObs) { 1297 btn.dataset.wcagPressObs = '1'; 1298 try { 1299 new MutationObserver(syncPressed).observe(tick, { attributes: true, attributeFilter: ['style', 'class'] }); 1300 } catch (e) {} 1301 btn.addEventListener('click', function () { setTimeout(syncPressed, 60); }); 1302 } 1303 }); 1304 1305 // (3) Keyboard activation for the div-based trigger + close controls. 1306 function keyActivate(el) { 1307 if (!el || el.dataset.wcagKey) return; 1308 el.dataset.wcagKey = '1'; 1309 el.addEventListener('keydown', function (e) { 1310 if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') { 1311 e.preventDefault(); el.click(); 1312 } 1313 }); 1314 } 1315 keyActivate(document.getElementById('uw-widget-custom-trigger')); 1316 keyActivate(document.getElementById('uw-widget-custom-trigger2')); 1317 1318 // (4) Dialog focus management (R3 re-audit Sr19 SC 1.3.2 + Sr49 SC 2.4.3, New/Critical): 1319 // when the panel opens, move focus INTO it (the Close button); trap Tab within 1320 // it while open; Esc closes and returns focus to the trigger. The panel 1321 // (#uw-main) already carries role=dialog / aria-modal / aria-label in markup 1322 // and is display:none when closed (so it is out of the AT tree until opened). 1323 var uwTrigger = document.getElementById('uw-widget-custom-trigger'); 1324 var uwClose = document.getElementById('uw-widget-custom-trigger2'); 1325 function uwOpen() { return window.getComputedStyle(widget).display !== 'none'; } 1326 function uwFocusables() { 1327 return Array.prototype.filter.call( 1328 widget.querySelectorAll('a[href], button:not([disabled]), input:not([disabled]), [role="button"], [tabindex]:not
1328([tabindex="-1"])'), 1329 function (el) { return el.offsetParent !== null; } 1330 ); 1331 } 1332 if (!widget.dataset.wcagDlgFocus) { 1333 widget.dataset.wcagDlgFocus = '1'; 1334 try { 1335 new MutationObserver(function () { 1336 if (uwOpen() && !widget.dataset.wcagWasOpen) { 1337 widget.dataset.wcagWasOpen = '1'; 1338 setTimeout(function () { if (uwClose && uwOpen()) uwClose.focus(); }, 60); 1339 } else if (!uwOpen() && widget.dataset.wcagWasOpen) { 1340 widget.dataset.wcagWasOpen = ''; 1341 if (uwTrigger) uwTrigger.focus(); 1342 } 1343 }).observe(widget, { attributes: true, attributeFilter: ['style', 'class'] }); 1344 } catch (e) {} 1345 widget.addEventListener('keydown', function (e) { 1346 if (e.key === 'Escape') { 1347 if (uwClose) uwClose.click(); 1348 if (uwTrigger) uwTrigger.focus(); 1349 return; 1350 } 1351 if (e.key !== 'Tab') return; 1352 var f = uwFocusables(); 1353 if (!f.length) return; 1354 var first = f[0], last = f[f.length - 1]; 1355 if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } 1356 else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } 1357 }); 1358 } 1359 } 1360 1361 /* ======================================================================== 1362 BATCH R2-H â "Menu" (Internet Banking) dialog + search: keyboard + state 1363 WCAG 2.1.1 / 4.1.2 / 1.3.2 (Header-Footer Sr36/Sr37/Sr46/Sr74/Sr75) 1364 ------------------------------------------------------------------------ 1365 The site's onlineloans.js/site-footer.js bind CLICK to the inner <i> of the 1366 hamburger and close controls, so keyboard users can't open/close the panel. 1367 We forward Enter/Space to the existing handlers (no logic duplicated), 1368 keep aria-expanded in sync, and gate the off-screen panel from assistive 1369 tech with aria-hidden until it is opened. 1370 ======================================================================== */ 1371 function enhanceMenuDialog() { 1372 var panel = document.querySelector('.right-side-slider-details-div'); 1373 if (!panel) return; 1374 var closeBtn = document.querySelector('.close_btn-div'); 1375 // There are MULTIPLE .nav-icon-wrap hamburger instances (responsive clones); 1376 // only one is visible at a given width. The old code wired only the FIRST 1377 // (which is display:none), so the VISIBLE hamburger had no keyboard handler 1378 // (R2 re-audit Sr38, SC 2.1.1). Wire EVERY instance. The site binds click to 1379 // the inner <i>, so we forward Enter/Space to that (verified: opens panel). 1380 var triggers = document.querySelectorAll('.nav-icon-wrap a'); 1381 var lastTrigger = null; 1382 function isOpen() { return panel.classList.contains('right-side-slider-details-div-show'); } 1383 function reflect() { 1384 panel.setAttribute('aria-hidden', isOpen() ? 'false' : 'true'); 1385 Array.prototype.forEach.call(triggers, function (t) { 1386 t.setAttribute('aria-expanded', isOpen() ? 'true' : 'false'); 1387 }); 1388 } 1389 if (panel.dataset.wcagDialog) { return; } 1390 panel.dataset.wcagDialog = '1'; 1391 Array.prototype.forEach.call(triggers, function (trigger) { 1392 var innerIcon = trigger.querySelector('i'); 1393 trigger.addEventListener('keydown', function (e) { 1394 if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') { 1395 e.preventDefault(); 1396 lastTrigger = trigger; 1397 if (innerIcon) { innerIcon.click(); } else { trigger.click(); } 1398 setTimeout(function () { reflect(); if (isOpen() && closeBtn) closeBtn.focus(); }, 40); 1399 } 1400 }); 1401 trigger.addEventListener('click', function () { 1402 lastTrigger = trigger; 1403 setTimeout(function () { reflect(); if (isOpen() && closeBtn) closeBtn.focus(); }, 40); 1404 }); 1405 }); 1406 if (closeBtn) { 1407 closeBtn.addEventListener('keydown', function (e) { 1408 if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') { 1409 e.preventDefault(); 1410 var ci = closeBtn.querySelector('i'); if (ci) { ci.click(); } else { closeBtn.click(); } 1411 } 1412 }); 1413 closeBtn.addEventListener('click', function () { 1414 setTimeout(function () { reflect(); if (!isOpen() && lastTrigger) lastTrigger.focus(); }, 40); 1415 }); 1416 } 1417 // Escape closes the dialog for keyboard users and restores focus. 1418 panel.addEventListener('keydown', function (e) { 1419 if (e.key === 'Escape' && isOpen()) { 1420 var ci = closeBtn && closeBtn.querySelector('i'); if (ci) { ci.click(); } else if (closeBtn) { closeBtn.click(); } 1421 if (lastTrigger) lastTrigger.focus(); 1422 } 1423 }); 1424 reflect(); 1425 } 1426 1427 if (document.readyState === 'loading') { 1428 document.addEventListener('DOMContentLoaded', init); 1429 } else { 1430 init(); 1431 } 1432})();
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.