1/*-----------------------------------------------------*\ 2 @GLOBAL variables 3\*-----------------------------------------------------*/ 4 5 /* Global constant isAccessibilityJSDefined defaults to true (used by Website Builder to determine if we're in the editor or on a group website) */ 6 const isAccessibilityJSDefined = true; 7 8 /* Global variable isAccessibilityJSVerbose 9 * ALWAYS set this to FALSE before committing code (to be phased out). 10 * Set to true for debug console output. 11 */ 12 var isAccessibilityJSVerbose = false; 13 14 /* Global variable isShiftTabNavigationPending used to signal/flag SHIFT-TAB has been pressed 15 * Used for back-navigation with the keyboard from (1) top of main-content back to left-navigation or (2) top of left-nav back to the top header 16 */ 17 var isShiftTabNavigationPending = false; 18 19 /* Global value cgTrapKeyboardTrapTabIndexReduction used when trapping keyboard functionality inside an iFrame or modal 20 * We remove keyboard functionality (outside the iFrame/modal) by subtracting this value from the tabindex of all elements outside the iFrame/modal 21 * We restore keyboard functionality (when the iFrame/modal is closed) by adding this value back to the tabindex of all other elements on the page 22 * Works well with multiple levels of modals open (ex. secondary modal opened from primary modal) 23 */ 24 const cgTrapKeyboardTrapTabIndexReduction = 1000; 25 26 /* Global array numberNames : The names of the numbers 0-19 27 * Used in getAriaNumberName(n) 28 */ 29 var numberNames = ['zero', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten', 'eleven', 'twelve', 'thirteen', 'fourteen', 'fifteen', 'sixteen', 'seventeen', 'eighteen', 'nineteen' ]; 30 31 /* Global array tensNames : The names of the "tens" component of a number (blank for numbers less than 20 as these don't have an associated "tens" name component) 32 * Used in getAriaNumberName(n) 33 */ 34 var tensNames = ['', '', 'twenty', 'thirty', 'fourty', 'fifty', 'sixty', 'seventy', 'eighty', 'ninety' ]; 35 36 /* Global variable setFocusOnTopMessagesEpoch used to prevent multiple calls to setFocusOnTopMessages() 37 * We'll only handle the first of N calls within a 100 millisecond timespan 38 */ 39 var setFocusOnTopMessagesEpoch = 0; 40 41 /* Global variable isClick set to true when an action is a click and false when it comes from keyboard 42 */ 43 var isClick = true; 44 45 /* Global stack for tracking open/close of modals, dialogs, iFrames, etc. 46 * ID of the modal/dialog/iFrame being opened is pushed onto dialogCallStack 47 * stack is popped in reverse order when associated dialogs are closed 48 * call stack is used to manage tabindex reduction for keyboard lock on the backsheet 49 * when AJAX calls return data that is rendered behind the top (active) popup 50 * NOTE: The same dialog ID can not be pushed twice in a row (you can't open a dialog on top of itself) 51 * If the dialog referenced by containerId is not on the top of the call stack, it's likely we're (accidentally) 52 * closing a modal, dialog or iFrame behind the top (active) dialog. In this case, we'll first attempt to properly close all open dialogs 53 * on top of the dialog requesting closure. 54 * FUTURE: a better solution in the future may be to properly handle ESCAPE and associated dialog close requests to either 55 * (i) always be caught by the top (active) dialog with stopPropogation OR 56 * (ii) indepdently (and properly) close all dialogs stacked on top of the dialog requesting closure - outside of this function 57 * Until we choose a better solution, we'll continue to manage multi-dialog closures within popDialogCallStack() below. 58 */ 59 var dialogCallStack = []; 60 61 /* Global counter to track consecutive mousedown events - if more than 3x mouse clicks we'll remove body.acc-keyboard-mode 62 * 63 */ 64 var countConsecutiveMousedowns = 0; 65 66 /* Global variable bypassFocusToMainContent set to true when opening an expandable sidebar menu. In keyboard mode, 67 * focus should move to the first sub-menu item inside an expandable menu when the menu is opened. 68 */ 69 var bypassFocusToMainContent = false; 70 71/*-----------------------------------------------------*\ 72 @LISTENERS AND BINDINGS 73 74 Some full document bindings and custom event listeners 75 to take appropriate actions when users appear to be 76 using the platform via keyboard or with a screen reader. 77\*-----------------------------------------------------*/ 78 79 document.addEventListener("DOMContentLoaded", function(event) { 80 81 document.addEventListener('mousedown', function() { 82 // A mousedown is a pointer interaction and fires before the resulting focusout, so flag 83 // it as a click here. Safari does not focus a link/button on click, so without this the 84 // topbar dropdown's focusout handler treats a mouse click on a result (after typing in the 85 // search box set isClick=false) as a keyboard tab-out and closes the dropdown before the 86 // click can navigate. See onDropdownFocusOut(). 87 isClick = true; 88 countConsecutiveMousedowns++; 89 if (countConsecutiveMousedowns >= 3) { 90 if (document.body.classList.contains('acc-keyboard-mode')) { 91 document.body.classList.remove('acc-keyboard-mode'); 92 } 93 } 94 }); 95 96 document.addEventListener('click', function() { 97 isClick = true; 98 }); 99 100 document.addEventListener('keydown', function(e) { 101 setTimeout(function () { 102 isClick = false; // all keydown's flag isClick=false (including ENTER) 103 if (isShiftTabKey(e) || isTabKey(e) || isArrowKey(e)) { 104 countConsecutiveMousedowns = 0; 105 if (!document.body.classList.contains('acc-keyboard-mode')) { 106 document.body.classList.add('acc-keyboard-mode'); 107 setupSlickAccKeyboardMode(); 108 } 109 } 110 }, 3); // wait for 'click' to be handled (will set isClick=true) - 3 ms works well in testing 111 }); 112 });
113 114 /* Custom event listeners */ 115 116 document.addEventListener('transitionSidebar', function(){ 117 if (isAccKeyboardMode()) { 118 bypassFocusToMainContent = true; 119 setupStrCheckboxForLists(); 120 setTimeout(function() { 121 if (isSidebarCollapsed()) { 122 navigateToActiveMenuItem(); 123 } 124 setFocusToSidebar(); 125 }, 1); 126 } 127 updateUserTagsBackgroundColors(); 128 updateBadgeBackgroundColors(); 129 setupA11yDragDropKeyboardSupport(); 130 }); 131 document.addEventListener('transitionContent', function(){ 132 if (isAccKeyboardMode()) { 133 setupStrCheckboxForLists(); 134 setFocusToContent(); 135 } 136 updateUserTagsBackgroundColors(); 137 updateBadgeBackgroundColors(); 138 setupA11yDragDropKeyboardSupport(); 139 }); 140 document.addEventListener('openModal', function(e){ 141 setModalDialogTitle(e.detail.modalId); 142 if (isAccKeyboardMode()) { 143 setFocusToModal(e.detail.modalId); 144 } 145 verifyKeyboardLock(); 146 updateUserTagsBackgroundColors();
147 updateBadgeBackgroundColors(); 148 setupA11yDragDropKeyboardSupport(); 149 }); 150 document.addEventListener('transitionModal', function(e){ 151 setModalDialogTitle(e.detail.modalId); 152 if (isAccKeyboardMode()) { 153 setFocusToModal(e.detail.modalId); 154 } 155 updateUserTagsBackgroundColors(); 156 updateBadgeBackgroundColors(); 157 setupA11yDragDropKeyboardSupport(); 158 }); 159 document.addEventListener('openDialog', function(e){ 160 setModalDialogTitle(e.detail.modalId); 161 if (isAccKeyboardMode()) { 162 setFocusToModal(e.detail.dialogId); 163 } 164 updateUserTagsBackgroundColors(); 165 updateBadgeBackgroundColors(); 166 setupA11yDragDropKeyboardSupport(); 167 }); 168 document.addEventListener('ajaxLoadMore', function(e){ 169 let parentContainerId = e.detail.parentContainerId; 170 if (isEmpty(parentContainerId)) { parentContainerId = null; } 171 setupAjaxAccessibilityForLoadMore(parentContainerId) 172 setupStrCheckboxForLists(); 173 updateUserTagsBackgroundColors();
174 updateBadgeBackgroundColors(); 175 }); 176 177/*-----------------------------------------------------*\ 178 @DOCUMENT READY LAUNCH and AJAX call returns 179 180 Calls to accessibility functions on page ready and 181 AJAX call returns. 182\*-----------------------------------------------------*/ 183 184 document.addEventListener("DOMContentLoaded", function() { 185 // KEYBOARD CALLS 186 handleKeyboardForTopbarDropdowns(); 187 188 // Restore keyboard navigation (remove keyboard trap from backsheet when the primary or secondary modal closes) 189 $("#primary-modal").on('hide.bs.modal', function () { terminatePopupAccessibility('primary-modal', 'modal'); }); 190 $("#secondary-modal").on('hide.bs.modal', function () { terminatePopupAccessibility('secondary-modal', 'modal'); }); 191 setAriaCurrentState(); 192 processAriaLive(); 193 setupStrCheckboxForLists(); 194 addAriaLabelToExpandableSidebarLinks(); 195 updateUserTagsBackgroundColors(); 196 updateBadgeBackgroundColors(); 197 setupTracksAndChecklistsAccessibility(); 198 setupA11yDragDropKeyboardSupport(); 199 }); 200 201 /* Function called to reset accessibility function on AJAX calls. */ 202 function setupAjaxAccessibility() { 203 addShiftTabListeners(); 204 addKeyboardClickListeners(); 205 setPageName(); 206 207 setupSkipToLeftNavigation(); 208 addAriaLabelsToOrderingSelectOptions(); 209 210 setupTooltipAccessibility(); 211 hideDecorativeImagesAndIconsFromScreenReader(); 212 setAriaCurrentState(); 213 addAriaLabelToExpandableSidebarLinks(); 214 setupTracksAndChecklistsAccessibility(); 215 setupA11yDragDropKeyboardSupport(); 216 } 217 218 /* Function called to hook in required accessibility for "Load More" AJAX calls 219 * OR standard AJAX calls that take longer to load and may not return before 220 * the user opens a dialog, modal or iFrame (i.e. new data loading on the backsheet 221 * after keyboard lock) 222 * 223 */ 224 function setupAjaxAccessibilityForLoadMore(parentContainerId) { 225 addKeyboardClickListeners(); 226 227 setupTooltipAccessibility(); 228 hideDecorativeImagesAndIconsFromScreenReader(); 229 verifyKeyboardLock(parentContainerId); // sync tabindex in regions behind modal/dialog on late load (or live/active areas) 230 setupA11yDragDropKeyboardSupport(); 231 } 232 233/*-----------------------------------------------------*\ 234 @SPECTRUM Color Picker 235\*-----------------------------------------------------*/ 236 237 function a11yHandleSpectrumPaletteColorClick() { 238 setTimeout(function() { 239 // after spectrum has refreshed the palette buttons in the DOM we'll add the a11y support back 240 addSpectrumColorPickerA11YSupport(); 241 }, 100); 242 } 243 244 function a11yHandleSpectrumPaletteColorKeyup(e) { 245 if (e.key === 'Enter') { 246 $(this)[0].click(); 247 // spectrum has flushed and refreshed the DOM palette elements and the click listener no longer exists -- call manually 248 a11yHandleSpectrumPaletteColorClick(); 249 } 250 } 251 252 function addSpectrumColorPickerA11YSupport() { 253 let paletteButtons = $('.sp-container:not(.sp-hidden)').find('.sp-palette-row .sp-thumb-el'); 254 for (var i=0; i<paletteButtons.length; i++) { 255 let paletteButton = paletteButtons[i]; 256 $(paletteButton).attr('tabindex', '0'); 257 $(paletteButton).attr('role', 'button'); 258 259 let pbTitle = $(paletteButton).attr('title'); // the title is the hex color of the palette button 260 let isPbThumbLight = (getColorContrastRatio(pbTitle, '#ffffff') < 4.5) ? true : false; 261 if (isPbThumbLight) { 262 // white text has low contrast - use dark text 263 $(paletteButton).addClass('sp-thumb-z6'); 264 } else { 265 // use light text 266 $(paletteButton).addClass('sp-thumb-f6'); 267 } 268 let pbAriaLabel = 'hex color'; 269 for (var j=0; j<pbTitle.length; j++) { 270 pbAriaLabel += ' ' + pbTitle[j]; 271 } 272 $(paletteButton).attr('aria-label', pbAriaLabel); 273 274 // click required for screen reader (ex. NVDA) 275 paletteButton.removeEventListener('click', a11yHandleSpectrumPaletteColorClick); 276 paletteButton.addEventListener('click', a11yHandleSpectrumPaletteColorClick); 277 278 // keyup checks for Enter key (both with/without screen reader) 279 paletteButton.removeEventListener('keyup', a11yHandleSpectrumPaletteColorKeyup); 280 paletteButton.addEventListener('keyup', a11yHandleSpectrumPaletteColorKeyup); 281 282 let isCurrentSelection = ($(paletteButton).closest('.sp-palette-row-selection').length > 0); 283 if (isCurrentSelection) { 284 // the current database color 285 $(paletteButton).attr('aria-current', 'true'); 286 } 287 if ($(paletteButton).hasClass('sp-thumb-active')) { 288 // the actively selected palette color (if any) 289 $(paletteButton).attr('aria-label', pbAriaLabel + ', selected'); 290 if (!isCurrentSelection) { 291 $(paletteButton)[0].focus(); 292 a11yAnnounceToScreenReader($(paletteButton).attr('aria-label'));
293 } 294 } 295 296 } 297 } 298 299/*-----------------------------------------------------*\ 300 @CONTRAST RATIO 301 302 Calculate color contrast ratio between text and background (or between two colors) 303 304\*-----------------------------------------------------*/ 305 306 /* 307 * Function that converts a color value into an {r, g, b, a} property set where r,g,b are in the range 0-255 and a is between 0 and 1 308 * @param color : A color value such as #112233 or rgba(255, 100, 33, 1) or hsla(360, 0.5, 0.3, 1) or hsla(360, 50%, 30%, 1) 309 * 310 * Properties included in the return value: 311 * 312 * r = red 313 * g = green 314 * b = blue 315 * a = alpha 316 * isRGBA = true 317 * 318 * The values r, g and b are in the range 0-255 and a is in the range 0-1. 319 * Also includes property isRGBA set to true. 320 */ 321 function getRGBA(color) { 322 if (isEmpty(color)) { console.error('color is empty'); return; } 323 if (color.isRGBA === true) { return color; } 324 if (color.isHSLA === true) { return convertHSLAToRGBA(color); } 325 if (!isString(color)) { console.error('color is not valid'); return; } 326 if (color.startsWith('hsl')) { return convertHSLAToRGBA(color); } 327 328 let r = 0; 329 let g = 0; 330 let b = 0; 331 let a = 1.0; 332 333 if (color.startsWith('rgb')) { 334 color = color.replace('rgb(', '').replace('rgba(', '').replace(')', ''); 335 color = color.split(','); 336 r = parseInt(color[0]); 337 g = parseInt(color[1]); 338 b = parseInt(color[2]); 339 if (color.length === 4) { 340 a = parseFloat(color[3]); 341 } 342 } else { 343 if (color.startsWith('#')) { 344 color = color.substring(1); 345 } 346 if (color.length<6) { 347 console.error('> color value is invalid for color=' + color); 348 return; 349 } 350 351 r = '' + color[0] + color[1]; 352 g = '' + color[2] + color[3] 353 b = '' + color[4] + color[5] 354 a = 'ff'; 355 if (color.length==8) { 356 a = '' + color[6] + color[7] 357 } 358 359 r = parseInt(r, 16); 360 g = parseInt(g, 16); 361 b = parseInt(b, 16); 362 a = parseInt(a, 16); 363 364 a /= 255; 365 } 366 367 return { r: r, g: g, b: b, a: a, isRGBA : true }; 368 } 369 370 /* 371 * Function that converts an RGBA color value to an HSLA property set. 372 * @param rgba : rgba color value in string or RGBA format 373 * 374 * Properties in return value: 375 * 376 * h = Hue 377 * s = Saturation 378 * l = Lightness 379 * a = Alpha 380 * isHSLA = true 381 * 382 */ 383 function convertRGBAToHSLA(color) { 384 let rgba = getRGBA(color); 385 if (isEmpty(rgba) || rgba.isRGBA !== true) { 386 console.error('rgba is invalid'); 387 return; 388 } 389 390 /* 391 * CONVERT rgb values to [0,1] 392 */ 393 let r = rgba.r / 255; 394 let g = rgba.g / 255; 395 let b = rgba.b / 255; 396 let a = rgba.a; 397 398 let cMin = Math.min(r, g, b); 399 let cMax = Math.max(r, g, b); 400 let delta = cMax - cMin; 401 402 let h = 0; 403 let s = 0; 404 let l = 0; 405 406 /* 407 * CALCULATE Hue 408 */ 409 if (delta === 0) { 410 // h = 0 411 } else if (cMax === r) { 412 h = ((g - b) / delta) % 6; 413 } else if (cMax === g) { 414 h = (b - r) / delta + 2; 415 } else if (cMax === b) { 416 h = (r - g) / delta + 4; 417 } 418 h = Math.round(h * 60); 419 if (h < 0) { 420 h += 360; 421 } 422 423 /* 424 * CALCULATE Lightness 425 */ 426 l = (cMax + cMin) / 2; 427 428 /* 429 * CALCULATE Saturation 430 */ 431 if (delta === 0) { 432 s = 0; 433 } else { 434 s = delta / (1 - Math.abs(2 * l - 1)); 435 } 436 437 return { h: h, s: s, l: l, a: a, isHSLA: true }; 438 } 439 440 /* Function that converts a color value into an {h, s, l, a} property set. 441 * @param color : A color value such as #112233 or rgba(255, 100, 33, 1) or hsla(360, 0.5, 0.3, 1) or hsla(360, 50%, 30%, 1) 442 * 443 * Properties in return value: 444 * 445 * h = Hue 446 * s = Saturation 447 * l = Lightness 448 * a = Alpha 449 * isHSLA = true 450 * 451 * Where h is in the range [0,360] and {s, l, a} are in the range [0, 1] 452 * Also includes property isHSLA set to true. 453 */ 454 function getHSLA(color) { 455 if (isEmpty(color)) { console.error('color is empty'); return; } 456 if (color.isHSLA === true) { return color; } 457 if (color.isRGBA === true) { return convertRGBAToHSLA(color); }
458 if (!isString(color)) { console.error('color is not valid'); return; } 459 460 if (color.startsWith('rgb') || color.startsWith('#')) { 461 return convertRGBAToHSLA(color); 462 } else if (color.startsWith('hsl') && color.includes('(') && color.includes(')')) { 463 let sx = color.indexOf('(') + 1; 464 let ex = color.indexOf(')'); 465 let parts = color.substring(sx, ex); 466 parts = parts.split(','); 467 let h = Math.floor(parseFloat(parts[0])); 468 let s = 0; 469 if (parts[1].includes('%')) { 470 s = 0.01 * parseFloat(parts[1].replace('%', '')); 471 } else { 472 s = parseFloat(parts[1]); 473 } 474 let l = 0; 475 if (parts[2].includes('%')) { 476 l = 0.01 * parseFloat(parts[2].replace('%', '')); 477 } else { 478 l = parseFloat(parts[2]); 479 } 480 let a = 1; 481 if (parts.length > 3) { 482 a = parseFloat(parts[3]); 483 } 484 return { h: h, s: s, l: l, a: a, isHSLA: true }; 485 } 486 } 487 488 /* Function that converts an HSLA color to RGBA format. 489 * @param color : a color value 490 * 491 * Properties included in the return value: 492 * 493 * r = red 494 * g = green 495 * b = blue 496 * a = alpha 497 * isRGBA = true 498 * 499 * The values r, g and b are in the range 0-255 and a is in the range 0-1. 500 * Also includes property isRGBA set to true. 501 */ 502 function convertHSLAToRGBA(color) { 503 let hsla = getHSLA(color); 504 if (isEmpty(hsla) || hsla.isHSLA !== true) { 505 console.error('> hsla is invalid'); 506 return; 507 } 508 509 let C = (1 - Math.abs(2 * hsla.l - 1)) * hsla.s; 510 let X = C * (1 - Math.abs((hsla.h / 60) % 2 - 1)); 511 let m = hsla.l - C / 2; 512 513 let r0 = 0; 514 let g0 = 0; 515 let b0 = 0; 516 if (hsla.h < 60) { 517 r0 = C; 518 g0 = X; 519 b0 = 0; 520 } else if (hsla.h < 120) { 521 r0 = X; 522 g0 = C; 523 b0 = 0; 524 } else if (hsla.h < 180) { 525 r0 = 0; 526 g0 = C; 527 b0 = X; 528 } else if (hsla.h < 240) { 529 r0 = 0; 530 g0 = X; 531 b0 = C; 532 } else if (hsla.h < 300) { 533 r0 = X; 534 g0 = 0; 535 b0 = C; 536 } else if (hsla.h < 360) { 537 r0 = C; 538 g0 = 0; 539 b0 = X; 540 } 541 let r = (r0 + m) * 255; 542 let g = (g0 + m) * 255; 543 let b = (b0 + m) * 255; 544 545 if (r > 255) { r = 255; } 546 if (g > 255) { g = 255; } 547 if (b > 255) { b = 255; } 548 549 let rgba = {r: Math.floor(r + 0.5), g: Math.floor(g + 0.5), b: Math.floor(b + 0.5), a: hsla.a, isRGBA: true}; 550 return rgba; 551 } 552 553 /* 554 * Function that calculates the relative Luminance for a given RGBA value normalized to values between 0 and 1 555 * @param color : a color value in hex format, rgb(), rgba(), hsla() etc. 556 */ 557 function getRelativeLuminance(color) { 558 color = getRGBA(color); 559 if (isEmpty(color)) { console.error('color is empty'); return; } 560 if (color.isRGBA !== true) { console.error('color is invalid'); return; } 561 562 let r = color.r / 255; 563 let g = color.g / 255; 564 let b = color.b / 255; 565 566 let R = 0; 567 if (r < 0.03928) { 568 R = r / 12.92; 569 } else { 570 R = Math.pow((r + 0.055) / 1.055, 2.4); 571 } 572 573 let G = 0; 574 if (g < 0.03928) { 575 G = g / 12.92; 576 } else { 577 G = Math.pow((g + 0.055) / 1.055, 2.4); 578 } 579 580 let B = 0; 581 if (b < 0.03928) { 582 B = b / 12.92; 583 } else { 584 B = Math.pow((b + 0.055) / 1.055, 2.4); 585 } 586 587 let L = 0.2126 * R + 0.7152 * G + 0.0722 * B; 588 589 return L; 590 } 591 592 /* Function that returns the visible RGB value represented by an RGBA foreground color (with alpha between 0 and 1) on an RGB background (with alpha 1.0) */
593 function getVisibleRGB(rgbaForeground, rgbaBackground) { 594 rgbaForeground = getRGBA(rgbaForeground); 595 rgbaBackground = getRGBA(rgbaBackground); 596 if (isEmpty(rgbaForeground)) { console.error('> ERROR rgbaForeground is empty'); return; } 597 if (isEmpty(rgbaBackground)) { console.error('> ERROR rgbaBackground is empty'); return; } 598 if (rgbaBackground.a < 1) { 599 rgbaBackground = getVisibleRGB(rgbaBackground, '#ffffff'); 600 } 601 602 let oneMinusA = 1.0 - rgbaForeground.a; 603 let r = rgbaForeground.r * rgbaForeground.a + rgbaBackground.r * oneMinusA; 604 let g = rgbaForeground.g * rgbaForeground.a + rgbaBackground.g * oneMinusA; 605 let b = rgbaForeground.b * rgbaForeground.a + rgbaBackground.b * oneMinusA; 606 607 r = Math.min(255, Math.max(0, Math.round(r))); 608 g = Math.min(255, Math.max(0, Math.round(g))); 609 b = Math.min(255, Math.max(0, Math.round(b))); 610 611 return getRGBA('rgb(' + r + ',' + g + ',' + b + ')'); 612 } 613 614 /* 615 * Function that returns the contrast ratio between two color value. This implementation matches the WAVE Plugin implementation as of March 2022. 616 * @param color1 : foreground color (usually text or icon) 617 * @param color2 : background color 618 * 619 * Returns a value between 1 and 21. For example, the standard WCAG Level AA color contrast ratio minimum is 4.5 for regular text and 3.0 for large text. 620 */ 621 function getColorContrastRatio(color1, color2) { 622 color1 = getVisibleRGB(color1, color2); 623 var rlum1 = getRelativeLuminance(color1); 624 if (isEmpty(rlum1)) { 625 console.error('rlum1 is empty'); 626 return; 627 } 628 var rlum2 = getRelativeLuminance(color2); 629 if (isEmpty(rlum2)) { 630 console.error('rlum2 is empty'); 631 return; 632 } 633 let max = Math.max(rlum1, rlum2); 634 let min = Math.min(rlum1, rlum2); 635 return (max + 0.05) / (min + 0.05); 636 } 637 638 /* Function that returns a text string usable as a color value in element.style such as background-color 639 * @param color : a color value 640 * 641 * Returns an rgba(r, g, b, a) or hsla(h, s, l, a) color value depending on the value passed in color. 642 */ 643 function getHtmlColorText(color, useHexFormat) { 644 if (color.isHSLA === true && useHexFormat !== true) { 645 let hText = '' + (Math.floor(100 * color.h + 0.5) / 100); 646 let sText = '' + (Math.floor(10000 * color.s + 0.5) / 100) + '%'; 647 let lText = '' + (Math.floor(10000 * color.l + 0.5) / 100) + '%'; 648 let aText = '' + (Math.floor(1000 * color.a + 0.5) / 1000); 649 let htmlColorText = 'hsla(' + hText + ', ' + sText + ', ' + lText + ', ' + aText + ')'; 650 return htmlColorText; 651 } 652 color = getRGBA(color); 653 if (isEmpty(color) || color.isRGBA !== true) { console.error('color is invalid'); return; } 654 655 if (useHexFormat === true) { 656 let rText = '' + color.r.toString(16); 657 let gText = '' + color.g.toString(16); 658 let bText = '' + color.b.toString(16); 659 let aText = ''; 660 if (rText.length < 2) { rText = '0' + rText; } 661 if (gText.length < 2) { gText = '0' + gText; } 662 if (bText.length < 2) { bText = '0' + bText; } 663 if (color.a < 1) { 664 let a255 = Math.floor(color.a * 255); 665 if (a255 > 255) { 666 a255 = 255; 667 } else if (a255 < 0) { 668 a255 = 0; 669 } 670 aText = a255.toString(16); 671 if (aText.length < 2) { 672 aText = '0' + aText; 673 } 674 } 675 return '#' + rText + gText + bText + aText; 676 } 677 678 let aText = '' + (Math.floor(1000 * color.a + 0.5) / 1000); 679 return 'rgba(' + color.r + ', ' + color.g + ', ' + color.b + ', ' + aText + ')'; 680 } 681 682/*-----------------------------------------------------*\ 683 @KEYBOARD 684 685 Setup and manage keyboard events 686 Functions to check what key is pressed 687 688\*-----------------------------------------------------*/ 689 690 /* Function that returns true if the current user appears to be using keyboard navigation and/or a screen reader 691 */ 692 function isAccKeyboardMode() { 693 return $("body").hasClass("acc-keyboard-mode"); 694 } 695 696 // DROPDOWN 697 /* Function called to handle keyboard navigation for dropdown in the topbar. */ 698 function handleKeyboardForTopbarDropdowns() { 699 700 // $(this) is either $topbarButton or $topbarDropdownContainer 701 // Need setTimeout because document.activeElement becomes body before focus is set 702 function onDropdownFocusOut(e) { 703 if (!isClick) { 704 $topbarFocusOutElement = $(this); 705 setTimeout(function () { 706 if ($topbarFocusOutElement.prop("tagName") == "BUTTON") { 707 let topbarDropdownContainer = $topbarFocusOutElement.siblings("ul")[0]; 708 if (!(topbarDropdownContainer == document.activeElement || topbarDropdownContainer.contains(document.activeElement))) { 709 $topbarFocusOutElement.parent().removeClass("open"); 710 } 711 } 712 else if ($topbarFocusOutElement.prop("tagName") == "UL") { 713 topbarButton = $topbarFocusOutElement.siblings("button")[0]; 714 if (!(topbarButton == document.activeElement || $topbarFocusOutElement[0].contains(document.activeElement))) { 715 $topbarFocusOutElement.parent().removeClass("open"); 716 } 717 } 718 }, 50, $topbarFocusOutElement); 719 } 720 } 721 $("#topbar li.dropdown").each(function() { 722 let $topbarButton = $(this).find("> button"); 723 let $topbarDropdownContainer = $(this).find("> ul"); 724 725 if ($topbarButton.length > 0) { 726 let elem = $topbarButton[0]; 727 $topbarButton[0].removeEventListener('focusout', onDropdownFocusOut); 728 $topbarButton[0].addEventListener('focusout', onDropdownFocusOut); 729 } 730 if ($topbarDropdownContainer.length > 0) { 731 $topbarDropdownContainer[0].removeEventListener('focusout', onDropdownFocusOut); 732 $topbarDropdownContainer[0].addEventListener('focusout', onDropdownFocusOut); 733 } 734 }); 735 } 736 737 /* Function called to check if a keydown event is Shift + Tabs 738 * SHIFT-TAB, shift-tab and backwards navigation are all the same (relates to keyboard user going "backwards" through the focusable DOM elements)
739 * @param e : Keydown Event 740 */ 741 function isShiftTabKey(e) { 742 return e.which === 9 && e.shiftKey && !e.altKey && !e.ctrlKey; 743 } 744 function isArrowKey(e) { 745 return (e.which === 37 || e.which === 38 || e.which === 39 || e.which === 40) && 746 !e.shiftKey && !e.altKey && !e.ctrlKey; 747 } 748 function isTabKey(e) { 749 return e.which === 9 && !e.shiftKey && !e.altKey && !e.ctrlKey; 750 } 751 function isEscapeKey(e) { 752 return e.which === 27 && !e.shiftKey && !e.altKey && !e.ctrlKey; 753 } 754 755 /* Function that returns the active top navigation element (Home, Event, Admin...). 756 * Default is Hamburger button 757 */ 758 function getTopbarActiveElement() { 759 var $topNavigationActiveElements = $('#topbar li.active a'); 760 if ($topNavigationActiveElements.length > 0) { 761 return $topNavigationActiveElements[0]; 762 } 763 return $('#button-menu-mobile')[0]; 764 } 765 766 /* Function that returns the active left navigation element (Sub-Menu Or Menu). 767 * Default is getTopbarActiveElement. 768 */ 769 function getSidebarActiveElement() { 770 var $leftNavActiveSubMenuItemElements = $('#sidebar li.active li.active a.active'); 771 if ($leftNavActiveSubMenuItemElements.length > 0) { 772 return $leftNavActiveSubMenuItemElements[0]; 773 } 774 var $leftNavActiveMenuItemElements = $('#sidebar li.active a.active'); 775 if ($leftNavActiveMenuItemElements.length > 0) { 776 return $leftNavActiveMenuItemElements[0]; 777 } 778 return getTopbarActiveElement(); 779 } 780 781 /* Function that returns all sidebar active menu items. 782 */ 783 function getSidebarActiveElements() { 784 return $('#sidebar li.active > a.active'); 785 } 786 787 /* Function that returns the main content top element */ 788 function getMainContentTopElement() { 789 var $mainContentTopElements = $("#span-top-of-main-content--0"); 790 if ($mainContentTopElements.length > 0) { 791 return $mainContentTopElements[0]; 792 } 793 } 794 795 /* Function that handles shift tab event. Set global variable to true to trigger action on focusout. 796 * @param e : Keydown Event 797 */ 798 function handleShiftTabKeydown(e) { 799 if (isShiftTabNavigationPending) { return; } 800 801 if (isShiftTabKey(e)) { 802 isShiftTabNavigationPending = true; 803 } 804 } 805 806 /* Function that handles shift tab event on main content. Set focus on left navigation active element (or top navigation active element if left navigation is not present). 807 * @param e : Keydown Event 808 * @param focusTargetElement : Element to receive focus 809 */ 810 function handleShiftTabFocusout(e, focusTargetElement) { 811 if (!isShiftTabNavigationPending) { return; } 812 813 isShiftTabNavigationPending = false; 814 815 if (isEmpty(focusTargetElement)) { 816 return; 817 } 818 focusTargetElement.focus(); 819 } 820 821 /* Function that handles shift tab event on main content. Set focus on left navigation active element (or top navigation active element if left navigation is not present). 822 * @param e : Keydown Event 823 */ 824 function handleMainContentShiftTabFocusout(e) { 825 if (!isShiftTabNavigationPending) { return; } 826 if (isSidebarCollapsed() && isAccKeyboardMode()) { 827 navigateToActiveMenuItem(); 828 } 829 handleShiftTabFocusout(e, getSidebarActiveElement()); 830 } 831 832 /* Function that handles shift tab event on left navigation. Set focus on top navigation active element. 833 * @param e : Keydown Event 834 */ 835 function handleLeftNavigationShiftTabFocusout(e) { 836 handleShiftTabFocusout(e, getTopbarActiveElement()); 837 } 838 839 /* Function that adds event listeners for keydown/focusout from the top of main content and top of left navigation 840 * Catch shift-tab backwards navigation out of a region so we can forced focus to a parent menu button or menu item 841 */ 842 function addShiftTabListeners() { 843 var $leftNavTopElements = $("#sidebar #sidebar-menu ul li a"); 844 if ($leftNavTopElements.length > 0) { 845 var leftNavTopElement = $leftNavTopElements[0]; // the top one (if multiple are active for some reason) 846 leftNavTopElement.removeEventListener('keydown', handleShiftTabKeydown); 847 leftNavTopElement.removeEventListener('focusout', handleLeftNavigationShiftTabFocusout); 848 leftNavTopElement.addEventListener('keydown', handleShiftTabKeydown); 849 leftNavTopElement.addEventListener('focusout', handleLeftNavigationShiftTabFocusout); 850 } 851 var mainContentTopElement = getMainContentTopElement(); 852 if (!isEmpty(mainContentTopElement)) { 853 mainContentTopElement.removeEventListener('keydown', handleShiftTabKeydown); 854 mainContentTopElement.removeEventListener('focusout', handleMainContentShiftTabFocusout); 855 mainContentTopElement.addEventListener('keydown', handleShiftTabKeydown); 856 mainContentTopElement.addEventListener('focusout', handleMainContentShiftTabFocusout); 857 } 858 } 859 860 /* Function that adds keyup listener to an element to capture ENTER/SPACE and call the element's .click() 861 * Note the element must already support being clicked by the mouse (.click() must already do something) 862 * @param element : The element that requires ENTER/SPACE assistance to call the .click() 863 */ 864 function addKeyboardClickListener(element) { 865 function handleKeyboardClick(e) { 866 if (e.which === 13 || e.which === 32) { 867 e.preventDefault(); 868 e.stopPropagation(); 869 870 // prevent multiple accidental clicks on "stuck" enter key (for example - enter pressed on modal 'x' close button should not press enter on this element) 871 if (checkLastKeyclick(element) < 500) { return; } 872 873 element.click(); 874 flagLastKeyclick(element); 875 } 876 } 877 // add tabindex="0" to ensure the element can take keyboard focus (but do not change the tabindex if already defined) 878 if (!isTabIndexSpecified(element)) { 879 $(element).attr('tabindex', '0'); 880 } 881 // Remove existing keyup listener to avoid multiple events firing 882 $(element).off('keyup.data-keyclick'); 883 $(element).on('keyup.data-keyclick', handleKeyboardClick); 884 } 885 886 /* Function that assigns the epoch millis of the last click to an element that was clicked 887 * used together with checkLastKeyclick() below to determine the milliseconds since the 888 * last time the associated element was clicked (in order to prevent fast user actions 889 * from accidentally causing a recurring click loop, ex. press enter to close a modal 890 * and the enter is also read by the associated backpage element taking focus on modal 891 * close - resulting in the modal being opened once again when you're actually trying to close it!) 892 */ 893 function flagLastKeyclick(element) { 894 if ($(element).attr('data-keyclick') !== 'true') {return;} 895 let epochNow = (new Date()).getTime(); 896 $(element).attr('data-epochLastKeyclick', '' + epochNow); 897 } 898 899 /* Function that checks the milliseconds since the associated element was clicked 900 * to prevent accidental double clicks or accidental re-focus clicks when a modal is closed 901 * called from function addKeyboardClickListener(element) -> inner function handleKeyboardClick(e) 902 */ 903 function checkLastKeyclick(element) { 904 if ($(element).attr('data-keyclick') !== 'true') {return;} 905 let epochNow = (new Date()).getTime(); 906 let epochLastClick = $(element).attr('data-epochLastKeyclick'); 907 if (isEmpty(epochLastClick)) { 908 epochLastClick = 0; 909 } else { 910 epochLastClick = parseInt(epochLastClick); 911 } 912 return epochNow - epochLastClick; 913 } 914 915 /* Function called to associate keyboard ENTER/SPACE with elements that define the attribute data-keyclick=true 916 * only use for elements that do not already click when keyboard ENTER/SPACE is pressed
917 * adds event listeners to click when ENTER/SPACE are pressed 918 */ 919 function addKeyboardClickListeners() { 920 var $clickableElements = $('[data-keyclick="true"]'); 921 for (var i = 0; i < $clickableElements.length; i++) { 922 addKeyboardClickListener($clickableElements[i]); 923 } 924 } 925 926 /* Function that hides or shows the Skip to Navigation link in the top skip-links (show if the left navigation menu is present, even if hidden) 927 * Hide the "Skip to Navigation" link if the left navigation menu is not present (it may be present but hidden in high-zoom or on smaller browser windows) 928 */ 929 function setupSkipToLeftNavigation() { 930 var skipToNavigationElement = document.getElementById('span-skip-to-left-navigation'); 931 if (isEmpty(skipToNavigationElement)) { return; } 932 933 if ($('ul#side-menu li.menu-title:first').length > 0) { 934 // DISPLAY Skip to Navigation (left navigation menu found) 935 skipToNavigationElement.style.display = ''; 936 } else { 937 // HIDE Skip to Navigation 938 skipToNavigationElement.style.display = 'none'; 939 } 940 } 941 942 /* Function that defines breadcrumbs for keyboard users (activate navigation menu items are flagged with attribute aria-current=true). 943 * The active topbar navigation items (and sidebar navigation items if present) are assigned attribute aria-current="true" 944 * FUTURE: expand to include main-content tab bars (main content navigation regions) 945 */ 946 function setAriaCurrentState() { 947 $('*[aria-current]').removeAttr('aria-current'); 948 $(getTopbarActiveElement()).attr('aria-current', 'true'); 949 let sidebarActiveElements = getSidebarActiveElements(); 950 $(sidebarActiveElements).attr('aria-current', 'true'); 951 for (var i=0; i<sidebarActiveElements.length; i++) { 952 let sidebarActiveElement = sidebarActiveElements[i]; 953 let closestUl = $(sidebarActiveElement).closest('ul'); 954 if (closestUl.length > 0) { 955 if ($(closestUl).hasClass('nav-second-level')) { 956 $(closestUl).attr('aria-expanded', 'true'); 957 $(closestUl).attr('data-a11y-sub-menu', 'true'); 958 } else { 959 $(sidebarActiveElement).attr('aria-expanded', 'true'); 960 $(sidebarActiveElement).attr('data-a11y-menu-option', 'true'); 961 } 962 } 963 } 964 } 965 966 /* Function that calls jQuery slideToggle() to expand or collapse a section and also updates the associated link's aria-expanded state 967 * based on the state after slideToggle() is called 968 * @param {HTMLElement} toggleLink - the link (or element) that is clicked to toggle the expandable section 969 * @param {string} toggleSelector - unique CSS selector for the expandable section to toggle 970 * @param {number} speed - milliseconds >= 1 to wait before toggling the section (passed to jQuery slideToggle()) 971 */ 972 function a11ySlideToggle(toggleLink, toggleSelector, speed) { 973 if (!toggleLink) { console.error('toggleLink is empty'); return; } 974 if (!toggleSelector) { console.error('toggleSelector is empty'); return; } 975 if (!speed || !isNumber(speed)) { console.error('speed is empty or zero'); return; } 976 977 let toggleElems = document.querySelectorAll(toggleSelector); 978 if (toggleElems.length < 1) { console.error('toggleSelector target not found'); return; } 979 if (toggleElems.length > 1) { console.error('toggleSelector target not unique'); return; } 980 981 let toggleElem = toggleElems[0]; 982 $(toggleElems).slideToggle(speed, function() { 983 let isExpanded = ($(toggleElem).css('display') === 'none') ? false : true; 984 if (isExpanded) { 985 $(toggleLink).attr('aria-expanded', 'true'); 986 } else { 987 $(toggleLink).attr('aria-expanded', 'false'); 988 } 989 }); 990 } 991 992/*-----------------------------------------------------*\ 993 * @TABSTOPS 994 * 995 * Find an element or a tree of elements for different scenarios 996 * 997\*-----------------------------------------------------*/ 998 999 /* Function that returns true if an element will take keyboard focus natively (by default) 1000 * These elements typically do not require tabindex="0" 1001 * @param element : The element in question 1002 */ 1003 function isNativeFocusElement(element) { 1004 const defaultFocusNodeNames = ['A', 'AREA', 'BUTTON', 'DETAILS', 'INPUT', 'SELECT', 'TEXTAREA']; 1005 return defaultFocusNodeNames.includes(element.nodeName); 1006 } 1007 1008 /* Function that returns true if an element has specifically assigned a tabindex value (is the tabindex specified for the element) 1009 * Asking for .attr('tabindex') will always return an integer (0 for elements that have not specified a tabindex) 1010 * We want to know if the actual HTML code has defined a tabindex attribute, or if a tabindex attribute has been added via JavaScript 1011 * @param element : The element in question 1012 */ 1013 function isTabIndexSpecified(element) { 1014 if (isEmpty(element.getAttributeNode) || isEmpty(element.getAttributeNode("tabIndex")) || isEmpty(element.getAttributeNode("tabIndex").specified)) { return false; } 1015 return element.getAttributeNode("tabIndex").specified; 1016 } 1017 1018 /* Function that returns true if an element requires a tabindex reduction when a modal opens (in order to trap the keyboad focu
1018s inside the modal that just opened) 1019 * Native focus elements always require a tabindex reduction on keytrap, as well as any elements with a tabindex specified 1020 * @param element : The element in question 1021 */ 1022 function isTabIndexReductionRequiredOnKeyboardTrap(element) { 1023 if (isNativeFocusElement(element)) { 1024 return true; 1025 } 1026 return isTabIndexSpecified(element); 1027 } 1028 1029 /* Function that returns a valid jQuery CSS selector for the associated DOM element 1030 * @param element : The element in question (DOM element) 1031 * Returns a selector that should uniquely select the element using $(selector) where $(selector).length === 1 and $(selector)[0] is the element itself 1032 */ 1033 function getElementSelector(element) { 1034 if (isEmpty(element) || $(element).length !== 1) { console.error('element is invalid'); return ''; } 1035 1036 let tagName = $(element).prop('tagName'); 1037 if (isEmpty(tagName)) { console.error('tagName is empty'); return ''; } 1038 1039 tagName = tagName.toLowerCase(); 1040 if (tagName === 'body') { 1041 return tagName; 1042 } 1043 1044 if (!isEmpty(element.id)) { 1045 let selector = tagName + '#' + element.id; 1046 try { 1047 if ($(selector).length === 1) { return selector; } 1048 } catch(e) { /* do nothing - selector is not valid (this is ok) */ } 1049 } 1050 1051 if (!isEmpty(element.classList) && element.classList.length > 0) { 1052 let selector = tagName + '.' + element.classList.toString().split(' ').join('.'); 1053 selector = selector.split('active').join(''); 1054 while (selector.includes('..')) { 1055 selector = selector.split('..').join('.'); 1056 } 1057 if (selector.endsWith('.')) { 1058 selector = selector.substring(0, selector.length-1); 1059 } 1060 try { 1061 if ($(selector).length === 1) { return selector; } 1062 } catch(e) { /* do nothing - selector is not valid (this is ok) */ } 1063 } 1064 1065 let href = $(element).attr('href'); 1066 if (!isEmpty(href) && !href.trim().startsWith('javascript')) { 1067 let selector = tagName + '[href="' + href.split('"').join('""') + '"]'; 1068 try { 1069 if ($(selector).length === 1) { return selector; } 1070 } catch(e) { /* do nothing - selector is not valid (this is ok) */ } 1071 } 1072 1073 let onclick = $(element).attr('onclick'); 1074 if (!isEmpty(onclick)) { 1075 let selector = tagName + '[onclick="' + onclick.split('"').join('""') + '"]'; 1076 try { 1077 if ($(selector).length === 1) { return selector; } 1078 } catch(e) { /* do nothing - selector is not valid (this is ok) */ } 1079 } 1080 1081 // temporarily tag the element with the following random class (we'll remove this before return) 1082 let tagClass = 'tag-'; 1083 tagClass = tagClass + '-' + (1000000000 + Math.floor(Math.random() * 1000000000)); 1084 tagClass = tagClass + '-' + (1000000000 + Math.floor(Math.random() * 1000000000)); 1085 $(element).addClass(tagClass); 1086 1087 let parent = $(element).parent()[0]; 1088 let siblings = $(parent).children(tagName); 1089 if (siblings.length > 1) { 1090 for (var i=0; i<siblings.length; i++) { 1091 if (siblings[i].classList.toString().includes(tagClass)) { 1092 $(element).removeClass(tagClass); 1093 return getElementSelector(parent) + ' > ' + tagName + ':nth-child(' + (i+1) + ')'; 1094 } 1095 } 1096 } 1097 $(element).removeClass(tagClass); 1098 return getElementSelector(parent) + ' > ' + tagName; 1099 } 1100 1101/*-----------------------------------------------------*\ 1102 @PASTE-LIST Dialogs 1103\*-----------------------------------------------------*/ 1104 1105/* Function that opens a paste list dialog with proper keyboard lock behind the dialog and sets focus to the first radio button */ 1106function a11yOpenPastelistDialogWithKeyboardSupport(pasteListButton, pasteListDialogId) { 1107 if (!pasteListButton) { 1108 console.error('> pasteListButton is empty'); 1109 return; 1110 } 1111 if (!pasteListDialogId) { 1112 console.error('> pasteListDialogId is empty'); 1113 return; 1114 } 1115 1116 pasteListDialogId = pasteListDialogId.replace('#', ''); 1117 let a11yToggleDataAttribute = 'data-cg-pastelist-toggle';
1118 let closeDialogReFocusSelector = '[' + a11yToggleDataAttribute + ']'; 1119 $(closeDialogReFocusSelector).removeAttr(a11yToggleDataAttribute); 1120 $(pasteListButton).attr(a11yToggleDataAttribute, 'true'); 1121 1122 $('#' + pasteListDialogId).toggle(150, function() { 1123 initPopupAccessibility(pasteListDialogId, closeDialogReFocusSelector); 1124 a11yAnnounceToScreenReader($('#' + pasteListDialogId + ' h2').text()); 1125 $('#' + pasteListDialogId + ' div.radio > input')[0].focus(); 1126 }); 1127} 1128 1129/* Function that closes a paste list dialog and re-enables keyboard behind (unlock) - also resets focus to the calling button clicked to open the dialog */ 1130function a11yClosePastelistDialogWithKeyboardSupport(pasteListDialogId) { 1131 if (!pasteListDialogId) { 1132 console.error('> pasteListDialogId is empty'); 1133 return; 1134 } 1135 pasteListDialogId = pasteListDialogId.replace('#', ''); 1136 let a11yToggleDataAttribute = 'data-cg-pastelist-toggle'; 1137 let closeDialogReFocusSelector = '[' + a11yToggleDataAttribute + ']'; 1138 $('#' + pasteListDialogId).hide(150, function() { 1139 terminatePopupAccessibility(pasteListDialogId); 1140 $(closeDialogReFocusSelector).removeAttr(a11yToggleDataAttribute); 1141 }); 1142} 1143 1144/*-----------------------------------------------------*\ 1145 * @POPUPS 1146 * 1147 * Accessibility functions to handle focus / keyboard trapping for Dialogs, jPrompts, Modals and Thickbox iFrames. In addition: 1148 * - update/restore page title on modal open/close 1149 * - set initial focus to a specific element (inside popup) 1150 * - return focus to the appropriate element (behind popup) when the popup closes 1151 * 1152\*-----------------------------------------------------*/ 1153 1154 /* Function that returns an array of all elements beneath a given element (including the element itself) recursively 1155 * Get an umbrella of elements (in an array) 1156 * @param element : Current element under inspection 1157 * @param elements : Array of all elements gathered recursively 1158 */ 1159 function getAllElementsInTree(element, elements) { 1160 if (isEmpty(element) || isEmpty(elements)) { return; } 1161 1162 elements[elements.length] = element; 1163 1164 var childElements = element.children; 1165 for (var i = 0; i < childElements.length; i++) { 1166 getAllElementsInTree(childElements[i], elements); 1167 } 1168 } 1169 1170 /* Function that returns an array of all elements beneath a given element (including the element itself) recursively, but excluding a sub-branch beneath the element with id=containerId 1171 * An umbrella of elements (in an array), but excluding a sub-umbrella. Typically the container is a modal/iFrame or equivalent. 1172 * @param element : Current element under inspection 1173 * @param elements : Array of all elements gathered recursively 1174 * @param containerId : String ID of the element to be excluded, including all child elements of this element 1175 */ 1176 function getAllElementsInTreeExcludingContainer(element, elements, containerId) { 1177 if (isEmpty(containerId)) {return getAllElementsInTree(element, elements); } 1178 if (isEmpty(element) || isEmpty(elements) || element.id === containerId) { return; } 1179 1180 elements[elements.length] = element; 1181 1182 var childElements = element.children; 1183 for (var i = 0; i < childElements.length; i++) { 1184 getAllElementsInTreeExcludingContainer(childElements[i], elements, containerId); 1185 } 1186 } 1187 1188 /* Function that returns all elements in the document except those in the container with id=containerId (and excluding the container element itself) 1189 * Used to get all elements requiring tabIndex reduction to trap keyboard navigation (keyboard to be locked inside the container referenced by containerId) 1190 * @param doc : The document from which you want to retrive all elements (excluding the element referenced by containerId and it's child elements) 1191 * @param containerId : String ID of the element to exclude, along with all child elements under this element (the dialog/jPropt/modal/thickbox to lock keyboard inside of) 1192 */
1193 function getAllElementsNotInsideContainer(doc, containerId) { 1194 if (isEmpty(doc)) { return; } 1195 1196 var elements = []; 1197 getAllElementsInTreeExcludingContainer(doc.body, elements, containerId); 1198 return elements; 1199 } 1200 1201 /* Function that reduces the tabindex for all clickable elements in the targetElements array (in order to trap keyboard functionality inside an iFrame/dialog/modal for example) 1202 * Used to remove keyboard functionality from a parent window or elements outside a dialog/modal while it's open (trap keyboard inside the dialog/modal) 1203 * References global cgTrapKeyboardTrapTabIndexReduction (defined above) to reduce the tabindex 1204 * @param targetElements : Array of elements (the elements to have keyboard functionality removed) 1205 */ 1206 function _removeKeyboardFunctionality(targetElements) { 1207 for (var i = 0; i < targetElements.length; i++) { 1208 var element = targetElements[i]; 1209 if (isTabIndexReductionRequiredOnKeyboardTrap(element)) { 1210 element.tabIndex = element.tabIndex - cgTrapKeyboardTrapTabIndexReduction; 1211 } 1212 } 1213 } 1214 1215 /* Function that restores the tabindex for all clickable elements in the targetElements array (in order to restore keyboard functionality when a dialog/modal/iFrame closes) 1216 * Used to restore keyboard functionality to a parent window or elements outside a dialog/modal when it closes 1217 * References global cgTrapKeyboardTrapTabIndexReduction (defined above) to restore the tabindex 1218 * @param targetElements : Array of elements (the elements to have keyboard functionality restored) 1219 * @DELETE 1220 */ 1221 function _restoreKeyboardFunctionality(targetElements) { 1222 for (var i = 0; i < targetElements.length; i++) { 1223 var element = targetElements[i]; 1224 if (isTabIndexReductionRequiredOnKeyboardTrap(element)) { 1225 var newTabIndex = element.tabIndex + cgTrapKeyboardTrapTabIndexReduction; 1226 // NEW elements might have been defined on the page AFTER we reduced the tabindex. These will have an unusually high newTabIndex value and should not be altered. 1227 if (newTabIndex < 100) { 1228 // element is OK to have tabindex restored (restored value is in reasonable range) 1229 element.tabIndex = newTabIndex; 1230 } 1231 } 1232 } 1233 } 1234 1235 /* Function called when a modal, dialog or iFrame (Thickbox) is opened to push the ID of the associated dialog onto the global dialogCallStack 1236 * @param containerId : The ID of the modal, dialog or iFrame that is being opened, ex. primary-modal, secondary-modal 1237 */ 1238 function pushDialogCallStack(containerId) { 1239 if (dialogCallStack[dialogCallStack.length-1] === containerId) { 1240 return; 1241 } 1242 dialogCallStack.push(containerId); 1243 } 1244 1245 /* Function called when a modal, dialog or iFrame (Thickbox) is closed. The associated ID of the closed dialog is popped off the dialogCallStack 1246 * @param containerId : The ID of the modal, dialog or iFrame that is being closed, ex. primary-modal, secondary-modal 1247 */ 1248 function popDialogCallStack(containerId) { 1249 if (dialogCallStack.length < 1) { return; } 1250 1251 let count = 0; // prevent future updates causing too many loops here (should never be more than 5 dialogs stacked; really 2 or 3) 1252 let id = dialogCallStack[dialogCallStack.length-1]; 1253 let isFound = (id === containerId); 1254 while (!isFound && dialogCallStack.length > 0 && count < 5) { 1255 if (id === 'secondary-modal' || id === 'primary-modal') { 1256 $('#' + id + ' button.close').click(); 1257 id = (dialogCallStack.length > 0) ? dialogCallStack[dialogCallStack.length-1] : null; 1258 } else if (id !== 'TB_window') { 1259 closeDialog('#' + id); 1260 id = (dialogCallStack.length > 0) ? dialogCallStack[dialogCallStack.length-1] : null; 1261 } else { 1262 unlockKeyboardFromPopup(id); 1263 id = dialogCallStack.pop(); 1264 } 1265 isFound = (id === containerId); 1266 count++; 1267 } 1268 if (isFound && dialogCallStack.length > 0) { 1269 dialogCallStack.pop(); 1270 } 1271 } 1272 1273 /* Function that locks keyboard on a popup 1274 * @param popupId : The popup ID 1275 */ 1276 function lockKeyboardForPopup(popupId) { 1277 var targetElements = getAllElementsNotInsideContainer(window.document, popupId); 1278 _removeKeyboardFunctionality(targetElements); 1279 } 1280 1281 /* Function that unlocks keyboard from a popup 1282 * @param popupId : The popup ID 1283 */ 1284 function unlockKeyboardFromPopup(popupId) { 1285 var targetElements = getAllElementsNotInsideContainer(window.document, popupId); 1286 _restoreKeyboardFunctionality(targetElements); 1287 } 1288 1289 /* Function that initializes accessibility behavior for popup 1290 * @param popupId : The popup ID 1291 * @param pathToElementToFocusOnClose : Selector path to element to focus on close 1292 * 1293 */ 1294 function initPopupAccessibility(popupId, pathToElementToFocusOnClose) { 1295 pushDialogCallStack(popupId); 1296 lockKeyboardForPopup(popupId); 1297 $("#" + popupId).attr("data-pathfocusclose", pathToElementToFocusOnClose); 1298 } 1299 1300 /* Function that terminates accessibility behavior for popup 1301 * @param popupId : The popup ID 1302 */ 1303 function terminatePopupAccessibility(popupId) { 1304 if ($('#' + popupId).length < 1) { 1305 return; 1306 } 1307 $('#' + popupId).removeAttr('aria-label'); // clear dialog accessibility label 1308 unlockKeyboardFromPopup(popupId); 1309 popDialogCallStack(popupId); 1310 let pathToElementToFocusOnClose = $("#" + popupId).attr("data-pathfocusclose"); 1311 setFocusToElementFromCssSelector(pathToElementToFocusOnClose); 1312 $("#" + popupId).attr("data-pathfocusclose", ""); 1313 } 1314 1315 /* Function that determines what modal, dialog or iFrame a particular element is in 1316 * Called from verifyKeyboardLock() to help determine the correct tabindex reduction for late loading elements in a locked region 1317 * The returned regionId represents the ID of the parent dialog to the element (based on the open dialogCallStack IDs registered) 1318 * @param element : a DOM element for which we want to determine the parent modal, dialog or iFrame (or if the element is behind all popups on the main body) 1319 */ 1320 function getDialogRegionId(element) { 1321 for (var i=dialogCallStack.length-1; i>=0; i--) { 1322 let dialogId = dialogCallStack[i]; 1323 if ($(element).closest('#' + dialogId).length > 0) { 1324 return dialogId; 1325 } 1326 } 1327 return 'body'; 1328 } 1329 1330 /* Function that verifies all elements in the associated parentContainerId are locked properly while an overlay/dialog is open 1331 * @param parentContainerId : the top element containing all elememts to be verified (ex. the ID of a parent DIV to which new elements were just added) 1332 */ 1333 function verifyKeyboardLock(parentContainerId) { 1334 if (dialogCallStack.length === 0) { return; } 1335 1336 // determine the tabindex reduction value for each sheet in the modal/dialog stack (each region) 1337 var tabindexReduction = 0; 1338 let tabindexReductionByRegionId = {}; 1339 for (var i=dialogCallStack.length-1; i>=0; i--) { 1340 let regionId = dialogCallStack[i]; // parent container for associated modal/dialog/iFrame 1341 tabindexReductionByRegionId[regionId] = tabindexReduction; 1342 tabindexReduction -= cgTrapKeyboardTrapTabIndexReduction; 1343 } 1344 tabindexReductionByRegionId['body'] = tabindexReduction; 1345 1346 function fixKeyboardLock(element, currentRegionId) {
1347 if (!isEmpty(element.id) && !isEmpty(tabindexReductionByRegionId[element.id])) { currentRegionId = element.id; } 1348 1349 const tabindexReduction = tabindexReductionByRegionId[currentRegionId]; 1350 if (tabindexReduction === 0) { return; } 1351 1352 let hasTabindex = isTabIndexSpecified(element); 1353 if (hasTabindex || isFocusableElement(element)) { 1354 // some 3rd party libraries have used tabindex=200 but I have not seen higher than this 1355 // we'll use 300 as arbitrary an upper positive tabindex below (i.e. the max regular positive tabindex we may encounter) 1356 const tabindex = (hasTabindex) ? parseInt(element.getAttribute('tabindex')) : 0; 1357 if (tabindex >= (tabindexReduction + 300)) { 1358 element.setAttribute('tabindex', tabindex + tabindexReduction); 1359 } 1360 } 1361 1362 let childElements = element.children; 1363 for (var i = 0; i < childElements.length; i++) { 1364 fixKeyboardLock(childElements[i], currentRegionId); 1365 } 1366 } 1367 1368 // DEFAULT to full dom scan (typically under 100 millis) unless parentContainerId provided 1369 // FUTURE expand on the below to work with selectors (and update 2+ containers based on selector instead of ID value) - requires loop 1370 let regionId = 'body'; 1371 let parentContainerElement = window.document.body; 1372 if (!isEmpty(parentContainerId)) { 1373 // caller specified a parent container (via ID) to help restrict the work to be done and avoid conflicts 1374 let element = $('#' + parentContainerId)[0]; 1375 if (!isEmpty(element)) { 1376 // this is element represented by #parentContainerId 1377 parentContainerElement = element; 1378 1379 // tabindex reduction will be based on the parent region (body, primary-modal, secondary-modal, TB_Window, etc.) 1380 regionId = getDialogRegionId(parentContainerElement); 1381 } 1382 } 1383 fixKeyboardLock(parentContainerElement, regionId); 1384 } 1385 1386 // ---------- 1387 // THICKBOX 1388 // ---------- 1389 1390 /* Function that waits for the thickbox dialog iframe to load then sets: 1391 * 1392 * - dialog role (on parent container) 1393 * - dialog accessibility label (set to dialog title/heading) 1394 * - dialog focus (to the first actionable element beneath the title bar) 1395 * 1396 * NOTE we need to wait for dialog open/load work to complete before setting focus 1397 * otherwise it will be set to the top of the iFrame (instead of the first actionable 1398 * element/input/tab inside the iframe) 1399 */ 1400 function setupThickboxDialogAccessibility() { 1401 // find thickbox iframe containers 1402 let tbContainerElems = $('#TB_window'); 1403 if (tbContainerElems.length < 0) { return; } 1404 1405 // tbContainerElem is the thickbox iframe container; we'll use this to set role=dialog and also set the dialog title accessibility label below 1406 let tbContainerElem = tbContainerElems[0]; 1407 $(tbContainerElem).attr('role', 'dialog'); 1408 1409 // if keyboard mode was active in the parent window then enable it in the iFrame (focus visibility) 1410 if (isAccKeyboardMode()) { 1411 $('iframe#TB_iframeContent').contents().find('body:not(body.acc-keyboard-mode)').addClass('acc-keyboard-mode'); 1412 } 1413 1414 /* Function to find the modal/dialog title text inside the dialog based on priority ordered selectors */ 1415 function findTitleText() { 1416 // future fixes can probably be performed by updating titleSelectors 1417 const titleSelectors = [ '.ttl', '.w_title', 'h1', 'h2' ]; 1418 if ($('iframe#TB_iframeContent').length > 0) { 1419 if ($('iframe#TB_iframeContent').contents().length > 0) { 1420 for (var i=0; i<titleSelectors.length; i++) { 1421 if ($('iframe#TB_iframeContent').contents().find(titleSelectors[i]).length > 0) { 1422 let titleElement = $('iframe#TB_iframeContent').contents().find(titleSelectors[i])[0]; 1423 return $(titleElement).text(); 1424 } 1425 } 1426 let titleText = $('iframe#TB_iframeContent').attr('title'); 1427 if (!isEmpty(titleText)) { 1428 return titleText; 1429 } 1430 } 1431 } 1432 } 1433 1434 /* Function to find the modal/dialog optimal focus element inside the dialog based on priority ordered selectors */ 1435 function findFocusContainer() { 1436 // future fixes can probably be performed by updating focusContainerSelectors 1437 const focusContainerSelectors = [ '.modal-body', '.w_content', '#page-cont', 'body' ]; 1438 for (var i=0; i<focusContainerSelectors.length; i++) { 1439 if ($('iframe#TB_iframeContent').length > 0) { 1440 if ($('iframe#TB_iframeContent').contents().length > 0) { 1441 if ($('iframe#TB_iframeContent').contents().find(focusContainerSelectors[i]).length > 0) { 1442 return $('iframe#TB_iframeContent').contents().find(focusContainerSelectors[i])[0]; 1443 } 1444 } 1445 } 1446 } 1447 } 1448 1449 let isTitleSet = false; 1450 let isFocusSet = false; 1451 let isKeyboardSetup = false; 1452 1453 let maxIntervals = 50; 1454 let intervalId = setInterval(function() { 1455 if (maxIntervals-- < 0) { 1456 setFocusThickboxIframe(); 1457 clearInterval(intervalId); 1458 return; 1459 } 1460 if ($('iframe#TB_iframeContent').contents().length > 0) { 1461 if (!isTitleSet) { 1462 let tbIFrameTitleText = findTitleText(); 1463 if (!isEmpty(tbIFrameTitleText)) { 1464 $(tbContainerElem).attr('aria-label', getSafeAriaLabelTextTrim(tbIFrameTitleText)); 1465 isTitleSet = true; 1466 } 1467 } 1468 if (!isFocusSet) { 1469 let focusContainer = findFocusContainer(); 1470 if (!isEmpty(focusContainer)) { 1471 let focusElement = findFirstActionableElementInsideContainer(focusContainer); 1472 if (!isEmpty(focusElement)) { 1473 setTimeout(function() { 1474 // wait for browser to finish processing iFrame and contents before setting focus 1475 // 111 milliseconds provides a smooth experience (but not too s
1475oon) 1476 focusElement.focus(); 1477 }, 150); 1478 isFocusSet = true; // set this immediately so we can clear the interval 1479 } 1480 } 1481 } 1482 if (!isKeyboardSetup) { 1483 isKeyboardSetup = setupThickboxIframeKeyboard(); 1484 } 1485 if (isTitleSet && isFocusSet && isKeyboardSetup) { 1486 setFocusThickboxIframe(); 1487 clearInterval(intervalId); 1488 } 1489 } 1490 }, 100); 1491 } 1492 1493 /* Function that adds a keydown listener on the Thickbox iframe document for Escape and Tab/Shift-Tab. 1494 * Called from setupThickboxDialogAccessibility() once the iframe body is available. 1495 * Returns true if the listener was attached, false if the iframe wasn't ready. 1496 * 1497 * Escape key: 1498 * Closes the Thickbox by calling tb_remove() in the parent window. 1499 * Defers when a jQuery UI datepicker is visible or a Bootstrap modal is open 1500 * inside the iframe â so Escape closes those first, then a second Escape closes the Thickbox. 1501 * 1502 * Focus trap (keydown):
1503 * Intercepts Tab/Shift-Tab on the iframe document. When the active element is at 1504 * the boundary, preventDefault() stops focus from leaving and redirects to the 1505 * opposite end. Focus never lands on an invisible element. 1506 */ 1507 function setupThickboxIframeKeyboard() { 1508 try { 1509 var iframeDoc = $('iframe#TB_iframeContent')[0].contentDocument; 1510 if (!iframeDoc || !iframeDoc.body) return false; 1511 1512 var focusableSelector = 'a[href], button:not([disabled]), input:not([type="hidden"]):not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex^="-"])'; 1513 1514 iframeDoc.addEventListener('keydown', function(e) { 1515 // Escape â close the Thickbox unless a datepicker or modal is open inside the iframe 1516 if (isEscapeKey(e)) { 1517 var $body = $(iframeDoc.body); 1518 if ($body.find('#ui-datepicker-div:visible').length > 0) return; 1519 if ($body.find('.modal.in, .modal.show').length > 0) return; 1520 e.preventDefault(); 1521 tb_remove(); 1522 return; 1523 } 1524
1525 // Tab/Shift-Tab â trap focus at iframe boundaries 1526 if (isTabKey(e) || isShiftTabKey(e)) { 1527 var $scope = $(iframeDoc.body); 1528 var $openModal = $scope.find('.modal.in:visible, .modal.show:visible').last(); 1529 if ($openModal.length > 0) { 1530 $scope = $openModal; 1531 } 1532 var $focusable = $scope.find(focusableSelector).filter(':visible'); 1533 if ($focusable.length === 0) return; 1534 if (isShiftTabKey(e) && iframeDoc.activeElement === $focusable.first()[0]) { 1535 e.preventDefault(); 1536 $focusable.last().trigger('focus'); 1537 } else if (isTabKey(e) && iframeDoc.activeElement === $focusable.last()[0]) { 1538 e.preventDefault(); 1539 $focusable.first().trigger('focus'); 1540 } 1541 } 1542 }); 1543 1544 return true; 1545 } catch(ex) { return false; } 1546 } 1547 1548 /* Function that traps keyboard focus inside a pseudo-modal (a .pseudo-modal or similar in-page 1549 * container that behaves like a dialog but is not a Bootstrap/Thickbox modal). 1550 * 1551 * Tab / Shift-Tab cycle focus within the modal. Escape hides the modal (with a 150ms animation) 1552 * and returns focus to the trigger element that opened it. 1553 * 1554 * The keydown listener is bound on the pseudo-modal element itself (not document) so that when 1555 * the pseudo-modal lives inside a Bootstrap modal, stopPropagation prevents the outer modal'
1555s 1556 * Escape handler from also firing and closing the Bootstrap modal. 1557 * 1558 * @param options.modalSelector : jQuery selector for the pseudo-modal. Must match an element in the DOM. 1559 * @param options.triggerSelector : Selector string for the element to refocus after Escape. 1560 * @param options.namespace : Optional event namespace suffix. Defaults to a sanitized copy of modalSelector. 1561 */ 1562 function setupPseudoModalFocusTrap(options) { 1563 if (!options || !options.modalSelector) return; 1564 1565 var $modal = $(options.modalSelector); 1566 if (!$modal.length) return; 1567 1568 var triggerSelector = options.triggerSelector; 1569 var ns = 'pseudoModalFocusTrap_' + (options.namespace || options.modalSelector.replace(/[^a-zA-Z0-9]/g, '_')); 1570 var focusableSelector = 'input, button, a[href], select, textarea, [tabindex]:not([tabindex="-1"])'; 1571 1572 $modal.off('keydown.' + ns).on('keydown.' + ns, function (e) { 1573 if (!isTabKey(e) && !isShiftTabKey(e) && !isEscapeKey(e)) return; 1574 if (!$modal.is(':visible')) return; 1575 if (!$.contains($modal[0], document.activeElement)) return; 1576 1577 if (isEscapeKey(e)) { 1578 e.preventDefault(); 1579 e.stopPropagation(); 1580 var $trigger = triggerSelector ? $(triggerSelector) : $(); 1581 $modal.hide(150, function () { 1582 if ($trigger.length) $trigger.trigger("focus"); 1583 }); 1584 return; 1585 } 1586 1587 var $focusable = $modal.find(focusableSelector).filter(':visible').filter(function () { 1588 return isFocusableElement(this); 1589 }); 1590 if (!$focusable.length) return; 1591 var first = $focusable.first()[0], last = $focusable.last()[0]; 1592 if (isShiftTabKey(e) && document.activeElement === first) { 1593 e.preventDefault(); 1594 last.focus(); 1595 } else if (isTabKey(e) && document.activeElement === last) { 1596 e.preventDefault(); 1597 first.focus(); 1598 } 1599 }); 1600 } 1601 1602 /* Function that locks keyboard functionality inside a Thickbox iFrame window when it opens (remove keyboard functionality in the parent window of the Thickbox iFrame) 1603 */ 1604 function lockKeyboardInsideThickbox() { 1605 pushDialogCallStack('TB_window'); 1606 var targetElements = getAllElementsNotInsideContainer(window.parent.document, 'TB_iframeContent'); 1607 _removeKeyboardFunctionality(targetElements); 1608 setTimeout(function() { 1609 // we've just removed keyboard functionality behind the iframe and the iframe is loading 1610 // 333 milliseconds provides smooth experience (but call too soon and it won't work as expected) 1611 setupThickboxDialogAccessibility(); 1612 }, 333); 1613 } 1614 1615 /* Function that restores keyboard functionaity to the parent window of a Thickbox iFrame (when the Thikbox iFrame closes) 1616 */ 1617 function unlockKeyboardOnThickboxClose() { 1618 var targetElements = getAllElementsNotInsideContainer(window.parent.document, 'TB_iframeContent'); 1619 _restoreKeyboardFunctionality(targetElements); 1620 popDialogCallStack('TB_window'); 1621 } 1622 1623/*-----------------------------------------------------*\ 1624 * @FOCUS 1625 * 1626 * Functions relating to setting focus or tracking keyboard navigation 1627 * in preparation for forcing focus change 1628 * 1629\*-----------------------------------------------------*/ 1630 1631 /* Function that sets (or returns) focus to the element specified by the selector, 1632 * for example, when closing a dialog or modal and we return focus to the button or link clicked to open the associated dialog 1633 * @param selector : a jQuery CSS selector string 1634 * If the element is no longer present on the page or no longer found using $(selector) focus is not changed (no action taken) 1635 */ 1636 function setFocusToElementFromCssSelector(selector) { 1637 try { 1638 if (selector && $(selector).length > 0) { 1639 let element = $(selector)[0]; 1640 flagLastKeyclick(element); 1641 element.focus(); 1642 } 1643 } 1644 catch (e) { 1645 // do nothing - assume page behind dialog was updated and changed significantly (selector no longer valid in current context) 1646 } 1647 } 1648 1649 /* Function that returns true if the element can take keyboard focus. 1650 * Returns true for all native focus elements (A, AREA, BUTTON, DETAILS, INPUT, SELECT, TEXTAREA) and elements with tabindex="0" 1651 * Returns false if the element is hidden (display="none" or aria-hidden="true" or type="hidden" or element.disabled is true) 1652 * @param element : The element in question. 1653 */ 1654 function isFocusableElement(element) { 1655 var canFocus = false; 1656 if (isTabIndexSpecified(element)) { 1657 canFocus = (element.getAttribute('tabindex') >= 0) ? true : false; 1658 } else if (isNativeFocusElement(element)) { 1659 canFocus = true; 1660 } 1661 return canFocus && element.style.display !== 'none' 1662 && element.getAttribute('aria-hidden') !== 'true' 1663 && element.getAttribute('type') !== 'hidden' 1664 && (typeof element.disabled === undefined || element.disabled !== true)
1665 && (isEmpty(element.id) || !element.id.startsWith('span-top-of-main-content--')) 1666 // ADD check for any of the CampusGroups 'hidden' classes 1667 } 1668 1669 /* Function that finds the first actionable element (that can take keyboard focus) inside the specified container element 1670 * @param containerElement : the element (ex. DIV) containing the subset of elements in which focus is to be placed (ex. primary-modal) 1671 */ 1672 function findFirstActionableElementInsideContainer(containerElement) { 1673 if (isEmpty(containerElement)) { return; } //Can this happen?? 1674 1675 // don't follow the tree below elements that are hidden from view 1676 if (containerElement.style.display === 'none' || containerElement.getAttribute('aria-hidden') === 'true') { return; } 1677 1678 // don't focus on the [x] close button at the top of modal dialogs => no, let's change the design of modal to follow proper order 1679 if (containerElement.tagName === 'BUTTON' && containerElement.classList.toString().includes('close')) { return; } 1680 1681 // don't focus on the <span id="span-top-of-main-content--*"> at the top of main content 1682 1683 // IF container element itself is actionable - return it 1684 if (isFocusableElement(containerElement)) { 1685 return containerElement; 1686 } 1687 1688 // CHECK all the child containers 1689 for (let i = 0; i < containerElement.children.length; i++) { 1690 let childElement = containerElement.children[i]; 1691 if (childElement.tagName !== "SCRIPT" && childElement.tagName !== "STYLE") { 1692 if(childElement.tagName === "IFRAME"){ 1693 const iframeURL = new URL(childElement.src); 1694 if(iframeURL.hostname === window.location.hostname && iframeURL.pathname.startsWith("/webapp/") && childElement.contentDocument.children.length > 0){ 1695 // Get the router-outlet inside the Angular iframe 1696 let routerOutlet = childElement.contentDocument.querySelector('router-outlet'); 1697 // Get the element immediately after the router-outlet 1698 if (routerOutlet) { 1699 childElement = routerOutlet.nextElementSibling; 1700 } 1701 } 1702 } 1703 var focusElement = findFirstActionableElementInsideContainer(childElement); 1704 if (!isEmpty(focusElement)) { 1705 return focusElement; 1706 } 1707 } 1708 } 1709 } 1710 1711 /* Function that waits for one or more elements matching $(selector) 1712 * @param selector : a valid jQuery selector string 1713 * @param asyncCallback : callback function, called if selector is found (passing results of $(selector)) 1714 * @param waitMillis : (optional) Defaults to 33; number of milliseconds to wait between query for selector 1715 * @param timeoutMillis : (optional) Defaults to 2000; maximum number of milliseconds to wait on successful query for selector 1716 * 1717 * If $(selector) finds one or more elements, asyncCallback() is called passing the results of the query 1718 * If the timeout is reached, the callback is not called 1719 * In both cases the interval is cleared (when found or on timeout) 1720 */ 1721 function waitFor(selector, asyncCallback, waitMillis, timeoutMillis) { 1722 1723 if (isEmpty(selector) || !isString(selector)) { 1724 console.error('selector is invalid'); 1725 return; 1726 } 1727 if (isEmpty(asyncCallback) || !isFunction(asyncCallback)) { 1728 console.error('asyncCallback is invalid'); 1729 return; 1730 } 1731 1732 if (isEmpty(waitMillis) || !isNumber(waitMillis) || waitMillis < 33) { 1733 waitMillis = 33; 1734 } else if (waitMillis > 1000) { 1735 waitMillis = 1000; 1736 } 1737 1738 if (isEmpty(timeoutMillis) || !isNumber(timeoutMillis)) { 1739 timeoutMillis = 2000; // default 2 seconds 1740 } else if (timeoutMillis > 15000) { 1741 timeoutMillis = 15000; // max 15 seconds 1742 } 1743 1744 let intervalId = setInterval(function() { 1745 if (timeoutMillis < 0) { 1746 clearInterval(intervalId); 1747 } else { 1748 let found = $(selector); 1749 if (found && found.length > 0) { 1750 asyncCallback(found); 1751 clearInterval(intervalId); 1752 } 1753 } 1754 timeoutMillis -= waitMillis; 1755 }, waitMillis); 1756 } 1757 1758 /* Function that returns true if the sidebar is in the collapsed (icon) state */ 1759 function isSidebarCollapsed() { 1760 return document.body.classList.contains('enlarged'); 1761 } 1762 1763 /* Function to navigate to the first menu item */ 1764 function navigateToActiveMenuItem() { 1765 var activeMenuItem = $('#sidebar-menu ul li a.active').first(); 1766 if (activeMenuItem.length > 0) { 1767 activeMenuItem[0].focus(); 1768 } 1769 } 1770 1771 /* Function that sets focus on the top left navigation menu item when the user clicks Skip to Navigation from the skip-links 1772 * If the left navigation is present, but hidden (ex. high-zoom or smaller browser window) we'll first expand it, but leave a focusin handler on main content to close the menu if required 1773 */ 1774 function setFocusToSidebar() { 1775 async function callback() { 1776 let focusEl = getSidebarActiveElement(); 1777 if (!isEmpty(focusEl)) { 1778 $(focusEl).trigger("focus"); 1779 } 1780 } 1781 if (isSidebarCollapsed() && isAccKeyboardMode()) { 1782 navigateToActiveMenuItem(); 1783 } 1784 waitFor('#sidebar li.active', callback); 1785 } 1786 1787 /* Function that sets keyboard focus to the most appropriate element inside the main content area, assuming new content has been loaded 1788 * @param forceAccKeyboardMode : true if you want to force enabling of acc-keyboard-mode 1789 * forceAccKeyboardMode is used by "Skip to Main Content" which keyboard users land on before the browser tab captures any keyboard activity 1790 * acc-keyboard-mode is only enabled after the users presses TAB (or shift-tab) 3 or more times and s
1790o is not usually enabled when the user 1791 * loads a new page and chooses "Skip to Main Content" after only pressing tab once. 1792 */ 1793 function setFocusToContent(forceAccKeyboardMode) { 1794 if (bypassFocusToMainContent) { 1795 bypassFocusToMainContent = false; 1796 return; 1797 } 1798 if (forceAccKeyboardMode === true && !isAccKeyboardMode()) { $("body").addClass("acc-keyboard-mode"); } 1799 1800 var focusElement = $('.content__top-element')[0]; 1801 if (isEmpty(focusElement)) { 1802 focusElement = findFirstActionableElementInsideContainer($("#page-cont")[0]); 1803 } 1804 $(focusElement).trigger("focus"); 1805 } 1806 1807 /* Function that sets keyboard focus to the first interactive element inside the modal main-content 1808 * (below the 'x' to close the modal in the title bar) 1809 * NOTE future: we should target the first interactive element that's not the 'x' 1810 * but include elements in the title bar such as "Past Emails" 1811 * @param modalId : the ID of the modal which is to take focus (ex. primary-modal) 1812 */ 1813 function setFocusToModal(modalId) { 1814 let pageContainer = $("#" + modalId)[0]; 1815 let focusElement = findFirstActionableElementInsideContainer(pageContainer); 1816 $(focusElement).trigger("focus"); 1817 } 1818 1819 /* Function that sets focus to the first actionable element inside a container referenced by ID 1820 * If the element associated with containerId is not found, no action is taken 1821 * @param containerId : the ID of the container element (div, span, whatever) inside which focus is to be set 1822 */ 1823 function setKeyboardFocusInsideContainer(containerId) { 1824 if (!isAccKeyboardMode()) { return; } 1825 let focusElement = findFirstActionableElementInsideContainer($('#' + containerId.replace('#', ''))[0]); 1826 if (isEmpty(focusElement)) { return; } 1827 focusElement.focus(); 1828 } 1829 1830 /* Function that smoothly scrolls to an element and focuses the next interactable element 1831 * @param elementId : the ID of the element to scroll to 1832 * @param offset : (optional) defaults to 0; the offset from the top of the element 1833 * @param duration : (optional) defaults to 500; number of milliseconds for the transition to take 1834 */ 1835 function a11yScrollToElement(elementId, offset = 0, duration = 500) { 1836 const target = $('#' + elementId); 1837 if (!target.length) return; 1838 $('html, body').animate({ scrollTop: target.offset().top - offset }, duration); 1839 setKeyboardFocusInsideContainer(elementId); 1840 } 1841 1842/*-----------------------------------------------------*\ 1843 * @TITLE 1844 * 1845 * Manage the document title (set title when empty, determine appropriate title) 1846 * 1847\*-----------------------------------------------------*/ 1848 1849 /* Function that sets the page title based on information defined in HeaderBootstrap (strPageName) 1850 * Don't call until after the page has loaded (presently only called once from setupAjaxAccessibility() above on every AJAX call successful return) 1851 * @global jsPageName : defined in HeaderBootstrap.ascx (based on the value assigned to VB global strPageName) 1852 */ 1853 function setPageName() { 1854 var isAjax = (jsAjaxRequest === false) ? false : true; 1855 if (isAjax && !isTransitionPageNav) { 1856 return; 1857 } 1858 isTransitionPageNav = false; // reset to default/false state 1859 1860 var titleText = (typeof jsPageName === 'undefined') ? '' : jsPageName.trim(); 1861 if (!isEmpty(titleText)) { 1862 document.title = convertToTitleCase(titleText.trim()); 1863 setMainContentAriaLabel(); 1864 } 1865 } 1866 1867 /* Function that maps the current page title to the main content container (presently #page-cont) 1868 * The screen reader will read the page title as you enter the main content area 1869 * FUTURE: create a quick function to return the main content container (centralize reference and support perhaps different pages) 1870 * discuss with Adrien 1871 */ 1872 function setMainContentAriaLabel() { 1873 var documentTitle = document.title.trim(); 1874 if (isEmpty(documentTitle)) {documentTitle = "Main Content."} 1875 if (!documentTitle.endsWith('.')) {documentTitle += '.';} 1876 $('#page-cont').attr('aria-label', documentTitle); 1877 } 1878 1879 /* Function that sets the dialog accessibility label on the dialog container (#primary-modal or #secondary-modal with role=dialog) 1880 * @param modalId : the ID of the modal which is to take focus (ex. primary-modal) 1881 */ 1882 function setModalDialogTitle(modalId) { 1883 if (isEmpty(modalId)) { console.error('modalId is empty'); return; } 1884 1885 let titleElem = $('#' + modalId + ' .modal-header h1'); 1886 if (titleElem.length < 1) { 1887 titleElem = $('#' + modalId + ' .modal-header h2'); 1888 } 1889 if (titleElem.length > 0) { 1890 let titleText = titleElem.text(); 1891 if (!isEmpty(titleText)) { 1892 $('#' + modalId).attr('aria-label', getSafeAriaLabelTextTrim(titleText)); 1893 } 1894 } 1895 } 1896 1897/*-----------------------------------------------------*\ 1898 * @IMAGES 1899 * 1900 * Functions to assist with accessibility requirements for images and CSS icons 1901 * 1902\*-----------------------------------------------------*/ 1903 1904 /* Function that adds keyboard and screen reader support to tooltip elements. 1905 * @param tooltipElement : a tooltip to be configured for keyboard/screen reader usability 1906 * There is no actual CSS definition for .aria-tooltip (and is not required) 1907 */ 1908 function setTooltipAriaLabel(tooltipElement) { 1909 if (!isEmpty($(tooltipElement).attr('data-original-title'))) { 1910 // The actual content of the tooltip will be read by the screen reader (we just want to let the user know
1910whey're on a tooltip) 1911 $(tooltipElement).addClass('aria-tooltip'); 1912 $(tooltipElement).attr('tabindex', '0'); 1913 $(tooltipElement).attr('aria-label', 'Tooltip. ' + getSafeAriaLabelTextTrim($(tooltipElement).attr('data-original-title'))); 1914 if (!$(tooltipElement).is('a, button, input, select, textarea')) { 1915 $(tooltipElement).attr('role', 'tooltip'); 1916 } 1917 } 1918 } 1919 1920 /* Function that selects all tooltips and enables them for keyboard/screen reader use (will not select tooltips that are already setup or previously setup) 1921 */ 1922 function setupTooltipAccessibility() { 1923 // TOOLTIPS need to be enabled for keyboard focus and additional screen reader context 1924 var elements = $('[data-toggle="tooltip"]:not(.aria-tooltip)'); 1925 for (var i=0; i<elements.length; i++) { 1926 if (isEmpty(elements[i].getAttribute('aria-label'))) { 1927 setTooltipAriaLabel(elements[i]); 1928 } 1929 } 1930 } 1931 1932 /* Function that hides an element from the keyboard and screen reader but leaves the element visible on the UI (as original) 1933 * @param element : the element to be hidden from keyboard/screen reader 1934 * There is no actual CSS definition for .aria-decorative (and is not required) 1935 */
1936 function hideElementFromKeyboardAndScreenReader(element) { 1937 // quick exit for elements previously processed 1938 if ($(element).hasClass('aria-decorative')) { return; } 1939 1940 // do the work for new elements 1941 $(element).addClass('aria-decorative'); 1942 $(element).attr('role', 'presentation'); 1943 $(element).attr('aria-hidden', 'true'); 1944 if (element.tagName === 'IMG') { 1945 $(element).attr('alt', ''); 1946 } 1947 if (isTabIndexSpecified(element) && $(element).attr('tabindex') >= 0) { 1948 $(element).attr('tabindex', '-1'); 1949 } 1950 } 1951 1952 /* Function that hides decorative images from the keyboard and screen reader 1953 */ 1954 function hideDecorativeImagesAndIconsFromScreenReader() { 1955 let selectors = ['.mdi', 'img[src^="/images/"]', '.caret', '.glyphicon']; 1956 1957 // we'll concatonate all elements of interest into a single array, then do the work 1958 var elements = []; 1959 for (var i=0; i<selectors.length; i++) { 1960 elements = elements.concat($(selectors[i] + ':not(.aria-decorative)').toArray()); 1961 } 1962 1963 for (var i=0; i<elements.length; i++) { 1964 let element = elements[i]; 1965 1966 var shouldBeAriaHidden = true; 1967 if ($(element).attr('data-toggle') === 'tooltip') { 1968 shouldBeAriaHidden = false; 1969 } else if (!isEmpty($(element).attr('onclick'))) { 1970 shouldBeAriaHidden = false; 1971 } else if (!isEmpty($(element).attr('onerror'))) { 1972 shouldBeAriaHidden = false; 1973 } else if (!isEmpty($(element).attr('data-keyclick'))) { 1974 shouldBeAriaHidden = false; 1975 } else if (!isEmpty($(element).text())) { 1976 shouldBeAriaHidden = false; 1977 } else if (!isEmpty($(element).attr('alt')) && $(element).attr('aria-decorative') === 'false') { 1978 shouldBeAriaHidden = false; 1979 } else if (isTabIndexSpecified(element) && $(element).attr('tabindex') >= 0) { 1980 shouldBeAriaHidden = false; 1981 } else if ($(element).prop("tagName") === "A" && !isTabIndexSpecified(element)) { 1982 shouldBeAriaHidden = false; 1983 } else if (!isEmpty($(element).attr('src'))) { 1984 if ($(element).attr('src').includes('/images/revisions_grey.png')) { 1985 shouldBeAriaHidden = false; 1986 } 1987 } 1988 1989 if (shouldBeAriaHidden) {
1990 hideElementFromKeyboardAndScreenReader(element); 1991 } 1992 } 1993 } 1994 1995/*-----------------------------------------------------*\ 1996 * @SAVE-FORM-ERRORS 1997 * 1998 * Manage error presentation when saving forms and errors are found 1999 * 2000\*-----------------------------------------------------*/ 2001 2002 /* -------------------------------------------------------------------------------------------------- 2003 * SAVE FORM Errors - handle errors on 'Save' form (for forms built in table_bootstrap__.inc) 2004 * 2005 */ 2006 var saveFormErrorCount = 0; 2007 var saveFormErrorMessages = {}; 2008 2009 /* -------------------------------------------------------------------------------------------------- 2010 * Returns true if errors were encountered when saving the form. 2011 * 2012 */ 2013 function hasSaveFormErrors() { 2014 return (saveFormErrorCount > 0) ? true : false; 2015 } 2016 2017 /* -------------------------------------------------------------------------------------------------- 2018 * Clear the save form error history. 2019 * 2020 */ 2021 function clearSaveFormErrors() { 2022 saveFormErrorCount = 0; 2023 saveFormErrorMessages = {}; 2024 } 2025 2026 /* -------------------------------------------------------------------------------------------------- 2027 * Add an error to saveFormErrorMessages. 2028 * - These will be presented to the user at the top of the form when save fails. 2029 * 2030 * @param {Element} obj : The input or equivalent that encountered an error on attempting to save the form 2031 * @param {string} message : The error message associated with the error encountered. 2032 * 2033 */ 2034 function addSaveFormError(obj, message) { 2035 2036 // @REMOVE-FROM here 2037 if (isEmpty(obj)) { 2038 console.error('obj is empty'); 2039 return; 2040 } 2041 2042 if (isEmpty(message)) { 2043 console.error('message is empty'); 2044 return; 2045 } else if (!isString(message)) { 2046 console.error('message is not a string'); 2047 return; 2048 } 2049 // @REMOVE-TO here 2050 2051 saveFormErrorCount++; 2052 2053 // @INVESTIGATE clean this up a bit below here 2054 var labelText = getLabelTextForElementWithoutAsterix(obj); 2055 if (isEmpty(labelText)) { 2056 console.error('labelText is empty'); 2057 return; 2058 } 2059 2060 if (!isEmpty(saveFormErrorMessages[labelText])) { 2061 // console.error('> addSaveFormError() labelText=' + labelText + ' already exists'); 2062 saveFormErrorCount--; 2063 return; 2064 } 2065 2066 saveFormErrorMessages[labelText] = message; 2067 } 2068 2069 /* -------------------------------------------------------------------------------------------------- 2070 * Returns a string representing the UI label associated with an input that received an error when 2071 * attempting to save the form. The string has a comma ',' or period '.' appended, depending on whether 2072 * or not it's the last error found while saving. 2073 * 2074 * Used to format the error notification (and associated aria content) when errors are found. 2075 * 2076 * @param {string} labelText : The UI label for the input that encountered an error on save. 2077 * @param {boolean} isLast : True if this is the last error encountered while saving the form. 2078 * 2079 */ 2080 function getSaveFormErrorFieldDescription(labelText, isLast) { 2081 2082 // @REMOVE-FROM here 2083 if (isEmpty(labelText)) { 2084 console.error('labelText is empty'); 2085 return ''; 2086 } 2087 2088 if (isEmpty(isLast)) { 2089 console.error('isLast is empty'); 2090 return ''; 2091 } 2092 // @REMOVE-TO here 2093 2094 if (isLast) { 2095 labelText += '.'; 2096 } else { 2097 labelText += ','; 2098 } 2099 2100 if (labelText.startsWith('*')) { 2101 labelText = labelText.substring(1).trim(); 2102 } 2103 labelText += ' '; 2104 2105 return labelText; 2106 } 2107 2108 /* -------------------------------------------------------------------------------------------------- 2109 * Generate an HTML insert with details on errors encountered when attempting to save a form. 2110 * - The HTML will be inserted into the '#table_form_save_errors' container at the top of the page 2111 * - Styled after the existing error messages at the top of main content 2112 * 2113 */ 2114 function displaySaveFormErrors() { 2115 // console.log('> displaySaveFormErrors() begins...'); 2116 2117 var topFormErrorMessageDiv = document.getElementById('table_form_save_errors'); 2118 if (isEmpty(topFormErrorMessageDiv)) { 2119 console.error('topFormErrorMessageDiv is empty.'); 2120 return; 2121 } 2122 2123 if (!hasSaveFormErrors()) { 2124 topFormErrorMessageDiv.style.display = 'none'; 2125 return; 2126 } 2127 2128 var errorHtml = '<i class="mdi mdi-block-helper"></i>'; 2129 if (saveFormErrorCount == 1) { 2130 errorHtml += 'Save failed. The following field is empty or contains invali
2130d data: '; 2131 } else { 2132 errorHtml += 'Save failed. The following ' + saveFormErrorCount + ' fields are empty or contain invalid data: '; 2133 } 2134 2135 var count = 0; 2136 var errorFieldsHtml = ''; 2137 for (var fieldName in saveFormErrorMessages) { 2138 var isLast = false; 2139 if (count >= (saveFormErrorCount - 1)) { 2140 isLast = true; 2141 } 2142 // console.log('> fieldName=' + fieldName + ', isLast=' + isLast); 2143 errorFieldsHtml += getSaveFormErrorFieldDescription(fieldName, isLast); 2144 count++; 2145 } 2146 2147 if (count < saveFormErrorCount) 2148 { 2149 var moreFields = null; 2150 var moreFieldsCount = saveFormErrorCount - count; 2151 if (moreFieldsCount === 1) { 2152 moreFields = ' and 1 other field'; 2153 } else { 2154 moreFields = ' and ' + moreFieldsCount + ' other fields'; 2155 } 2156 errorFieldsHtml += getSaveFormErrorFieldDescription(moreFields, true); 2157 } 2158 2159 if (isEmpty(errorFieldsHtml)) { 2160 console.error('errorFieldsHtml is empty'); 2161 errorFieldsHtml = ''; 2162 } 2163 errorHtml += getEncodedHtmlContent(errorFieldsHtml); 2164 2165 topFormErrorMessageDiv.innerHTML = errorHtml; 2166 topFormErrorMessageDiv.style.display = ''; 2167 topFormErrorMessageDiv.focus(); 2168 } 2169 2170/*-----------------------------------------------------*\ 2171 * @UTILITIES 2172 * 2173 * Basic utility or helper functions 2174 * 2175\*-----------------------------------------------------*/ 2176 2177 /* -------------------------------------------------------------------------------------------------- 2178 * Returns true if x is a bigint object. 2179 * 2180 * @param {Object} x : value to be considered 2181 * 2182 */ 2183 function isBigInt(x) { 2184 return typeof x === 'bigint'; 2185 } 2186 2187 /* -------------------------------------------------------------------------------------------------- 2188 * Returns true if x is a boolean object. 2189 * 2190 * @param {Object} x : value to be considered 2191 * 2192 */ 2193 function isBoolean(x) { 2194 return typeof x === 'boolean'; 2195 } 2196 2197 /* -------------------------------------------------------------------------------------------------- 2198 * Returns true if x is a JavaScript Date object. 2199 * 2200 * @param {Object} x : value to be considered 2201 * 2202 */ 2203 function isDate(x) { 2204 if (isEmpty(x)) { 2205 return false; 2206 } else if (!isObject(x)) { 2207 return false; 2208 } else if (isEmpty(x.now)) { 2209 return false; 2210 } else if (isEmpty(x.getTime)) { 2211 return false; 2212 } else if (isEmpty(x.getYear)) { 2213 return false; 2214 } else if (isEmpty(x.getMonth)) { 2215 return false; 2216 } else if (isEmpty(x.UTC)) { 2217 return false; 2218 } 2219 return true; 2220 } 2221 2222 /* -------------------------------------------------------------------------------------------------- 2223 * Returns true if x has no value (undefined, null or an empty trim() string) 2224 * 2225 * @param {Object} x : value to be considered 2226 * 2227 */ 2228 function isEmpty(x) { 2229 2230 if (typeof x === 'undefined') { 2231 return true; 2232 } else if (x === null) { 2233 return true; 2234 } else if (typeof x === 'string' && x.trim().length < 1) { 2235 return true; 2236 } 2237 return false; 2238 } 2239 2240 /* -------------------------------------------------------------------------------------------------- 2241 * Returns true if x is a function object 2242 * 2243 * @param {Object} x : value to be considered 2244 * 2245 */ 2246 function isFunction(x) { 2247 return typeof x === 'function'; 2248 } 2249 2250 /* -------------------------------------------------------------------------------------------------- 2251 * Returns true if x is a hex character 2252 * 2253 * @param {Object} x : value to be considered 2254 * 2255 */ 2256 var ulcHexCharacters = '0123456789abcdefABCDEF'; 2257 function isHexCharacter(c) { 2258 if (isEmpty(c)) { 2259 return false; 2260 } else if (!isString(c)) { 2261 return false; 2262 } 2263 2264 if (!ulcHexCharacters.includes(c)) { 2265 return false; 2266 } 2267 return true; 2268 } 2269 2270 /* -------------------------------------------------------------------------------------------------- 2271 * Returns true if x is a hex string 2272 * 2273 * @param {Object} x : value to be considered 2274 * 2275 */ 2276 function isHexString(s) { 2277 if (isEmpty(s)) { 2278 return false; 2279 } else if (!isString(s)) { 2280 return false; 2281 } 2282 2283 for (var i = 0; i < s.length; i++) { 2284 if (!isHexCharacter(s.charAt(i))) { 2285 return false; 2286 } 2287 } 2288 2289 return true; 2290 } 2291 2292 /* -------------------------------------------------------------------------------------------------- 2293 * Returns true if x is a jQuery object 2294 * 2295 * @param {Object} x : value to be considered 2296 * 2297 */ 2298 function isJQueryElement(x) { 2299 if (isEmpty(x)) { 2300 return false; 2301 } else if (!isEmpty(x.jquery)) { 2302 return true; 2303 } 2304 return false; 2305 } 2306 2307 /* -------------------------------------------------------------------------------------------------- 2308 * Returns true if x is null 2309 * 2310 * @param {Object} x : value to be considered 2311 * 2312 */ 2313 function isNull(x) { 2314 if (isUndefined(x)) { 2315 console.error('x is undefined'); 2316 return; 2317 } 2318 return x === null; 2319 } 2320 2321 /* -------------------------------------------------------------------------------------------------- 2322 * Returns true if x is a number 2323 * 2324 * @param {Object} x : value to be considered 2325 * 2326 */ 2327 function isNumber(x) { 2328 return typeof x === 'number' && !isNaN(x); 2329 } 2330 2331 /* -------------------------------------------------------------------------------------------------- 2332 * Returns true if x is a boolean number value (0 or 1) 2333 * 2334 * @param {Object} x : value to be considered 2335 * 2336 */ 2337 function isNumberBoolean(x) { 2338 // return true if x is a number and is either zero or one 2339 if (!isNumber(x)) { 2340 return false; 2341 } 2342 return x === 0 || x === 1; 2343 } 2344 2345 /* -------------------------------------------------------------------------------------------------- 2346 * Returns true if x is an object 2347 * 2348 * @param {Object} x : value to be considered 2349 * 2350 */ 2351 function isObject(x) { 2352 return typeof x === 'object'; 2353 } 2354 2355 /* -------------------------------------------------------------------------------------------------- 2356 * Returns true if x is a primitive JavaScript data type 2357 * 2358 * @param {Object} x : value to be considered 2359 * 2360 */ 2361 function isPrimitive(x) { 2362 // 2363 // return true if x is a JavaScript primitive data type 2364 // 2365 if (isBigInt(x)) { 2366 return true; 2367 } else if (isBoolean(x)) { 2368 return true; 2369 } else if (isFunction(x)) { 2370 // functions are treated as primitives in this implementation 2371 return true; 2372 } else if (isNumber(x)) { 2373 return true; 2374 } else if (isString(x)) { 2375 return true; 2376 } else if (isSymbol(x)) { 2377 return true; 2378 } else if (isUndefined(x)) { 2379 return true; 2380 } 2381 2382 return false; 2383 } 2384 2385 /* -------------------------------------------------------------------------------------------------- 2386 * Returns true if x is a string 2387 * 2388 * @param {Object} x : value to be considered 2389 * 2390 */ 2391 function isString(x) { 2392 return typeof x === 'string'; 2393 } 2394 2395 /* -------------------------------------------------------------------------------------------------- 2396 * Returns true if x is an boolean string value ('true' or 'false') 2397 * 2398 * @param {Object} x : value to be considered 2399 * 2400 */ 2401 function isStringBoolean(x) { 2402 // return true if x is a string with value 'true' or 'false' (after trim() and toLowerCase() applied) 2403 if (!isString(x)) { 2404 return false; 2405 } 2406 x = x.trim().toLowerCase(); 2407 return x === 'true' || x === 'false'; 2408 } 2409 2410 /* -------------------------------------------------------------------------------------------------- 2411 * Returns true if x is a symbol 2412 * 2413 * @param {Object} x : value to be considered 2414 * 2415 */ 2416 function isSymbol(x) { 2417 return typeof x === 'symbol'; 2418 } 2419 2420 /* -------------------------------------------------------------------------------------------------- 2421 * Returns true if x is undefined 2422 * 2423 * @param {Object} x : value to be considered 2424 * 2425 */ 2426 function isUndefined(x) { 2427 return typeof x === 'undefined'; 2428 } 2429 2430 /* -------------------------------------------------------------------------------------------------- 2431 * Returns true if x is a string representing a UUID value 2432 * 2433 * @param {Object} x : value to be considered 2434 * 2435 */ 2436 function isUUID(uuidString) { 2437 // a hack implementation but usable 2438 if (isEmpty(uuidString)) { 2439 return false; 2440 } else if (!isString(uuidString)) { 2441 return false; 2442 } else if (uuidString.length !== 36) { 2443 return false; 2444 } else if (uuidString.charAt(8) !== '-') { 2445 return false; 2446 } else if (uuidString.charAt(13) !== '-') { 2447 return false; 2448 } else if (uuidString.charAt(18) !== '-') { 2449 return false; 2450 } else if (uuidString.charAt(23) !== '-') { 2451 return false; 2452 } else { 2453 uuidString = uuidString.trim().toLowerCase().replaceAll('-', ''); 2454 if (uuidString.length != 32) { 2455 return false; 2456 } 2457 if (!isHexString(uuidString)) { 2458 return false; 2459 } 2460 } 2461 return true; 2462 } 2463 2464 /* -------------------------------------------------------------------------------------------------- 2465 * Toggle a boolean value (value may be represented as type boolean, a number (0/1) or a string (true/false)) 2466 * 2467 * @param {Object} x : value to be considered 2468 * 2469 */ 2470 function toggleBoolean(x) { 2471 if (isEmpty(x)) { 2472 console.error('x is empty'); 2473 return; 2474 } else if (isBoolean(x)) { 2475 return !x; 2476 } else if (isStringBoolean(x)) { 2477 return (x === 'false') ? 'true' : 'false'; 2478 } else if (isNumberBoolean(x)) { 2479 return (x === 0) ? 1 : 0; 2480 } else { 2481 console.error('typeof x is unsupported'); 2482 } 2483 } 2484 2485 /* --------------------------------------------------------------------------------------------------
2486 * Returns a string used for console log indentation. 2487 * - Used by getDescription() to generate easy to read console output. 2488 * 2489 * @param {number} indentCount : indentation level (using spaces) 2490 * 2491 */ 2492 function getIndent(indentCount) { 2493 var indent = ''; 2494 for (var count = 0; count < indentCount; count++) { 2495 indent += ' '; 2496 } 2497 return indent; 2498 } 2499 2500 /* -------------------------------------------------------------------------------------------------- 2501 * Returns a string used for console log indentation. 2502 * - Used by getDescription() to generate easy to read console output. 2503 * 2504 * @param {object} x : value/variable that you want to describe 2505 * @param {number} indentCount : indentation level (using spaces; used for recursive calls while building description) 2506 * 2507 */ 2508 var newlineChar = '\n'; 2509 function getDescription(x, indentCount) { 2510 var indentCountMax = 8; 2511 2512 if (isEmpty(indentCount)) { 2513 indentCount = 0; 2514 } 2515 2516 if (isUndefined(x)) { 2517 return '{undefined}'; 2518 } else if (isNull(x)) { 2519 return '{null}'; 2520 } else if (isUUID(x)) { 2521 return '{UUID}' + x.toString(); 2522 } else if (typeof x === 'string') { 2523 return '{string}' + x.toString(); 2524 } else if (typeof x === 'number') { 2525 return '{number}' + x.toString(); 2526 } else if (typeof x === 'boolean') { 2527 return '{boolean}' + x.toString(); 2528 } else if (typeof x === 'symbol') { 2529 return '{symbol}' + x.toString(); 2530 } else if (typeof x === 'function') { 2531 return '{function}' + x.toString(); 2532 } else if (isDate(x)) { 2533 return '{date}' + x.toString(); 2534 } else if (typeof x === 'object') { 2535 if (!isEmpty(x.length)) { 2536 var description = '{object,length=' + x.length + '}' + newlineChar; 2537 indentCount += 2; 2538 if (indentCount > indentCountMax) { 2539 description += getIndent(indentCount) + 'index[' + index + ']={more...;indentCount>indentCountMax}' + newlineChar; 2540 } else { 2541 for (var index = 0; index < x.length; index++) { 2542 description += getIndent(indentCount) + 'index[' + index + ']=' + getDescription(x[index], indentCount) + newlineChar; 2543 } 2544 return description.trim(); 2545 } 2546 } else if (getPropertyCount(x) > 0) { 2547 var description = '{object,propertyCount=' + getPropertyCount(x) + '}' + newlineChar; 2548 indentCount += 2; 2549 if (indentCount > indentCountMax) { 2550 description += getIndent(indentCount) + 'index[' + index + ']={more...;indentCount>indentCountMax}' + newlineChar; 2551 } else { 2552 for (var property in x) { 2553 description += getIndent(indentCount) + 'property[' + property + ']=' + getDescription(x[property], indentCount) + newlineChar; 2554 } 2555 return description.trim(); 2556 } 2557 } 2558 return '{object}' + x.toString(); 2559 } else { 2560 var unknownType = typeof x; 2561 return '{unknown-type[' + unknownType + ']}' + x.toString(); 2562 } 2563 } 2564 2565 /* -------------------------------------------------------------------------------------------------- 2566 * Returns the count of the number of properties the object 'x' has. 2567 * 2568 * @param {Object} x : value to be considered 2569 * 2570 */ 2571 function getPropertyCount(x) { 2572 if (isEmpty(x)) { 2573 return 0; 2574 } else if (isPrimitive(x)) { 2575 return 0; 2576 } 2577 2578 var count = 0; 2579 for (k in x) { 2580 count++; 2581 } 2582 2583 return count; 2584 } 2585 2586 /* -------------------------------------------------------------------------------------------------- 2587 * Returns a partial DOM path for the element specified. 2588 * - used for debug/diagnostics to get details on the path of the element being inspected 2589 * - called by getElementPath(elem) when building the element's full path 2590 * 2591 * @param {Element} elem : an element in the document 2592 * 2593 */ 2594 function getElementPathPart(elem) { 2595 if (isEmpty(elem)) { 2596 return ''; 2597 } 2598 var pathPart = elem.tagName; 2599 if (!isEmpty(elem.id)) { 2600 pathPart += '#' + elem.id; 2601 } else if (!isEmpty(elem.classList) && !isEmpty(elem.classList.toString())) { 2602 pathPart += '.' + replaceAll(elem.classList.toString(), ' ', '.'); 2603 } 2604 return pathPart; 2605 } 2606 2607 /* -------------------------------------------------------------------------------------------------- 2608 * Returns the full document path for an element. 2609 * - used for debug/diagnostics to get details on the path of the element being inspected 2610 * 2611 * @param {Element} elem : an element in the document 2612 * 2613 */ 2614 function getElementPath(elem) { 2615 // @REMOVE-FROM here 2616 if (isEmpty(elem)) { 2617 console.error('elem is empty'); 2618 return; 2619 } 2620 // @REMOVE-TO here 2621 2622 const pathPart = getElementPathPart(elem); 2623 2624 var parentPath = ''; 2625 const parentElement = elem.parentElement; 2626 if (!isEmpty(parentElement)) { 2627 parentPath = getElementPath(parentElement); 2628 } 2629 var dot = ''; 2630 if (parentPath.length > 0) { 2631 dot = '.'; 2632 } 2633 2634 return parentPath + dot + pathPart; 2635 } 2636 2637 /* -------------------------------------------------------------------------------------------------- 2638 * Replace all occurances of 'x' with 'y' in a string. 2639 * 2640 * @param {string} x : the string to be replaced 2641 * @param {string}
2641 y : the replacement text string 2642 * 2643 */ 2644 function replaceAll(text, x, y) { 2645 if (text === undefined || text === null || text === '') { return text; } 2646 if (x === undefined || x === null || x === '') { return text; } 2647 if (!text.includes(x)) { return text; } 2648 if (y === undefined || y === null) { y = ''; } 2649 return text.split(x).join(y); 2650 } 2651 2652 /* -------------------------------------------------------------------------------------------------- 2653 * Returns the string with the first letter capitalized. 2654 * 2655 * @param {string} s : A string value in which we want to capitalize the first letter. 2656 * 2657 */ 2658 function firstToCap(s) { 2659 return s.charAt(0).toUpperCase() + s.slice(1); 2660 } 2661 2662 /* -------------------------------------------------------------------------------------------------- 2663 * Convert a string to title case, with each word in the title capitalized. 2664 * 2665 * @param {string} text : A text string to be converted to title case. 2666 * 2667 */ 2668 function convertToTitleCase(text) { 2669 if (isEmpty(text)) { 2670 console.error('text is empty'); 2671 return ''; 2672 } 2673 if (text.length === 0) { 2674 return ''; 2675 } 2676 var parts = text.split(' '); 2677 for (var i=0; i<parts.length; i++) { 2678 if (parts[i].length < 2) { 2679 parts[i] = parts[i].toUpperCase(); 2680 } else { 2681 var firstChar = parts[i].charAt(0); 2682 if (firstChar === "'" || firstChar === '"' || firstChar === '(' || firstChar === '[' || firstChar === '{' || firstChar === '<' || firstChar === '$') { 2683 if (parts[i].length < 3) { 2684 parts[i] = firstChar + parts[i].charAt(1).toUpperCase(); 2685 } else { 2686 parts[i] = firstChar + parts[i].charAt(1).toUpperCase() + parts[i].substring(2); 2687 } 2688 } else { 2689 parts[i] = parts[i].charAt(0).toUpperCase() + parts[i].substring(1); 2690 } 2691 } 2692 } 2693 return parts.join(' '); 2694 } 2695 2696 /* -------------------------------------------------------------------------------------------------- 2697 * Return the number of seconds (to 3 decimals) elapsed since the specified epoch value (in millis). 2698 * 2699 * @param {number} epochStart : An epoch value, in milliseconds. 2700 * 2701 */ 2702 function getElapsedSeconds(epochStart) { 2703 if (isEmpty(epochStart)) { 2704 console.error('epochStart is empty'); 2705 return; 2706 } 2707 return (Date.now() - epochStart) / 1000.0; 2708 } 2709 2710 /* -------------------------------------------------------------------------------------------------- 2711 * Returns an HTML encoded string equivalent to text when decoded. Used when adding database (or any 2712 * potentially unsafe text) as HTML to the document. 2713 * 2714 * @param {string} text : The text string to be HTML encoded. 2715 * 2716 */ 2717 function getEncodedHtmlContent(text) { 2718 if (isEmpty(text)) { 2719 return ''; 2720 } 2721 2722 var elem = document.createElement('span'); 2723 elem.textContent = text; 2724 2725 var encodedHtml = elem.innerHTML; 2726 if (isEmpty(encodedHtml)) { 2727 encodedHtml = ''; 2728 } 2729 2730 return encodedHtml; 2731 } 2732 2733 /* Function that copies text content to the clipboard */ 2734 async function copyToClipboard(text) { 2735 try { 2736 await navigator.clipboard.writeText(text); 2737 alert('Copied to clipboard!'); 2738 } catch (err) { 2739 alert('Error unable to copy text to clipboard'); 2740 } 2741 } 2742 2743/*-----------------------------------------------------*\ 2744 @CHARTS 2745\*-----------------------------------------------------*/ 2746 2747 /* Function that toggles graphic charts to text view equivalent tables (and back) 2748 * @param {HTMLElement} toggleButton - The button clicked to toggle text/chart view 2749 * @param {string} chartSectionTitle - The title of the chart or chart section (usually the associated heading) 2750 * @param {string} chartContainerId - The ID of the DIV that contains both the chart view and text view for associated charts/
2750data 2751 * The toggle button text and aria-label will be updated when toggled to reflect the opposite toggle option 2752 */ 2753 function toggleChartView(toggleButton, chartSectionTitle, chartContainerId) { 2754 if (isEmpty(toggleButton)) { 2755 console.error('> toggleButton is empty'); 2756 return; 2757 } 2758 if (isEmpty(chartSectionTitle)) { 2759 console.error('> chartSectionTitle is empty'); 2760 return; 2761 } 2762 if (isEmpty(chartContainerId)) { 2763 console.error('> chartContainerId is empty'); 2764 return; 2765 } 2766 2767 let chartContainer = document.getElementById(chartContainerId); 2768 if (!chartContainer) { 2769 console.error('> chartContainer not found'); 2770 return; 2771 } 2772 2773 if ($(chartContainer).find('.charts-cg--chart-view.active').length > 0) { 2774 $(toggleButton).text('Switch to Chart View'); 2775 $(toggleButton).attr('aria-label', 'Switch to chart view'); 2776 $(chartContainer).find('.charts-cg--chart-view').removeClass('active'); 2777 $(chartContainer).find('.charts-cg--text-view').removeClass('active'); 2778 $(chartContainer).find('.charts-cg--text-view').addClass('active'); 2779 a11yAnnounceToScreenReader(chartSectionTitle + ', presenting data in text view'); 2780 } else { 2781 $(toggleButton).text('Switch to Text View'); 2782 $(toggleButton).attr('aria-label', 'Switch to text view'); 2783 $(chartContainer).find('.charts-cg--text-view').removeClass('active'); 2784 $(chartContainer).find('.charts-cg--chart-view').removeClass('active'); 2785 $(chartContainer).find('.charts-cg--chart-view').addClass('active'); 2786 a11yAnnounceToScreenReader(chartSectionTitle + ' presenting data in chart view'); 2787 } 2788 } 2789 2790 /* Function that copies a chart table to CSV text */ 2791 function getTableAsCSVText(tableId) { 2792 var csvText = ''; 2793 let tableElem = document.getElementById(tableId); 2794 if (tableElem) { 2795 let caption = tableElem.querySelector('caption'); 2796 if (caption) { 2797 let captionText = caption.getAttribute('aria-label'); 2798 if (!isEmpty(captionText)) { 2799 captionText = captionText.split('"').join('""'); 2800 csvText += '"' + captionText + '"\n'; 2801 } 2802 } 2803 let tableRows = tableElem.querySelectorAll('tr'); 2804 for (var i=0; i<tableRows.length; i++) { 2805 let cols = tableRows[i].querySelectorAll('td'); 2806 if (cols.length < 1) { 2807 cols = tableRows[0].querySelectorAll('th'); 2808 } 2809 for (var j=0; j<cols.length; j++) { 2810 let col = cols[j]; 2811 let colText = $(col).text().split('"').join('""'); 2812 csvText += '"' + colText + '"'; 2813 if (j < (cols.length-1)) { 2814 csvText += ','; 2815 } 2816 } 2817 csvText += '\n'; 2818 } 2819 } 2820 if (isEmpty(csvText)) { 2821 csvText = 'Error'; 2822 } 2823 return csvText; 2824 } 2825 2826/*-----------------------------------------------------*\ 2827 * @ARIA-LABEL 2828 * 2829 * Functions to prepare and set aria-label or alt= attribute text (or any attribute text enclosed in single or double quotes) 2830 * 2831\*-----------------------------------------------------*/ 2832 2833 /* Function that adds screen reader context (aria-label) to entries in the topbar notification dropdown. 2834 * The notifications are pre-generated and stored in the database so we can only update/alter them after being rendered in the DOM 2835 * The notifications are loaded only if you open the dropdown or click "load more" inside the dropdown 2836 * Using setTimeout() to make sure all associated DOM elements have had time to be rendered 2837 */ 2838 function setupNotificationsDropdownAccessibility() { 2839 setTimeout(function() { 2840 let notificationElements = $('#header__notif-dropdown #all_notifications .n
2840otification-cg--details'); 2841 for (var i=0; i<notificationElements.length; i++) { 2842 let notificationElement = notificationElements[i]; 2843 let notificationDetails = addAriaLabelPunctuation(getSafeAriaLabelTextTrim(notificationElement.innerHTML)); 2844 let notificationLinks = $(notificationElement).find('a'); 2845 2846 // the links embedded within the notification content 2847 for (var j=0; j<notificationLinks.length; j++) { 2848 let notificationLink = notificationLinks[j]; 2849 let notificationLinkAriaLabel = $(notificationLink).attr('aria-label'); 2850 if (isEmpty(notificationLinkAriaLabel)) { 2851 let notificationLinkHref = $(notificationLink).attr('href'); 2852 let notificationLinkDescription = 'Click for more details.'; 2853 if (notificationLinkHref.includes('/student_profile?uid')) { 2854 notificationLinkDescription = "Click to open the user's profile."; 2855 } else if (notificationLinkHref.includes('/d?t=event')) { 2856 notificationLinkDescription = "Click to open the event page."; 2857 } else if (notificationLinkHref.includes('/rsvp?id')) { 2858 notificationLinkDescription = "Click to open the event page."; 2859 } else if (notificationLinkHref.includes('/officer_login_redirect?r=')) { 2860 notificationLinkDescription = "Click to open group dashboard."; 2861 } 2862 $(notificationLink).attr('aria-label', notificationDetails + ' ' + notificationLinkDescription); 2863 $(notificationLink).attr('data-a11y', 'a01'); 2864 } 2865 } 2866 2867 // 'x' button to clear/delete the notification from dropdown 2868 let deleteNotificationLink = $(notificationElement).parent().find('a.close_sugg'); 2869 if (deleteNotificationLink.length === 1) { 2870 if (isEmpty($(deleteNotificationLink[0]).attr('aria-label'))) { 2871 $(deleteNotificationLink[0]).attr('aria-label', 'Clear notification'); 2872 $(deleteNotificationLink[0]).attr('aria-description', notificationDetails); 2873 } 2874 } 2875 2876 // Notification icon link (left of notification message); sometimes just an <img> sometimes an <a> with embedded <img> 2877 let notificationIconLinks = $(notificationElement).closest('tr').find('> td > a'); 2878 if (notificationIconLinks.length === 1) { 2879 $(notificationIconLinks[0]).attr('aria-label', 'Notification icon'); 2880 $(notificationIconLinks[0]).attr('aria-description', 'Notification icon. ' + notificationDetails + " Click to view user's profile."); 2881 } 2882 2883 $(notificationElement).attr('data-a11y', 'a01'); // flag content OK for automated testing 2884 } 2885 }, 100); 2886 } 2887 2888 /* Function that extracts the first occurance of text inside two markers (x and y) 2889 * If found, the entire text block, including the opening and closing markers, is returned 2890 * @param text : String text in which we want to find any text blocks starting with 'x' and ending with 'y' 2891 * @param x : String opening marker, for example, '<script>' 2892 * @param y : String closing marker, for example, '</script>' 2893 * @param returnFullTailIfYNotFound : when true and 'x' is found but 'y' is not, the entire string starting from 'x' is returned 2894 */ 2895 function extractFirstBlockInsideMarkers(text, x, y, returnFullTailIfYNotFound) { 2896 if (isEmpty(text)) { return; } 2897 if (isEmpty(x)) { return; } 2898 if (isEmpty(y)) { return; } 2899 if (isEmpty(returnFullTailIfYNotFound)) { returnFullTailIfYNotFound = false; } 2900 2901 var lcText = text.toLowerCase(); 2902 var sx = lcText.indexOf(x.toLowerCase()); 2903 if (sx < 0) { return; } 2904
2905 var ex = lcText.indexOf(y.toLowerCase(), sx + 1); 2906 if (ex < 0) { 2907 if (returnFullTailIfYNotFound) { 2908 return text.substring(sx); 2909 } else { 2910 return; 2911 } 2912 } 2913 return text.substring(sx, ex + y.length); 2914 } 2915 2916 /* Function that returns the index of the next occurance of the text 'searchFor' within the string 'text', starting from index. Search is case insensitive. 2917 * @param text : text within which to find 'searchFor' 2918 * @param searchFor : the string to find in 'text' 2919 * @param index : the starting index to begin searching 2920 * 2921 * Returns -1 if the string 'searchFor' is not found in 'text'. 2922 */ 2923 function indexOfIgnoreCase(text, searchFor, index) { 2924 if (typeof text === 'undefined' || text === null) { return -1; } 2925 if (typeof searchFor === 'undefined' || searchFor === null) { return -1; } 2926 if (isEmpty(index)) { index = 0; } 2927 return text.toLowerCase().indexOf(searchFor.toLowerCase(), index); 2928 } 2929 2930 /* Function that returns the index of the next HTML open quote (single quote or double-quote) from the specified index 2931 * @param htmlContent : text content that may or may not contain HTML (possibly with <script>, <style> or VB.Net blocks, etc.) 2932 * @param sx : start index to begin scanning forward (no default value; you must pass 0 (zero) to start from the beginning) 2933 */ 2934 function getNextHtmlOpenQuoteIndex(htmlContent, sx) { 2935 if (isEmpty(htmlContent)) { return -1; } 2936 if (isEmpty(sx) || sx < 0 || sx >= htmlContent.length) { return -1; } 2937 2938 var ix1 = htmlContent.indexOf('"', sx); // next double-quote 2939 var ix2 = htmlContent.indexOf("'", sx); // next single-quote 2940 if (ix1 >= sx && ix2 >= sx) { 2941 return Math.min(ix1, ix2); 2942 } else if (ix1 >= sx) { 2943 return ix1; 2944 } else if (ix2 >= sx) { 2945 return ix2; 2946 } 2947 return -1; 2948 } 2949 2950 /* Function that returns the index of the closing quote for a given open quote within HTML content, starting at the specified index 2951 * @param htmlContent : text content that may or may not contain HTML (possibly with <script>, <style> or VB.Net blocks, etc.) 2952 * @param openQuoteIndex : index within htmlContent of the opening quote for which we want to find the associated closing quote 2953 */ 2954 function getNextHtmlCloseQuoteIndex(htmlContent, openQuoteIndex) { 2955 if (isEmpty(htmlContent)) { return -1; } 2956 if (isEmpty(openQuoteIndex) || openQuoteIndex < 0 || openQuoteIndex >= htmlContent.length) { return -1; } 2957 2958 const chQuote = htmlContent[openQuoteIndex]; 2959 var ixOnlyQuote = htmlContent.indexOf(chQuote, openQuoteIndex + 1); 2960 var ixEscapeQuote = htmlContent.indexOf('\\' + chQuote, openQuoteIndex + 1); 2961 while (ixOnlyQuote < htmlContent.length && ixOnlyQuote === (ixEscapeQuote + 1)) { 2962 ixOnlyQuote = htmlContent.indexOf(chQuote, ixOnlyQuote + 1); 2963 ixEscapeQuote = htmlContent.indexOf('\\' + chQuote, ixOnlyQuote + 1); 2964 } 2965 return (ixOnlyQuote > openQuoteIndex) ? ixOnlyQuote : -1; 2966 } 2967 2968 /* Function that returns true if the specified character is a valid HTML tag identifier (typically upper/lower a-z but also !, %, etc.) 2969 * @param ch : the first character inside the associated tag (<{ch]...> OR </{ch}...>) 2970 */ 2971 function isValidHtmlTagNameCharacter(ch) { 2972 if (isEmpty(ch) || !isString(ch)) { return false; } 2973 if (ch.length > 1) { ch = ch[0]; } 2974 if (ch >= 'A' && ch <= 'Z') return true; 2975 if (ch >= 'a' && ch <= 'z') return true; 2976 if (ch > ' ' && ch < '+') return true; 2977 if (ch === '/') return true; 2978 return false; 2979 } 2980 2981 /* Function that returns the index of the next '<' character representing the opening of an HTML tag (or equivalent such as VB.Net < followed by %) 2982 * @param htmlContent : text content that may or may not contain HTML (possibly with <script>, <style> or VB.Net blocks, etc.) 2983 * @param sx : start index swithin htmlContent to begin scanning forward (defaults to zero) 2984 */ 2985 function getNextTagOpenIndex(htmlContent, sx) { 2986 if (isEmpty(htmlContent)) { return -1; } 2987 if (isEmpty(sx)) { 2988 sx = 0; 2989 } else if (sx < 0 || sx >= htmlContent.length) { 2990 return -1; 2991 } 2992 2993 var ix = htmlContent.indexOf('<', sx); 2994 while (ix >= sx && ix < (htmlContent.length - 1) && !isValidHtmlTagNameCharacter(htmlContent[ix+1])) { 2995 ix = htmlContent.indexOf('<', ix+1); 2996 } 2997 return (ix < sx || ix >= htmlContent.length) ? -1 : ix; 2998 } 2999 3000 /* Function that returns the index of the next '>' character representing the closing of an HTML tag 3001 * @param htmlContent : text content that may or may not contain HTML (possibly with <script>, <style> or VB.Net blocks, etc.) 3002 * @param openTagIndex : index within htmlContent where the HTML tag was opened (index of '<' that opened the HTML tag) 3003 */ 3004 function getNextTagCloseIndex(htmlContent, openTagIndex) { 3005 if (isEmpty(htmlContent)) { return -1; } 3006 if (isEmpty(openTagIndex) || openTagIndex < 0 || openTagIndex >= htmlContent.length) { return -1; } 3007 3008 var closeTagIndex = htmlContent.indexOf('>', openTagIndex); 3009 if (closeTagIndex < 0) {return -1; } 3010 3011 var openQuoteIndex = getNextHtmlOpenQuoteIndex(htmlContent, openTagIndex); 3012 while (openQuoteIndex > openTagIndex && openQuoteIndex < closeTagIndex) { 3013 var closeQuoteIndex = getNextHtmlCloseQuoteIndex(htmlContent, openQuoteIndex); 3014 if (closeQuoteIndex < 0) { 3015 return -1; 3016 } else if (closeQuoteIndex < closeTagIndex) { 3017 var oqi = getNextHtmlOpenQuoteIndex(htmlContent, closeQuoteIndex+1); 3018 if (oqi > closeTagIndex) { 3019 return closeTagIndex; 3020 } 3021 openQuoteIndex = oqi; 3022 } else if (closeQuoteIndex > closeTagIndex) { 3023 closeTagIndex = htmlContent.indexOf('>', closeQuoteIndex); 3024 if (closeTagIndex < 0) { 3025 return -1; 3026 } 3027 openQuoteIndex = getNextHtmlOpenQuoteIndex(htmlContent, openTagIndex); 3028 } 3029 } 3030 return closeTagIndex; 3031 } 3032 3033 /* Function that returns the index of the next '</' character sequence representing the opening of a "close tag" ex. </div> or </span>, etc. 3034 * @param htmlContent : text content that may or may not contain HTML (possibly with <script>, <style> or VB.Net blocks, etc.) 3035 * @param sx : start index swithin htmlContent to begin scanning forward (defaults to zero) 3036 */ 3037 function getNextClosingTagIndex(htmlContent, sx) { 3038 if (isEmpty(htmlContent)) { return -1; } 3039 if (isEmpty(sx)) { 3040 sx = 0; 3041 } else if (sx < 0 || sx >= htmlContent.length) { 3042 return -1; 3043 } 3044 3045 var ix = htmlContent.indexOf('</'); 3046 while (ix >= sx && ix < (htmlContent.length-2) && !isValidHtmlTagNameCharacter(htmlContent[ix+2])) { 3047 ix = htmlContent.indexOf('</', ix+1); 3048 } 3049 return (ix < sx || ix >= htmlContent.length) ? -1 : ix; 3050 } 3051 3052 /* Function that converts any coded character values back to the associated character. 3053 * @param text : string possibly containing HTML encoded characters 3054 */ 3055 function decodeHtmlCodes(text) { 3056 let sx = text.indexOf('&#'); 3057 while (sx >= 0) { 3058 let ex = text.indexOf(';', sx + 2); 3059 if ((ex-sx > 2) && (ex-sx <= 9)) { 3060 let ix = sx + 2; 3061 let isHtmlCode = true; 3062 while (isHtmlCode && ix < ex) { 3063 isHtmlCode = (text[ix] >= '0' && text[ix] <= '9') ? true : false; 3064 ix++; 3065 } 3066 if (isHtmlCode) { 3067 let htmlCode = text.substring(sx, ex + 1); 3068 let codeValue = parseInt(text.substring(sx + 2, ex)); 3069 text = text.split(htmlCode).join(String.fromCharCode(codeValue)); 3070 } 3071 } 3072 sx = text.indexOf('&#', sx + 1); 3073 } 3074 return text; 3075 } 3076 3077 /* Function that extracts pure (visible) text content from HTML content, leaving breathers. Intended for safe HTML attribute text and clear/understandable text for screen readers. 3078 * Extract the "usable" text content from the innerHTML of a div, span, etc. 3079 * 1. Remove any script or style blocks 3080 * 2. Append a space before any break, paragraph, span, div, etc. so the text isn't concatenated (leave space between text where we would see breaks/spaces)
3081 * 3. Replace tabs and double-spaces with single spaces 3082 * @param htmlContent : HTML content string to be converted to human visible text (if one were looking at the UI) including breathers for screen reader to read properly 3083 */ 3084 function extractPureTextContentFromHtml(htmlContent) { 3085 if (isEmpty(htmlContent)) return ' '; 3086 3087 // Specific HTML code replacement 3088 htmlContent = replaceAll(htmlContent, '«', '<'); 3089 htmlContent = replaceAll(htmlContent, '»', '>'); 3090 htmlContent = replaceAll(htmlContent, '<', '<'); 3091 htmlContent = replaceAll(htmlContent, '>', '>'); 3092 3093 htmlContent = decodeHtmlCodes(htmlContent); 3094 3095 // convert non-breaking space to regular space (the decoded nbsp is ascii 160) 3096 htmlContent = replaceAll(htmlContent, '¶', ' '); 3097 htmlContent = replaceAll(htmlContent, ' ', ' '); 3098 htmlContent = replaceAll(htmlContent, String.fromCharCode(9), ' '); 3099 htmlContent = replaceAll(htmlContent, String.fromCharCode(10), ' '); 3100 htmlContent = replaceAll(htmlContent, String.fromCharCode(11), ' '); 3101 htmlContent = replaceAll(htmlContent, String.fromCharCode(13), ' '); 3102 htmlContent = replaceAll(htmlContent, String.fromCharCode(160), ' '); 3103 htmlContent = replaceAll(htmlContent, String.fromCharCode(182), ' '); 3104 htmlContent += ' '; // why again - do we need padding on the tail to help the below work better ? 3105 3106 // VB.Net 3107 var openTag = '<%--'; 3108 var closeTag = '--%>'; 3109 var blockToRemove = extractFirstBlockInsideMarkers(htmlContent, openTag, closeTag, true); 3110 while (!isEmpty(blockToRemove)) { 3111 htmlContent = htmlContent.replace(blockToRemove, ' '); 3112 blockToRemove = extractFirstBlockInsideMarkers(htmlContent, openTag, closeTag, true); 3113 } 3114 3115 // VB.net 3116 openTag = '<%'; 3117 closeTag = '%>'; 3118 blockToRemove = extractFirstBlockInsideMarkers(htmlContent, openTag, closeTag, true); 3119 while (!isEmpty(blockToRemove)) { 3120 htmlContent = htmlContent.replace(blockToRemove, ' '); 3121 blockToRemove = extractFirstBlockInsideMarkers(htmlContent, openTag, closeTag, true); 3122 } 3123 3124 // HTML 3125 openTag = '<!--'; 3126 closeTag = '-->'; 3127 blockToRemove = extractFirstBlockInsideMarkers(htmlContent, openTag, closeTag, true); 3128 while (!isEmpty(blockToRemove)) { 3129 htmlContent = htmlContent.replace(blockToRemove, ' '); 3130 blockToRemove = extractFirstBlockInsideMarkers(htmlContent, openTag, closeTag, true); 3131 } 3132 3133 // HTML names 3134 htmlContent = replaceAll(htmlContent, '&', '&'); 3135 htmlContent = replaceAll(htmlContent, ''', "â"); 3136 htmlContent = replaceAll(htmlContent, '©', ' copyright '); 3137 htmlContent = replaceAll(htmlContent, '°', ' degrees '); 3138 htmlContent = replaceAll(htmlContent, '÷', ' divided by '); 3139 htmlContent = replaceAll(htmlContent, '€', ' euros '); 3140 htmlContent = replaceAll(htmlContent, '>', '>'); 3141 htmlContent = replaceAll(htmlContent, '«', '<'); 3142 htmlContent = replaceAll(htmlContent, '<', '<'); 3143 htmlContent = replaceAll(htmlContent, '·', '-'); // minus sign 3144 htmlContent = replaceAll(htmlContent, '–', '-'); 3145 htmlContent = replaceAll(htmlContent, '"', '"'); // double-quote 3146 htmlContent = replaceAll(htmlContent, '»', '>'); 3147 htmlContent = replaceAll(htmlContent, '®', ' registered trade mark '); 3148 htmlContent = replaceAll(htmlContent, '×', ' x '); // letter x 3149 htmlContent = replaceAll(htmlContent, '¨', '"'); // double-quote 3150 3151 // Script, style, etc. blocks 3152 const extractFullTailWhenCloseTagIsAbsent = true; 3153 const fullBlockRemovalTags = [ 'script', 'style', 'svg', 'math', 'meta', 'metadata', 'head', 'body', 'html', 'iframe' ]; 3154 for (var i=0; i<fullBlockRemovalTags.length; i++) { 3155 let openTags = [ '<' + fullBlockRemovalTags[i] + '>', '<' + fullBlockRemovalTags[i] + ' ' ]; 3156 let closeTag = '</' + fullBlockRemovalTags[i] + '>'; 3157 3158 for (var otx = 0; otx < openTags.length; otx++) { 3159 let openTag = openTags[otx]; 3160 var blockToRemove = extractFirstBlockInsideMarkers(htmlContent, openTag, closeTag, extractFullTailWhenCloseTagIsAbsent); 3161 while (!isEmpty(blockToRemove)) { 3162 htmlContent = htmlContent.replace(blockToRemove, ' '); 3163 blockToRemove = extractFirstBlockInsideMarkers(htmlContent, openTag, closeTag, extractFullTailWhenCloseTagIsAbsent); 3164 } 3165 } 3166 } 3167 3168 // add breather spacing between tags 3169 htmlContent = replaceAll(htmlContent, '><', '> <').trim(); 3170 3171 // Remove just the tags (leaving inner text content) 3172 var otix = getNextTagOpenIndex(htmlContent); 3173 while (otix >= 0) { 3174 var ctix = getNextTagCloseIndex(htmlContent, otix); 3175 if (ctix > otix) { 3176 // console.log('>
3176 EXTRACT otix=' + otix + ' ctix=' + ctix + ' extract=[' + htmlContent.substring(otix, ctix + 1) + ']'); 3177 htmlContent = htmlContent.substring(0, otix) + ' ' + htmlContent.substring(ctix + 1); 3178 otix = getNextTagOpenIndex(htmlContent, otix); 3179 } else { 3180 // an open tag '<' with no matching close to the tag (can't find '>' beyond '<') 3181 otix = -1; 3182 } 3183 } 3184 3185 return htmlContent.trim(); 3186 } 3187 3188 /* Function that returns safe text for use in ALT attributes, aria-label attributes or other text attributes 3189 * Replaces all single and double quote characters with their extended ASCII code equivalents, so they function as expected using screen reader 3190 * Converts single and double quotes to alternate character codes that "look" the same but are not recognized as single/double quotes by HTML parser 3191 * @param text : String text to be prepared for assignment to an attribute for screen reader use (alt, aria-label attributes, etc.) 3192 * 3193 * Return value will not include single or double quotes and will not incude any of the following: & < > = (+more, see below) 3194 */ 3195 function getSafeAriaLabelText(text) { 3196 if (isEmpty(text)) { return ' '; } 3197 3198 // console.log('> getSafeAriaLabelText() text=' + text); 3199 3200 // Remove any HTML, script/style blocks, etc. 3201 var safeAttributeText = extractPureTextContentFromHtml(text); 3202 // safeAttributeText = unescape(safeAttributeText); 3203 3204 // Replace specific characters by code 3205 safeAttributeText = replaceAll(safeAttributeText, String.fromCharCode(35), ' number sign '); // # 3206 safeAttributeText = replaceAll(safeAttributeText, String.fromCharCode(42), ' asterix '); // * 3207 safeAttributeText = replaceAll(safeAttributeText, String.fromCharCode(92), ' backslash '); // backslash key 3208 safeAttributeText = replaceAll(safeAttributeText, String.fromCharCode(169), ' copyright '); // © 3209 safeAttributeText = replaceAll(safeAttributeText, String.fromCharCode(183), '-'); // · 3210 safeAttributeText = replaceAll(safeAttributeText, String.fromCharCode(215), ' times '); // × 3211 safeAttributeText = replaceAll(safeAttributeText, String.fromCharCode(247), ' divided by'); // ÷ 3212 safeAttributeText = replaceAll(safeAttributeText, String.fromCharCode(8211), '-'); // en dash 3213 safeAttributeText = replaceAll(safeAttributeText, String.fromCharCode(8212), '-'); // em dash 3214 safeAttributeText = replaceAll(safeAttributeText, String.fromCharCode(8482), ' trade mark '); // TM symbol 3215 3216 // Specific to date/time text 3217 safeAttributeText = replaceAll(safeAttributeText, "GMT-", "GMT minus "); 3218 3219 // Improve readability 3220 safeAttributeText = replaceAll(safeAttributeText, "!=", " not equal to "); 3221 safeAttributeText = replaceAll(safeAttributeText, "<=", " less than or equal to "); 3222 safeAttributeText = replaceAll(safeAttributeText, ">=", " greater than or equal to "); 3223 safeAttributeText = replaceAll(safeAttributeText, "<>", " less than or greater than "); 3224 3225 // Safe readable text (these would otherwise be encoded and not readable by screen reader) 3226 safeAttributeText = replaceAll(safeAttributeText, '&', ' and '); // & ampersand 3227 safeAttributeText = replaceAll(safeAttributeText, '<', ' less than '); // < 3228 safeAttributeText = replaceAll(safeAttributeText, '>', ' greater than '); // > 3229 safeAttributeText = replaceAll(safeAttributeText, "'", String.fromCharCode(8217)); // single quote 3230 safeAttributeText = replaceAll(safeAttributeText, '"', String.fromCharCode(8220)); // double quote 3231 3232 // Improve readability 3233 safeAttributeText = replaceAll(safeAttributeText, "=", " equals "); 3234 safeAttributeText = replaceAll(safeAttributeText, "^", " caret "); 3235 safeAttributeText = replaceAll(safeAttributeText, "+", " plus "); 3236 // safeAttributeText = replaceAll(safeAttributeText, "-", " dash "); 3237 safeAttributeText = replaceAll(safeAttributeText, "~", " tilde "); 3238 safeAttributeText = replaceAll(safeAttributeText, "_", " underscore "); 3239 safeAttributeText = replaceAll(safeAttributeText, "|", " vertical bar "); 3240 safeAttributeText = replaceAll(safeAttributeText, "/", " slash "); 3241 3242 // Add breathers around braces and brackets 3243 safeAttributeText = replaceAll(safeAttributeText, '{', ' { '); 3244 safeAttributeText = replaceAll(safeAttributeText, '}', ' } '); 3245 safeAttributeText = replaceAll(safeAttributeText, '(', ' ( '); 3246 safeAttributeText = replaceAll(safeAttributeText, ')', ' ) '); 3247 safeAttributeText = replaceAll(safeAttributeText, '[', ' [ '); 3248 safeAttributeText = replaceAll(safeAttributeText, ']', ' ] '); 3249 3250 // Replace DOUBLE-SPACE with SPACE 3251 while (safeAttributeText.includes(' ')) { 3252 safeAttributeText = safeAttributeText.replaceAll(' ', ' '); 3253 } 3254 3255 // console.log('> safeAttributeText=' + safeAttributeText); 3256 3257 return safeAttributeText.trim() + ' '; 3258 } 3259 3260 /* Function that returns a trim() version of safe aria-label text from getSafeAriaLabelText() 3261 * @param text : String text to be assigned to an element attribute for screen reader use (alt, aria-label attributes, etc.) 3262 */ 3263 function getSafeAriaLabelTextTrim(text) { 3264 return getSafeAriaLabelText(text).trim(); 3265 } 3266 3267 /* Function that updates the aria-label for any input validation, based on real-time feedback as you type. 3268 * Generic verions of setAriaLabelForGroupAcronymInput() 3269 */ 3270 function setAriaLabelForInputValidation(inputKey) { 3271 var $inputElem = $("#" & inputKey); 3272 if ($inputElem.length !== 1) { return; } 3273 3274 var $inputHelpblock = $("#" & inputKey & "_helpblock"); 3275 if ($inputHelpblock.length !== 1) { return; } 3276 3277 var ariaLabelText = $inputElem.attr('data-original-aria-label'); 3278 if (isEmpty(ariaLabelText)) { 3279 ariaLabelText = ''; 3280 } 3281 3282 var inputHelpblockText = $inputHelpblockText.text(); 3283 if (!isEmpty(inputHelpblockText)) { 3284 ariaLabelText = inputHelpblockText + ' ' + ariaLabelText; 3285 } 3286 3287 setTimeout(function () { 3288 $inputElem.attr('aria-label', getSafeAriaLabelTextTrim(ariaLabelText)); 3289 }, 250); 3290 } 3291 3292 /* Function that returns a screen reader audible equivalent to a number value between -999 and +999. 3293 * Convert numbers (integers) to text for screen reader. Numbers outside of range will be returned as a string version of the number. 3294 * @param n : Number between -999 and +999 3295 */ 3296 function getAriaNumberName(n) { 3297 if (isEmpty(n) || !isNumber(n)) { return ''; } 3298 if (Math.abs(n) >= 1000) { return '' + n; } 3299 if (n < 0) { return 'minus ' + getAriaNumberName(-n); } 3300 if (n === 0) { return numberNames[0]; } 3301 3302 var numberName = ''; 3303 3304 var hundreds = Math.floor(n / 100); 3305 if (hundreds > 0) { numberName += numberNames[hundreds] + ' hundred'; } 3306 3307 n = Math.floor(n % 100); 3308 if (n > 0) { 3309 if (!isEmpty(numberName)) { numberName += ' and '; } 3310 if (n < 20) { 3311 numberName += numberNames[n]; 3312 } else { 3313 numberName += tensNames[Math.floor(n / 10)]; 3314 var ones = Math.floor(n % 10);
3315 if (ones > 0) { 3316 numberName += ' ' + numberNames[ones]; 3317 } 3318 } 3319 } 3320 return numberName.trim(); 3321 } 3322 3323 /* Function that converts event date and time information into a version suitable for the screen reader (alt text, aria-label, etc.) 3324 * Converts event start/end date details, as returned by the database, to a more screen reader (and removes any embedded HTML or scripts) 3325 * @param eventDateDetails : The event start/end date as provided by the database (including HTML for formatting inside an event carousel card or equivalent) 3326 */ 3327 function convertEventDateDetailsToAriaLabelText(eventDateDetails) { 3328 3329 var expandedTerms = {}; 3330 // day names 3331 expandedTerms['Mon,'] = 'Monday,'; 3332 expandedTerms['Tue,'] = 'Tuesday,'; 3333 expandedTerms['Wed,'] = 'Wednesday,'; 3334 expandedTerms['Thu,'] = 'Thursday,'; 3335 expandedTerms['Fri,'] = 'Friday,'; 3336 expandedTerms['Sat,'] = 'Saturday,'; 3337 expandedTerms['Sun,'] = 'Sunday,'; 3338 // month names 3339 expandedTerms[' Jan '] = ' January '; 3340 expandedTerms[' Feb '] = ' February '; 3341 expandedTerms[' Mar '] = ' March '; 3342 expandedTerms[' Apr '] = ' April '; 3343 expandedTerms[' May '] = ' May '; 3344 expandedTerms[' Jun '] = ' June '; 3345 expandedTerms[' Jul '] = ' July '; 3346 expandedTerms[' Aug '] = ' August '; 3347 expandedTerms[' Sep '] = ' September '; 3348 expandedTerms[' Oct '] = ' October '; 3349 expandedTerms[' Nov '] = ' November '; 3350 expandedTerms[' Dec '] = ' December '; 3351 // other 3352 expandedTerms['\n'] = ' '; 3353 expandedTerms[' .'] = '.'; 3354 3355 eventDateDetails = getSafeAriaLabelText(eventDateDetails); 3356 for (var k in expandedTerms) { 3357 eventDateDetails = replaceAll(eventDateDetails, k, expandedTerms[k]); 3358 } 3359 return eventDateDetails.trim(); 3360 } 3361 3362 /* Function that adds punctuation to the end of a block of text, typically to prepare multiple blocks of text for the screen reader. 3363 * If punctuationChar is not empty AND the text is already terminated with a punctuation character, the existing punctuation character will be replaced with the value specified by 'punctuationChar'. 3364 * Used to prepar ALT text or aria-label, etc. 3365 * @param text : String text to be terminated with punctuation. 3366 * @param punctuationChar : The desired punctuation to be placed at the end of 'text'. If empty, defaults to a period '.', when provided, will replace any existing punctuation at the end of 'text'. 3367 */ 3368 function addAriaLabelPunctuation(text, punctuationChar) { 3369 var tailPadding = ' '; 3370 if (isEmpty(text)) { return tailPadding; } 3371 3372 text = text.trim(); 3373 var hasPunctuation = text.endsWith('.') || text.endsWith('!') || text.endsWith('?') || text.endsWith(':') || text.endsWith(';') || text.endsWith('-') || text.endsWith(','); 3374 3375 if (isEmpty(punctuationChar)) { 3376 // Don't force the punctuation - just use a '.' if there is no punctuation present 3377 if (!hasPunctuation) { text = text + '.'; } 3378 return text + tailPadding; 3379 } else if (text.endsWith(punctuationChar)) { 3380 // already ends with the specified punctuation 3381 return text + tailPadding; 3382 } else if (hasPunctuation) { 3383 // If text length is 1, we have a single-character punctuation mark to begin with - equivalent to an empty string 3384 if (text.length <= 1) { return tailPadding; } 3385 3386 // Remove existing punctuation and replace with specified punctuationChar 3387 return text.substring(0, text.length - 1) + punctuationChar + tailPadding; 3388 } 3389 return text + punctuationChar + tailPadding; 3390 } 3391 3392 // END refactoring 2021-01-06 3393 3394 /* -------------------------------------------------------------------------------------------------- 3395 * Get the UI label for an input and format for use in an aria-label or alt attribute (or equivalent) 3396 * - We only want the first sentence (or text up to a period) if the label has a lot of text 3397 * - We also want to remove the word "Select" from the start of the label if present 3398 * @param {string} textContent : The visible UI text label for an input 3399 */ 3400 function extractVisibleLabel(textContent) { 3401 if (isEmpty(textContent)) { 3402 console.warn('textContent is empty'); 3403 return ''; 3404 } 3405 3406 var ex = textContent.indexOf('.'); 3407 if (ex > 0) 3408 { 3409 textContent = textContent.substring(0, ex); 3410 } 3411 textContent = textContent.replace("Select ", ""); 3412 3413 return textContent.trim(); 3414 } 3415 3416 /* -------------------------------------------------------------------------------------------------- 3417 * Attempt to determine the visible "UI" label for an element from the element's aria-label attribute. 3418 * - May be modified/shortened if the aria-label is long or represents a SELECT dropdown input 3419 * @param {Element} elem : An input element 3420 */ 3421 function extractVisibleLabelFromAriaLabel(elem) { 3422 3423 if (isEmpty(elem)) { 3424 console.error('elem is empty.'); 3425 return ''; 3426 } 3427 3428 var ariaLabel = elem.getAttribute('aria-label'); 3429 if (isEmpty(ariaLabel)) { 3430 return ''; 3431 } 3432 3433 return extractVisibleLabel(ariaLabel); 3434 } 3435 3436 /* -------------------------------------------------------------------------------------------------- 3437 * Extract the visible UI label from an input's associated <label> element's text content 3438 * - May be modified/shortened if the label text is long or represents a SELECT dropdown input 3439 * @param {Element} labelElem : Label element <label> associated with an input 3440 */ 3441 function extractVisibleLabelFromLabelTextContent(labelElem) { 3442 3443 if (isEmpty(labelElem)) { 3444 console.error('labelElem is empty.'); 3445 return ''; 3446 } 3447
3448 if (isEmpty(labelElem.innerHTML)) { 3449 return ''; 3450 } 3451 3452 // 3453 // we don't want to use the textContent of the label if it includes any functional code 3454 // i.e. anything other than pure label content and associated standard formatting 3455 // <span> is OK for formatting for example 3456 // 3457 if (labelElem.innerHTML.includes('<script') || labelElem.innerHTML.includes('<style') || labelElem.innerHTML.includes('$(')) { 3458 console.error('> extractVisibleLabelFromLabelTextContent() unusable content.'); 3459 return ''; 3460 } 3461 3462 return extractVisibleLabel(labelElem.textContent); 3463 } 3464 3465 /* -------------------------------------------------------------------------------------------------- 3466 * Extract the visible UI label from an input's associated <label> element 3467 * - First check if the label has an aria-label attribute defined 3468 * - If not, try to use the label's text content 3469 * 3470 * - May be modified/shortened if the label text is long or represents a SELECT dropdown input 3471 * 3472 * @param {Element} labelElem : Label element <label> associated with an input 3473 * 3474 */ 3475 function extractVisibleLabelFromLabel(labelElem) 3476 { 3477 if (isEmpty(labelElem)) { 3478 console.error('labelElem is empty.'); 3479 return ''; 3480 } 3481 3482 var labelText = extractVisibleLabelFromAriaLabel(labelElem); 3483 if (isEmpty(labelText)) { 3484 labelText = extractVisibleLabelFromLabelTextContent(labelElem); 3485 if (isEmpty(labelText)) { 3486 labelText = ''; 3487 } 3488 } 3489 3490 labelText = labelText.trim().replace('<', '<'); 3491 3492 return labelText; 3493 } 3494 3495 /* -------------------------------------------------------------------------------------------------- 3496 * Get the <label> element associated with an input (or equivalent) 3497 * - The CG coding convention appears to use 'label-for' + inputId as the ID for the associated <label> 3498 * - can be expanded if necessary if there are more patterns to consider 3499 * 3500 * @param {string} elemId : The ID of an input (or equivalent) for which we want to find the associated label element 3501 * 3502 */ 3503 function getLabelForElement(elemId) 3504 { 3505 // @REMOVE-FROM here 3506 if (isEmpty(elemId)) { 3507 console.error('elemId is empty'); 3508 return; 3509 } 3510 // @REMOVE-TO here 3511 3512 var elemLabelId = 'label-for-' + elemId; 3513 return document.getElementById(elemLabelId); 3514 } 3515 3516 /* -------------------------------------------------------------------------------------------------- 3517 * Get the UI label text for an element (typically an input element). 3518 * - First check if the element has an associated label (and if so, the text from the <label>) 3519 * - If we don't find anything, use the input's aria-label 3520 * 3521 * The returned string will be shortened if the label text is long or if it starts with "Select " (we'll remove the "Select " prefix) 3522 * 3523 * @param {Element} elem : The input or equivalent element for which we want to get the UI visible label text 3524 * 3525 */ 3526 function getLabelTextForElement(elem) { 3527 if (isEmpty(elem)) { 3528 console.error('elem is empty'); 3529 return ''; 3530 } 3531 3532 var labelText = ''; 3533 if (!isEmpty(elem.id)) { 3534 var labelElem = getLabelForElement(elem.id); 3535 if (!isEmpty(labelElem)) { 3536 labelText = extractVisibleLabelFromLabel(labelElem); 3537 } 3538 } 3539 3540 if (isEmpty(labelText)) { 3541 labelText = extractVisibleLabelFromAriaLabel(elem); 3542 } 3543 3544 return labelText; 3545 } 3546 3547 /* -------------------------------------------------------------------------------------------------- 3548 * Get the UI label text for an element (typically an input element) 3549 * - First check if the element has an associated label (and if so, the text from the <label>) 3550 * - If we don't find anything, use the input's aria-label 3551 * - If the label text is prefixed with an asterix (a required input) we'll remove this as well 3552 * 3553 * The returned string will be shortened if the label text is long or if it starts with "Select " (we'll remove the "Select " prefix) 3554 * 3555 * @param {Element} elem : The input or equivalent element for which we want to get the UI visible label text 3556 * 3557 */ 3558 function getLabelTextForElementWithoutAsterix(elem) { 3559 var labelText = getLabelTextForElement(elem); 3560 if (isEmpty(labelText)) { 3561 labelText = ''; 3562 } else { 3563 if (labelText.startsWith('*')) { 3564 labelText = labelText.substring(1).trim(); 3565 } 3566 } 3567 return labelText; 3568 } 3569 3570 /* -------------------------------------------------------------------------------------------------- 3571 * ADD aria-label attribute to SELECT options that include the up-arrow or down-arrow representing ordering (ascending or descending) 3572 * - For list pages, if the filter bar includes an "Ordering" dropdown, we want to improve the screen reader content so it specifically indicates if the order is ascending or descending 3573 * 3574 */ 3575 function addAriaLabelsToOrderingSelectOptions() { 3576 setTimeout(function () { 3577 var selectOptions = $('select#select_order option'); 3578 if (selectOptions && selectOptions.length) { 3579 for (var index=0; index<selectOptions.length; index++) { 3580 var option = selectOptions.get(index); 3581 var optionText = option.textContent.trim(); 3582 var ariaLabel = null; 3583 if (optionText.endsWith('â²')) { 3584 ariaLabel = optionText.substring(0, optionText.length - 1).trim() + ', Ascending A to Z'; 3585 } else if (optionText.endsWith('â¼')) { 3586 ariaLabel = optionText.substring(0, optionText.length - 1).trim() + ', Descending Z to A'; 3587 } 3588 if (ariaLabel !== null) { 3589 option.setAttribute('aria-label', ariaLabel); 3590 } 3591 } 3592 } 3593 }, 100); 3594 } 3595 3596 /* -------------------------------------------------------------------------------------------------- 3597 * Function that updates screen reader context for expandable sidebar menus that automatically 3598 * load one of the sub-menu pages when expanded. Changing page context when expanding a menu 3599 * is unexpected behavior for keyboard users and so we'll add additional context notifying 3600 * users that the page context will change when they expand the menu. 3601 * 3602 * Not all expandable menus change page context, we're only targeting the pages that auto-load 3603 * a sub-menu page (all call a transition nav or save recently function in the link onclick). 3604 * 3605 */ 3606 function addAriaLabelToExpandableSidebarLinks() { 3607 setTimeout(function () { 3608 try { 3609 // The Sidebar expandable menus that load a sub-menu page when expanded all have an onclick defined 3610 // we're not interested in expandable menus without onclick as they don't automatically load a sub-menu 3611 let onclickCategoryTags = []; 3612 onclickCategoryTags.push('transitionAdminNav('); // Topbar > Admin 3613 onclickCategoryTags.push('saveAdminRecently('); // Topbar > Admin 3614 onclickCategoryTags.push('transitionAccountNav('); // Topbar > Account 3615 onclickCategoryTags.push('transitionAppNav('); // Topbar > Home 3616 onclickCategoryTags.push('transitionGroupsNav('); // Home > Feed 3617 onclickCategoryTags.push('transitionManageNav('); // Manage Group 3618 3619 let expandableSidebarLinks = $('div#sidebar-menu ul#side-menu > li > a[aria-expanded]'); 3620 for (var i=0; i<expandableSidebarLinks.length; i++) { 3621 let expandableSidebarLink = expandableSidebarLinks[i]; 3622 let onclick = $(expandableSidebarLink).attr('onclick'); 3623 if (onclick) { 3624 let onclickTag = null; 3625 for (var oct=0; oct<onclickCategoryTags.length; oct++) { 3626 let onclickCategoryTag = onclickCategoryTags[oct]; 3627 if (onclick.includes(onclickCategoryTag)) { 3628 let sx = onclick.indexOf(onclickCategoryTag); 3629 let ex = onclick.indexOf(',', sx); 3630 if (ex > sx) { 3631 if (onclickCategoryTag === 'saveAdminRecently(') { 3632 sx = ex + 1; 3633 ex = onclick.indexOf(',', sx); 3634 if (ex > sx) { 3635 onclickTag = onclick.substring(sx, ex).trim(); 3636 } 3637 } else { 3638 onclickTag = onclick.substring(sx, ex).trim(); 3639 if (onclickCategoryTag === 'transitionAdminNav(') { 3640 onclickTag = onclickTag.replace('transitionAdminNav(', ''); 3641 } 3642 } 3643 } 3644 } 3645 } 3646 if (onclickTag) { 3647 let j = 0; 3648 let defaultOpenSubMenuName = null; 3649 let subMenuLink = null; 3650 let subMenuLinks = $(expandableSidebarLink).parent().find('ul > li > a'); 3651 while (j<subMenuLinks.length && defaultOpenSubMenuName === null) { 3652 subMenuLink = subMenuLinks[j]; 3653 subMenuLinkOnclick = $(subMenuLink).attr('onclick'); 3654 if (subMenuLinkOnclick && subMenuLinkOnclick.includes(onclickTag)) { 3655 defaultOpenSubMenuName = $(subMenuLink).attr('aria-label'); 3656 if (defaultOpenSubMenuName.indexOf('.') > 0) { 3657 defaultOpenSubMenuName = defaultOpenSubMenuName.substring(0, defaultOpenSubMenuName.indexOf('.')).trim(); 3658 } 3659 } 3660 j++; 3661 } 3662 defaultOpenSubMenuName = (defaultOpenSubMenuName) ? defaultOpenSubMenuName : 'default sub-menu'; 3663 if ($(expandableSidebarLink).attr('acc-sidebar-config') !== 'true') { 3664 let expandableSidebarLinkAriaDescription = $(expandableSidebarLink).attr('aria-description'); 3665 if (isEmpty(expandableSidebarLinkAriaDescription)) { 3666 expandableSidebarLinkAriaDescription = ''; 3667 } 3668 expandableSidebarLinkAriaDescription = addAriaLabelPunctuation(expandableSidebarLinkAriaDescription, '.'); 3669 expandableSidebarLinkAriaDescription = expandableSidebarLinkAriaDescription.trim(); 3670 expandableSidebarLinkAriaDescription += ' Automatically loads the ' + defaultOpenSubMenuName + ' page when expanded.'; 3671 $(expandableSidebarLink).attr('aria-description', expandableSidebarLinkAriaDescription.trim()); 3672 $(expandableSidebarLink).attr('acc-sidebar-config', 'true'); 3673 } 3674 } 3675 } 3676 } 3677 } catch (e) { 3678 console.error(e); 3679 } 3680 }, 667); // wait for the sidebar to render
3681 } 3682 3683/*-----------------------------------------------------*\ 3684 * @SELECTIZE 3685 * 3686 * Setup and manage keyboard navigation for SELECT inputs 3687 * (multi-select checkbox list item dropdowns) 3688 * 3689\*-----------------------------------------------------*/ 3690 3691 /* -------------------------------------------------------------------------------------------------- 3692 * SELECTIZE management for dropdown selects that "Start typing and wait for suggestions" such as user selects with real-time feedback on partial text 3693 * - help with aria-label on the base input when typing and navigating result list 3694 * - for example, after typing 3+ characters, if the result list has results we update the aria-label to read the length of the result list 3695 * - this is just the setup to manage the dropdown/select 3696 * - set keyup event on up/down arrows so the input's aria-label is updated to read the current selection as we navigate up/down the list 3697 * 3698 * @param {string} wrapperId : The container in which the <input> associated with the select dropdown resides 3699 * @param {string} selectizeInputAriaLabel : The aria-label associated with the select (to be assigned to the UI select) 3700 * @param {boolean} isSelectizeInputRequired : True if this is a required input (defaults to false if empty) 3701 * 3702 */ 3703 var selectizeResultCount = {}; // count of the actual number of list items added (html) to the result list during build; incremented during loop/build 3704 var selectizeResultLength = {}; // the length of the result list used to build the (html) results; set once when we receive the results 3705 function setupSelectizeInput(wrapperId, selectizeInputAriaLabel, isSelectizeInputRequired) { 3706 if (isEmpty(wrapperId)) { 3707 console.error('wrapperId is empty'); 3708 return; 3709 } 3710 if (isEmpty(selectizeInputAriaLabel)) { 3711 selectizeInputAriaLabel = ''; 3712 } 3713 if (isEmpty(isSelectizeInputRequired)) { 3714 isSelectizeInputRequired = false; 3715 } 3716 3717 var selectizeInputId = '#' + wrapperId + ' input:not([type="radio"])'; 3718 var selectizeInput = $(selectizeInputId); 3719 if (isEmpty(selectizeInput)) { 3720 console.error('selectizeInput is empty'); 3721 return; 3722 } 3723 3724 var dataOriginalAriaLabel = addAriaLabelPunctuation(selectizeInputAriaLabel); 3725 if (isSelectizeInputRequired === true) { 3726 dataOriginalAriaLabel += ' Required.'; 3727 } 3728 dataOriginalAriaLabel = getSafeAriaLabelTextTrim(dataOriginalAriaLabel); 3729 3730 selectizeInputAriaLabel = dataOriginalAriaLabel; 3731 selectizeInputAriaLabel += ' Start typing and wait for suggestions.'; 3732 3733 selectizeInput.attr('data-original-aria-label', dataOriginalAriaLabel); 3734 selectizeInput.attr('aria-label', selectizeInputAriaLabel); 3735 selectizeInput.attr('tabindex', '0'); 3736 3737 selectizeInput.keyup(function (event) { 3738 if (event.which == 38 || event.which == 40) { 3739 // up/down arrows 3740 setAriaLabelForSelectize(wrapperId); 3741 } 3742 }); 3743 } 3744 3745 /* -------------------------------------------------------------------------------------------------- 3746 * Clear the result count and length for the specified select input. 3747 * 3748 * @param {string} wrapperId : The container in which the <input> associated with the select dropdown resides 3749 * 3750 */ 3751 function clearSelectizeResults(wrapperId) { 3752 // @REMOVE-FROM here 3753 if (isEmpty(wrapperId)) { 3754 console.error('wrapperId is empty'); 3755 return; 3756 } 3757 // @REMOVE-TO here 3758 // console.log('> clearSelectizeResults() wrapperId=' + wrapperId); 3759 if (!isEmpty(selectizeResultCount[wrapperId])) { 3760 delete selectizeResultCount[wrapperId]; 3761 } 3762 if (!isEmpty(selectizeResultLength[wrapperId])) { 3763 delete selectizeResultLength[wrapperId]; 3764 } 3765 } 3766 3767 /* -------------------------------------------------------------------------------------------------- 3768 * Returns the length of the result list for the given select input. 3769 * 3770 * @param {string} wrapperId : The container in which the <input> associated with the select dropdown resides 3771 * 3772 */ 3773 function getSelectizeResultLength(wrapperId) { 3774 // @REMOVE-FROM here
3775 if (isEmpty(wrapperId)) { 3776 console.error('wrapperId is empty'); 3777 return; 3778 } 3779 // @REMOVE-TO here 3780 if (isEmpty(selectizeResultLength[wrapperId])) { 3781 return 0; 3782 } 3783 // console.log('> getSelectizeResultLength() wrapperId=' + wrapperId + ', selectizeResultLength[wrapperId]=' + selectizeResultLength[wrapperId]); 3784 return selectizeResultLength[wrapperId]; 3785 } 3786 3787 /* -------------------------------------------------------------------------------------------------- 3788 * Sets the length of the result list for the given select input (based on result from ajax call). 3789 * 3790 * @param {string} wrapperId : The container in which the <input> associated with the select dropdown resides 3791 * 3792 */ 3793 function setSelectizeResultLength(wrapperId, count) { 3794 // @REMOVE-FROM here 3795 if (isEmpty(wrapperId)) { 3796 console.error('wrapperId is empty'); 3797 return; 3798 } 3799 if (isEmpty(count)) { 3800 console.error('count is empty'); 3801 return; 3802 } 3803 // @REMOVE-TO here 3804 selectizeResultLength[wrapperId] = count; 3805 // console.log('> setSelectizeResultLength() wrapperId=' + wrapperId + ', count=' + count); 3806 } 3807 3808 /* -------------------------------------------------------------------------------------------------- 3809 * Returns the number of list items currently in the visible UI select dropdown associated with the input/select. 3810 * - This is different from the ajax request result length as ajax results appear to be added to the local list items, but previous query list items also still exist (and are often active/visible) 3811 * 3812 * @param {string} wrapperId : The container in which the <input> associated with the select dropdown resides 3813 * 3814 */ 3815 function getSelectizeResultCount(wrapperId) { 3816 var listItemsSelector = '#' + wrapperId + ' div.selectize-dropdown-content div'; 3817 var listItems = $(listItemsSelector); 3818 return (listItems === undefined || listItems === null) ? 0 : listItems.length / 2; 3819 } 3820 3821 /* -------------------------------------------------------------------------------------------------- 3822 * Increment the count of the number of list items added to the select dropdown referenced by wrapperId. 3823 * 3824 * @param {string} wrapperId : The container in which the <input> associated with the select dropdown resides 3825 * 3826 */ 3827 function incrementAndGetSelectizeResultCount(wrapperId) { 3828 // @REMOVE-FROM here 3829 if (isEmpty(wrapperId)) { 3830 console.error('wrapperId is empty'); 3831 return; 3832 } 3833 // @REMOVE-TO here 3834 if (isEmpty(selectizeResultCount[wrapperId])) { 3835 selectizeResultCount[wrapperId] = 0; 3836 } 3837 selectizeResultCount[wrapperId] = selectizeResultCount[wrapperId] + 1; 3838 return selectizeResultCount[wrapperId]; 3839 } 3840 3841 /* -------------------------------------------------------------------------------------------------- 3842 * For multi-select inputs with checkbox list items, set the link's role (to checkbox) and if the checkbox is active, set the 'checked' attribute as well (or clear otherwise) 3843 * 3844 * @param {string} selectId : ID of the select dropdown 3845 * 3846 */ 3847 function setOptionLinkRolesForSelectMultipleNewWithClass(selectId) { 3848 3849 if (selectId.startsWith('#')) { 3850 selectId = selectId.substring(1); 3851 } 3852 3853 var optionLinks = $('#' + selectId).next().find('ul li a'); 3854 for (var i=0; i<optionLinks.length; i++) { 3855 var optionLink = $(optionLinks.get(i)); 3856 $(optionLink).attr('role', 'checkbox'); 3857 3858 if ($(optionLink).parent().hasClass('active')) { 3859 $(optionLink).attr('checked', ''); 3860 $(optionLink).find('div input').attr('checked', ''); 3861 } else { 3862 $(optionLink).removeAttr('checked'); 3863 $(optionLink).find('div input').removeAttr('checked'); 3864 } 3865 } 3866 } 3867 3868 /* -------------------------------------------------------------------------------------------------- 3869 * Keyboard input manager for dropdown/select inputs (BuildSelect using checkbox options inside select) 3870 * - Determine the character associated with keyup events (regardless of laptop/desktop keyboard or Windows/Mac OS) 3871 * - Used in the "skip to nearest item" functionality for dropdown selects allowing "type to find the nearest select item" 3872 * 3873 * @param {number} which : KeyboardEvent.which value (from associated keyup event) 3874 * @param {boolean} isShiftKey : True if the shift key was pressed 3875 * 3876 */ 3877 function getCharFromKeyup(which, isShiftKey) { 3878 // @REMOVE-FROM here
3879 if (isEmpty(which)) { 3880 console.error('which is empty'); 3881 return; 3882 } else if (!isNumber(which)) { 3883 console.error('which is not a number'); 3884 return; 3885 } 3886 3887 if (isEmpty(isShiftKey)) { 3888 console.error('isShiftKey is empty'); 3889 return; 3890 } else if (!isBoolean(isShiftKey)) { 3891 console.error('isShiftKey is not a boolean'); 3892 return; 3893 } 3894 // @REMOVE-TO here 3895 3896 var block186LowerCase = ';=,-./`'; 3897 var block186UpperCase = ':+<_>?~'; 3898 var block219LowerCase = "[\\]'"; 3899 var block219UpperCase = '{|}"'; 3900 var lettersLowerCase = 'abcdefghijklmnopqrstuvwxyz'; 3901 var lettersUpperCase = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; 3902 var numbersLowerCase = '0123456789'; 3903 var numbersUpperCase = ')!@#$%^&*('; 3904 var numpadChars = '*+ -./' 3905 3906 console.log('> getCharFromKeyup() isShiftKey=' + isShiftKey + ', which=' + which); 3907 3908 var charValue = null; 3909 if (isShiftKey) { 3910 if (which >= 48 && which <= 57) { 3911 charValue = numbersUpperCase[which - 48]; 3912 } else if (which >= 65 && which <= 90) { 3913 charValue = lettersUpperCase[which - 65]; 3914 } else if (which >= 96 && which <= 105) { 3915 charValue = numbersLowerCase[which - 96]; 3916 } else if (which >= 106 && which <= 111) { 3917 charValue = numpadChars[which - 106]; 3918 } else if (which >= 186 && which <= 192) { 3919 charValue = block186UpperCase[which - 186]; 3920 } else if (which >= 219 && which <= 222) { 3921 charValue = block219UpperCase[which - 219]; 3922 } 3923 } else { 3924 if (which >= 48 && which <= 57) { 3925 charValue = numbersLowerCase[which - 48]; 3926 } else if (which >= 65 && which <= 90) { 3927 charValue = lettersLowerCase[which - 65]; 3928 } else if (which >= 96 && which <= 105) { 3929 charValue = numbersLowerCase[which - 96]; 3930 } else if (which >= 106 && which <= 111) { 3931 charValue = numpadChars[which - 106]; 3932 } else if (which >= 186 && which <= 192) { 3933 charValue = block186LowerCase[which - 186]; 3934 } else if (which >= 219 && which <= 222) { 3935 charValue = block219LowerCase[which - 219]; 3936 } 3937 } 3938 3939 console.log('> charValue=' + charValue); 3940 3941 if (isEmpty(charValue)) { 3942 charValue = null; 3943 } 3944 3945 return charValue; 3946 } 3947 3948 /* -------------------------------------------------------------------------------------------------- 3949 * Returns a number indicating the sort order of the select dropdown list. 3950 * - For dropdown multi-selects that support "type to skip" to the nearest list item 3951 * 3952 * -1 = Descending (Z-A) 3953 * 0 = Not sorted or unordered 3954 * +1 = Ascending (A-Z) 3955 * 3956 */ 3957 function getDropdownSortOrder(selectId) { 3958 if (selectId.startsWith('#')) { 3959 selectId = selectId.substring(1); 3960 } 3961 3962 var optionLabels = $('#' + selectId).next().find('ul li a div label'); 3963 var prevTitle = null; 3964 var isAscending = true; 3965 var isDescending = true; 3966 for (var i=0; i<optionLabels.length; i++) { 3967 var optionLabel = $(optionLabels.get(i)); 3968 var title = $(optionLabel).attr('title').trim().toLowerCase(); 3969 if (prevTitle === null) { 3970 prevTitle = title; 3971 } else { 3972 if (title.localeCompare(prevTitle) >= 0) { 3973 isDescending = false; 3974 } else if (title.localeCompare(prevTitle) <= 0) { 3975 isAscending = false; 3976 } 3977 } 3978 } 3979 3980 if (isAscending) { 3981 return +1; 3982 } else if (isDescending) { 3983 return -1; 3984 } 3985 3986 return 0; 3987 } 3988 3989 /* -------------------------------------------------------------------------------------------------- 3990 * Set keyup event handler on select facilitating type-to-skip to the nearest record based on what the user types 3991 *
3992 * - Start typing, focus will skip to the nearest list item matching what you've typed 3993 * - Press Backspace/Delete to clear the typing buffer and start over/try again 3994 * - Press ESCAPE to collapse the dropdown (close the select) 3995 * - Press ENTER or SPACE to check a list item (check/uncheck depending on state) 3996 * 3997 * @param {string} selectId : ID of the associated select dropdown 3998 * @param {number} optionCount : The number of items in the select dropdown list. 3999 * 4000 * NOTE optionCount is no longer used (and useApplicationMode is set to true by default). This should be cleaned up in next round of refactoring. 4001 * 4002 */ 4003 function manageKeyboardForSelectMultipleNewWithClass(selectId, optionCount) { 4004 4005 if (selectId.startsWith('#')) { 4006 selectId = selectId.substring(1); 4007 } 4008 4009 if (isEmpty(optionCount)) { 4010 optionCount = 0; 4011 } 4012 4013 // @INVESTIGATE rework the code below (remove useApplicationMode boolean; it's always true) 4014 var useApplicationMode = false; 4015 if (optionCount >= 0) { 4016 // OCT-15 use application mode perpetually for JAWS, NVDA and VoicdOver support 4017 useApplicationMode = true; 4018 } 4019 4020 var chars = ''; 4021 var sortOrder = 0; // -1 descending : +1 ascending : 0 not sorted 4022 4023 setOptionLinkRolesForSelectMultipleNewWithClass(selectId); 4024 4025 if (useApplicationMode) { 4026 // flag this area as an application to prevent NVDA/etc. from handling keyboard input 4027 $('#' + selectId).parent().attr('role', 'application'); 4028 4029 setTimeout(function () { 4030 sortOrder = getDropdownSortOrder(selectId); 4031 // console.log('> sortOrder=' + sortOrder); 4032 }, 100); 4033 } 4034 4035 var selectButton = $('#' + selectId).next().find('button'); 4036 if (!isEmpty(selectButton) && selectButton.length === 1) { 4037 var originalAriaLabel = $(selectButton).attr('aria-label'); 4038 $(selectButton).attr('data-original-aria-label', originalAriaLabel); 4039 4040 if (useApplicationMode) { 4041 var newAriaLabel = addAriaLabelPunctuation(originalAriaLabel).trim() + ' Enter or arrow down to expand the list. Start typing to navigate to the nearest option. Backspace or delete to empty typing buffer.' 4042 $(selectButton).attr('aria-label', newAriaLabel); 4043 } 4044 } 4045 4046 $('#' + selectId).next().keyup(function (event) { 4047 console.log('> SELECT keyup selectId=' + selectId + ', sortOrder=' + sortOrder); 4048 4049 if (isAccessibilityJSVerbose) { 4050 console.log('keyup'); 4051 } 4052 if (event.which === 27) { 4053 // escape key 4054 if (isAccessibilityJSVerbose) { 4055 console.log('> keyup event.which=' + event.which + ' *** ESC escape key pressed!'); 4056 } 4057 $('#' + selectId).next().removeClass('open'); 4058 selectButton.trigger("focus") 4059 } 4060 4061 if (useApplicationMode) { 4062 if (event.which === 8 || event.which === 46) { 4063 // backspace and delete 4064 if (isAccessibilityJSVerbose) { 4065 console.log('> keyup event.which=' + event.which); 4066 } 4067 chars = ''; 4068 } else if (event.which === 13 || event.which === 32) { 4069 // enter or space bar 4070 var activeElem = document.activeElement;
4071 setActiveOptionAriaLabelForSelectMultipleNewWithClass(activeElem); 4072 } else { 4073 // all other keys 4074 if (isAccessibilityJSVerbose) { 4075 console.log('> keyup event.which=' + event.which); 4076 } 4077 4078 var char = getCharFromKeyup(event.which, event.shiftKey); 4079 if (!isEmpty(char)) { 4080 char = char.toLowerCase(); 4081 console.log('> char=' + char); 4082 chars += char; 4083 console.log('> chars=' + chars); 4084 4085 var hasFocused = false; 4086 var optionLabels = $('#' + selectId).next().find('ul li a div label'); 4087 console.log('> optionLabels.length=' + optionLabels.length); 4088 for (var i=0; i<optionLabels.length; i++) { 4089 var optionLabel = $(optionLabels.get(i)); 4090 var title = $(optionLabel).attr('title'); 4091 if (!hasFocused && !isEmpty(title)) { 4092 title = title.trim().toLowerCase(); 4093 console.log('> title=' + title); 4094 if (!isEmpty(chars)) { 4095 var takeFocus = false; 4096 if (title.startsWith(chars)) { 4097 console.log('> *** FOCUS{a} on=' + title); 4098 takeFocus = true; 4099 } else { 4100 if (sortOrder > 0 && title.localeCompare(chars) > 0) { 4101 console.log('> *** FOCUS{ascending} on=' + title); 4102 takeFocus = true; 4103 } else if (sortOrder < 0 && title.localeCompare(chars) < 0) { 4104 console.log('> *** FOCUS{descending} on' + title); 4105 takeFocus = true; 4106 } 4107 } 4108 } 4109 4110 if (takeFocus) { 4111 hasFocused = true; 4112 var option = $(optionLabel).parent().parent();
4113 setActiveOptionAriaLabelForSelectMultipleNewWithClass(option); 4114 option.trigger("focus"); 4115 break; 4116 } 4117 } 4118 } 4119 } 4120 } 4121 } 4122 }); 4123 } 4124 4125 /* -------------------------------------------------------------------------------------------------- 4126 * Set the aria-label on the input associated with a dropdown "Type to search" select 4127 * - As the user types, the results may change (add new results, reduce the result set) 4128 * - Using up/down arrow, as the user navigates the results we update the input's aria-label to match the currently selected list item 4129 * 4130 * @param {string} wrapperId : The container in which the <input> associated with the select dropdown resides 4131 * @elem {string} inputLabelText : The visible UI label text for the input/select. 4132 * @elem {boolean} includeResultCount : If true, we'll include "7 Results." or equivalent ("N Results." depending on the number of items in the select dropdown) 4133 * 4134 */ 4135 function setAriaLabelForSelectize(wrapperId, inputLabelText, includeResultCount) { 4136 if (isEmpty(wrapperId)) { 4137 console.error('wrapperId is empty'); 4138 return; 4139 } 4140 if (isEmpty(inputLabelText)) { 4141 inputLabelText = ''; 4142 } 4143 if (isEmpty(includeResultCount)) { 4144 includeResultCount = false; 4145 } 4146 var activeItemSelector = '#' + wrapperId + ' div.selectize-dropdown-content div.active'; 4147 var activeItem = $(activeItemSelector); 4148 if (activeItem !== undefined && activeItem !== null && activeItem.length == 1) { 4149 var listItemAriaLabel = activeItem.attr('aria-label'); 4150 if (!isEmpty(listItemAriaLabel)) { 4151 var originalInputAriaLabel = $('#' + wrapperId + ' input:not([type="radio"])').attr('data-original-aria-label'); 4152 if (isEmpty(originalInputAriaLabel)) { 4153 console.error('> NOTE setAriaLabelForSelectize() originalInputAriaLabel is empty'); 4154 originalInputAriaLabel = inputLabelText; 4155 } 4156 var inputAriaLabel = ''; 4157 if (includeResultCount === true) { 4158 inputAriaLabel = inputAriaLabel + getSelectizeResultCount(wrapperId) + ' Results. '; 4159 } 4160 inputAriaLabel = inputAriaLabel + ' ' + listItemAriaLabel + ' '; 4161 if (!isEmpty(originalInputAriaLabel)) { 4162 inputAriaLabel = inputAriaLabel + addAriaLabelPunctuation(originalInputAriaLabel); 4163 } 4164 inputAriaLabel = inputAriaLabel + ' Use up and down arrows to navigate list results.'; 4165 inputAriaLabel = getSafeAriaLabelTextTrim(inputAriaLabel); 4166 $('#' + wrapperId + ' input:not([type="radio"])').attr('aria-label', inputAriaLabel); 4167 } 4168 } 4169 } 4170 4171 /* -------------------------------------------------------------------------------------------------- 4172 * Set the aria-label on the input associated with a dropdown "Type to search" select 4173 * - Depending on the potential length of the result set (or when querys are known to take more time than usual) 4174 * - we will wait the specified number of millis before calling setAriaLabelForSelectize() 4175 * 4176 * @param {number} waitMillis : Number of milliseconds to wait before updating the aria-label on the <input> associated with the select 4177 * @param {string} wrapperId : The container in which the <input> associated with the select dropdown resides 4178 * @elem {string} inputLabelText : The visible UI label text for the input/select. 4179 * @elem {boolean} includeResultCount : If true, we'll include "7 Results." or equivalent ("N Results." depending on the number of items in the select dropdown) 4180 4181 */ 4182 function setAriaLabelForSelectizeDelayed(waitMillis, wrapperId, inputLabelText, includeResultCount) { 4183 // @REMOVE-FROM here 4184 if (isEmpty(waitMillis)) { 4185 console.error('waitMillis is empty'); 4186 return; 4187 } 4188 // @REMOVE-TO here 4189 setTimeout(function () { 4190 setAriaLabelForSelectize(wrapperId, inputLabelText, includeResultCount); 4191 }, waitMillis); 4192 } 4193 4194 /* -------------------------------------------------------------------------------------------------- 4195 * Build and return an aria-label for list-item results representing students. 4196 * 4197 * @param {string} wrapperId : The container in which the <input> associated with the select dropdown resides 4198 * @param {string} full_name : Student's full name 4199 * @param {string} email : Student's email address (may be empty) 4200 * @param {string} student_type : Student type (may be empty) 4201 * @param {string} student_yog : Student year of graduation (may be empty) 4202 * 4203 */ 4204 function getAriaLabelForSelectizeStudents(wrapperId, full_name, email, student_type, student_yog) { 4205 // @REMOVE-FROM here 4206 console.log('> getAriaLabelForSelectizeStudents() begins...'); 4207
4208 if (isEmpty(wrapperId)) { 4209 console.error('wrapperId is empty'); 4210 return ''; 4211 } 4212 // @REMOVE-TO here 4213 4214 if (isEmpty(full_name)) { 4215 console.error('full_name is empty'); 4216 return ''; 4217 } 4218 4219 var resultCount = incrementAndGetSelectizeResultCount(wrapperId); 4220 var resultsLength = getSelectizeResultLength(wrapperId); 4221 if (resultsLength < 1) { 4222 console.log('> NOTE getAriaLabelForSelectizeStudents() resultsLength < 1'); 4223 } 4224 4225 var listItemAriaLabel = ''; 4226 // listItemAriaLabel = listItemAriaLabel + 'Option ' + resultCount + ' of ' + resultsLength + '. '; 4227 4228 listItemAriaLabel += getSafeAriaLabelTextTrim(full_name); 4229 if (!isEmpty(email)) { 4230 listItemAriaLabel += ', email ' + getSafeAriaLabelTextTrim(email); 4231 } 4232 var studentDetails = ''; 4233 if (!isEmpty(student_type)) { 4234 studentDetails += getSafeAriaLabelTextTrim(student_type) + ' '; 4235 } 4236 if (!isEmpty(student_yog)) { 4237 studentDetails += 'graduating ' + getSafeAriaLabelTextTrim(student_yog); 4238 } 4239 if (!isEmpty(studentDetails)) { 4240 listItemAriaLabel += ', ' + getSafeAriaLabelTextTrim(studentDetails); 4241 } 4242 listItemAriaLabel = addAriaLabelPunctuation(listItemAriaLabel); 4243 4244 return getSafeAriaLabelTextTrim(listItemAriaLabel); 4245 } 4246 4247 /* -------------------------------------------------------------------------------------------------- 4248 * Set the aria-label for the active list option (including checked/unchecked state). 4249 * - Can be improved by moving code closer to the ajax results and/or onClick function that handles check/uncheck for the list items 4250 * - Discuss with Adrien 4251 * 4252 * @param (Element) aOption : The <a> link of the active result list item 4253 * 4254 */ 4255 function setActiveOptionAriaLabelForSelectMultipleNewWithClass(aOption) { 4256 var aOptionParent = $(aOption).parent(); 4257 if ($(aOptionParent).is('li')) { 4258 var isChecked = false; 4259 if (aOptionParent.hasClass('active')) { 4260 isChecked = true; 4261 if (isAccessibilityJSVerbose) { 4262 console.log('> aOptionParent is ACTIVE'); 4263 } 4264 } 4265 var aOptionLabel = $(aOption).find('div label'); 4266 if (!isEmpty(aOptionLabel) && aOptionLabel.length === 1) { 4267 var labelText = aOptionLabel.text(); 4268 var labelAriaLabel = addAriaLabelPunctuation(labelText); 4269 if (isChecked) { 4270 labelAriaLabel += ' Checked.' 4271 } 4272 labelAriaLabel = getSafeAriaLabelTextTrim(labelAriaLabel); 4273 if (isAccessibilityJSVerbose) { 4274 console.log('> aOptionentLabel=' + labelText); 4275 console.log('> labelAriaLabel=' + labelAriaLabel); 4276 } 4277 $(aOptionLabel).attr('aria-label', labelAriaLabel); 4278 $(aOption).attr('aria-label', labelAriaLabel); 4279 if (isChecked) { 4280 $(aOption).attr('checked', ''); 4281 } else { 4282 $(aOption).removeAttr('checked'); 4283 } 4284 } 4285 var aOptionInput = $(aOption).find('div input'); 4286 if (!isEmpty(aOptionInput) && aOptionInput.length === 1) { 4287 if (isChecked) { 4288 $(aOptionInput).attr('checked', ''); 4289 } else { 4290 $(aOptionInput).removeAttr('checked'); 4291 } 4292 } 4293 } 4294 } 4295 4296/*-----------------------------------------------------*\ 4297 * @SLIDESHOW-CAROUSEL 4298 * 4299 * Setup and manage keyboard navigation for event carousels 4300 * (event carousel implemented using slick slideshow) 4301 * 4302\*-----------------------------------------------------*/ 4303 4304 /* Function that reduces the slick slideshow slidesToScroll value to 1 when in acc-keyboard mode 4305 * (called automatically when acc-keyboard-mode is enabled; otherwise no action). 4306 */ 4307 function setupSlickAccKeyboardMode() { 4308 let slideshows = $('.slick-initialized'); 4309 for (var i=0; i<slideshows.length; i++) { 4310 if ($(slideshows[i]).attr('data-cg-slick') !== 'true') { 4311 $(slideshows[i]).attr('data-cg-slick', 'true'); 4312 $(slideshows[i]).slick('slickSetOption', 'slidesToScroll', 1); 4313 } 4314 } 4315 } 4316 4317 /* -------------------------------------------------------------------------------------------------- 4318 * Setup keyboard navigation management on the website slideshow. 4319 * - Add keyup, focus and blur events to the slideshow controls 4320 * 4321 * @param {string} divWrapperId : ID of the slideshow container div 4322 * @param {string} h2SlideshowPreviousId : ID of the slideshow previous button 4323 * @param {string} h2SlideshowNextId : ID of the slideshow next button 4324 * @param {string} spanSlideshowPreviousId : ID of the span inside the slideshow previous button 4325 * @param {string} spanSlideshowNextId : ID of the span inside the slideshow next button 4326 * 4327 * Suspect we can improve by removing parameters [spanSlideshowPreviousId,spanSlideshowNextId] 4328 * 4329 */ 4330 function setupKeyboardNavigationForEventCarouselSlideshow(divWrapperId, h2SlideshowPreviousId, h2SlideshowNextId, spanSlideshowPreviousId, spanSlideshowNextId) { 4331 4332 if (!divWrapperId.startsWith('#')) { divWrapperId = '#' + divWrapperId; } 4333 if (!h2SlideshowPreviousId.startsWith('#')) { h2SlideshowPreviousId = '#' + h2SlideshowPreviousId; } 4334 if (!spanSlideshowPreviousId.startsWith('#')) { spanSlideshowPreviousId = '#' + spanSlideshowPreviousId; } 4335 if (!h2SlideshowNextId.startsWith('#')) { h2SlideshowNextId = '#' + h2SlideshowNextId; } 4336 if (!spanSlideshowNextId.startsWith('#')) { spanSlideshowNextId = '#' + spanSlideshowNextId; } 4337 4338 let wrapperElem = $(divWrapperId)[0]; 4339 if (!isEmpty($(wrapperElem).attr('data-cg-slider-init'))) { 4340 return; // already setup 4341 } 4342 $(wrapperElem).attr('data-cg-slider-init', 'true'); 4343 4344 var intervalId = setInterval(function () { 4345 4346 let h2Prev = $(h2SlideshowPreviousId)[0]; 4347 let h2Next = $(h2SlideshowNextId)[0]; 4348 let spanPrev = $(spanSlideshowPreviousId)[0]; 4349 let spanNext = $(spanSlideshowNextId)[0]; 4350 4351 if (isEmpty(h2Prev) || isEmpty(h2Next)) { 4352 clearInterval(intervalId); 4353 } else { 4354 if (isEmpty($(h2Prev).attr('data-cg-slickId'))) { 4355 $(h2Prev).attr('data-cg-slickId', h2SlideshowPreviousId); 4356 $(h2Next).attr('data-cg-slickId', h2SlideshowNextId); 4357 $(spanPrev).attr('data-cg-slickId', spanSlideshowPreviousId); 4358 $(spanNext).attr('data-cg-slickId', spanSlideshowNextId); 4359 4360 $(h2SlideshowPreviousId).on('click', function () { 4361 setFocusOnEventsCarouselPreviousSlideButtonClick(divWrapperId, h2SlideshowPreviousId); 4362 }); 4363 4364 $(h2SlideshowNextId).on('click', function () { 4365 setFocusOnEventsCarouselNextSlideButtonClick(divWrapperId, h2SlideshowNextId); 4366 }); 4367 4368 $(h2SlideshowPreviousId).on("focus", function () { 4369 if (isAccKeyboardMode()) { 4370 $(spanSlideshowPreviousId).removeClass('mdi-chevron-left'); 4371 $(spanSlideshowPreviousId).addClass('mdi-chevron-left-box'); 4372 } 4373 }); 4374 4375 $(h2SlideshowNextId).on("focus", function () { 4376 if (isAccKeyboardMode()) { 4377 $(spanSlideshowNextId).removeClass('mdi-chevron-right'); 4378 $(spanSlideshowNextId).addClass('mdi-chevron-right-box'); 4379 } 4380 }); 4381 4382 $(h2SlideshowPreviousId).on("blur", function () { 4383 if (isAccKeyboardMode()) { 4384 $(spanSlideshowPreviousId).removeClass('mdi-chevron-left-box'); 4385 $(spanSlideshowPreviousId).addClass('mdi-chevron-left'); 4386 } 4387 }); 4388 4389 $(h2SlideshowNextId).on("blur", function () { 4390 if (isAccKeyboardMode()) { 4391 $(spanSlideshowNextId).removeClass('mdi-chevron-right-box'); 4392 $(spanSlideshowNextId).addClass('mdi-chevron-right'); 4393 } 4394 }); 4395 4396 } 4397 } 4398 }, 1000); 4399 } 4400 4401 /* -------------------------------------------------------------------------------------------------- 4402 * Handle click/keyboard enter for Events carousel/slideshow PREVIOUS button 4403 * - Set focus to the first visible left card inside the slideshow after prev.click() udpates the visible slides 4404 * - When previous is clicked on the initial slideshow, or when previous is clicked on the first slideshow card, it takes a bit more time for slick to update the display 4405 * 4406 * @param {string} divWrapperId : ID of the slideshow container div 4407 * @param {string} h2SlideshowPreviousId : ID of the slideshow previous button 4408 */ 4409 function previousEventCarouselButtonOnClick(divWrapperId, h2SlideshowPreviousId) { 4410 if (!divWrapperId.startsWith('#')) { divWrapperId = '#' + divWrapperId; } 4411 if (!h2SlideshowPreviousId.startsWith('#')) { h2SlideshowPreviousId = '#' + h2SlideshowPreviousId; } 4412 $(divWrapperId + ' .event_slider').slick('slickPrev'); 4413 } 4414 4415 /* -------------------------------------------------------------------------------------------------- 4416 * Handle click/keyboard enter for Events carousel/slideshow NEXT button 4417 * - Set focus to the first visible right-most card inside the slideshow after prev.click() udpates the visible slides 4418 * 4419 * @param {string} divWrapperId : ID of the slideshow container div 4420 * @param {string} h2SlideshowNextId : ID of the slideshow next button 4421 */ 4422 function nextEventCarouselButtonOnClick(divWrapperId, h2SlideshowNextId) { 4423 if (!divWrapperId.startsWith('#')) { divWrapperId = '#' + divWrapperId; } 4424 if (!h2SlideshowNextId.startsWith('#')) { h2SlideshowNextId = '#' + h2SlideshowNextId; } 4425 $(divWrapperId + ' .event_slider').slick('slickNext'); 4426 } 4427 4428 /* -------------------------------------------------------------------------------------------------- 4429 * Set focus to the left-most visible slide in the event carousel/slidewhow. 4430 * 4431 * @param {string} divWrapperId : ID of the slideshow container div 4432 * @param {string} h2SlideshowPreviousId : ID of the slideshow previous button 4433 * 4434 * NOTE h2SlideshowPreviousId does not appear to be used anymore (can remove in next refactoring) 4435 * 4436 */ 4437 function setFocusOnEventsCarouselPreviousSlideButtonClick(divWrapperId, h2SlideshowPreviousId) { 4438 if (!divWrapperId.startsWith('#')) { divWrapperId = '#' + divWrapperId; } 4439 if (!h2SlideshowPreviousId.startsWith('#')) { h2SlideshowPreviousId = '#' + h2SlideshowPreviousId; } 4440 4441 setTimeout(function () { 4442 var slickActiveSlides = $(divWrapperId + ' div.slick-track div.slick-active'); 4443 if (!isEmpty(slickActiveSlides) && slickActiveSlides.length > 0) { 4444 var focusCard = slickActiveSlides.get(0); 4445 if (isAccessibilityJSVerbose) { 4446 console.log('> focusCard=' + getElementPath(focusCard)); 4447 } 4448 $(focusCard).focus(); 4449 } 4450 }, 100); 4451 } 4452 4453 /* -------------------------------------------------------------------------------------------------- 4454 * Set focus to the right-most visible slide in the event carousel/slidewhow. 4455 * 4456 * @param {string} divWrapperId : ID of the slideshow container div 4457 * @param {string} h2SlideshowNextId : ID of the slideshow next button 4458 * 4459 * NOTE h2SlideshowNextId does not appear to be used anymore (can remove in next refactoring) 4460 * 4461 */ 4462 function setFocusOnEventsCarouselNextSlideButtonClick(divWrapperId, h2SlideshowNextId) { 4463 if (!divWrapperId.startsWith('#')) { divWrapperId = '#' + divWrapperId; } 4464 if (!h2SlideshowNextId.startsWith('#')) { h2SlideshowNextId = '#' + h2SlideshowNextId; } 4465 4466 setTimeout(function () { 4467 var slickActiveSlides = $(divWrapperId + ' div.slick-track div.slick-active'); 4468 if (!isEmpty(slickActiveSlides) && slickActiveSlides.length > 0) { 4469 var focusCard = slickActiveSlides.get(slickActiveSlides.length - 1); 4470 if (isAccessibilityJSVerbose) { 4471 console.log('> focusCard=' + getElementPath(focusCard)); 4472 } 4473 $(focusCard).focus(); 4474 } 4475 }, 100); 4476 } 4477 4478/*-----------------------------------------------------*\ 4479 * @CALENDAR / @DATEPICKER 4480 * 4481 * Setup and manage keyboard navigation for datepicker controls. 4482 * 4483\*-----------------------------------------------------*/ 4484 4485 var currentDateInput = null; // set to the currently active datepicker on focus 4486 var datePickerMadeAccessible = false; // will be set to true when keyboard setup is complete 4487 4488 // daysOfWeek used to determine the day name of an associated datepicker cell based on what column it's in 4489 var daysOfWeek = []; 4490 daysOfWeek[0] = 'Sunday'; 4491 daysOfWeek[1] = 'Monday'; 4492 daysOfWeek[2] = 'Tuesday'; 4493 daysOfWeek[3] = 'Wednesday'; 4494 daysOfWeek[4] = 'Thursday';
4495 daysOfWeek[5] = 'Friday'; 4496 daysOfWeek[6] = 'Saturday'; 4497 4498 /* -------------------------------------------------------------------------------------------------- 4499 * Setup the datepicker keyboard support 4500 * 4501 */ 4502 function accessibilityMagic() { 4503 setTimeout(function() { 4504 // Hide the "today" button because it doesn't do what 4505 // you think it supposed to do 4506 $(".ui-datepicker-current").hide(); 4507 4508 var container = document.getElementById('ui-datepicker-div'); 4509 4510 if (!container) { 4511 console.log("No container"); 4512 return; 4513 } 4514 4515 container.setAttribute('role', 'application'); 4516 container.setAttribute('aria-label', 'Calendar view date-picker. Use arrow keys, page up, page down, home and end keys to change date. Press enter to select or escape to exit.'); 4517 4518 // the top controls: 4519 var prev = $('.ui-datepicker-prev', container)[0], 4520 next = $('.ui-datepicker-next', container)[0]; 4521 4522 4523 // Lock the next/prev buttons from keyboard use - we don't need them (use arrow keys, page up/down, home, end instead); TAB/SHIFT-TAB will simply close the datepicker 4524 // NOTE the tab/shif-tab key will exit the datepicker (setting tabindex to 0 on the next/prev datepicker top corner controls does not fix this) 4525 next.href = 'javascript:;'; 4526 next.setAttribute('tabindex', '-1'); 4527 next.setAttribute('role', 'button'); 4528 next.removeAttribute('title'); 4529 4530 prev.href = 'javascript:;'; 4531 prev.setAttribute('tabindex', '-1'); 4532 prev.setAttribute('role', 'button'); 4533 prev.removeAttribute('title'); 4534 4535 appendOffscreenMonthText(next); 4536 appendOffscreenMonthText(prev); 4537 4538 // delegation won't work here for whatever reason, so we are 4539 // forced to attach individual click listeners to the prev / 4540 // next month buttons each time they are added to the DOM 4541 $(next).on('click', handleNextClicks); 4542 $(prev).on('click', handlePrevClicks); 4543 4544 monthDayYearText(); 4545 4546 datePickHandler(); 4547 4548 $(document).on('click', '#ui-datepicker-div .ui-datepicker-close', function () { 4549 closeCalendar(); 4550 }); 4551 }); 4552 } 4553 4554 /* -------------------------------------------------------------------------------------------------- 4555 * Handle keydown events while inside the datepicker (ESC, arrow up/down/left/right, etc.) 4556 * 4557 */ 4558 function datePickHandler() { 4559 var container = document.getElementById('ui-datepicker-div'); 4560 if (!container) { 4561 console.log("No container"); 4562 return; 4563 } 4564 var activeDate; 4565 var prev = $('.ui-datepicker-prev', container)[0], 4566 next = $('.ui-datepicker-next', container)[0]; 4567 4568 $('#ui-datepicker-div').on('keydown', function calendarKeyboardListener(keyVent) { 4569 var which = keyVent.which; 4570 var target = keyVent.target; 4571 var dateCurrent = getCurrentDate(container); 4572 4573 if (!dateCurrent) { 4574 dateCurrent = $('a.ui-state-default')[0]; 4575 setHighlightState(dateCurrent, container); 4576 } 4577 4578 if (27 === which) { 4579 keyVent.stopPropagation(); 4580 return closeCalendar(); 4581 } else if (isShiftTabKey(keyVent)) { 4582 // Shift-TAB Key : we're not allowing keyboard access to the next/prev buttons and the close button is not displayed; just exit the datepicker 4583 keyVent.preventDefault(); 4584 keyVent.stopPropagation(); 4585 return closeCalendar(); 4586 } else if (isTabKey(keyVent)) { 4587 // Forward TAB Key : we're not allowing keyboard access to the next/prev buttons and the close button is not displayed; just exit the datepicker 4588 keyVent.preventDefault(); 4589 keyVent.stopPropagation();
4590 return closeCalendar(); 4591 } else if (which === 37) { // LEFT arrow key 4592 // if we're on a date link... 4593 if (!$(target).hasClass('ui-datepicker-close') && $(target).hasClass('ui-state-default')) { 4594 keyVent.preventDefault(); 4595 previousDay(target); 4596 } 4597 } else if (which === 39) { // RIGHT arrow key 4598 // if we're on a date link... 4599 if (!$(target).hasClass('ui-datepicker-close') && $(target).hasClass('ui-state-default')) { 4600 keyVent.preventDefault(); 4601 nextDay(target); 4602 } 4603 } else if (which === 38) { // UP arrow key 4604 if (!$(target).hasClass('ui-datepicker-close') && $(target).hasClass('ui-state-default')) { 4605 keyVent.preventDefault(); 4606 upHandler(target, container, prev); 4607 } 4608 } else if (which === 40) { // DOWN arrow key 4609 if (!$(target).hasClass('ui-datepicker-close') && $(target).hasClass('ui-state-default')) { 4610 keyVent.preventDefault(); 4611 downHandler(target, container, next); 4612 } 4613 } else if (which === 13) { // ENTER 4614 if ($(target).hasClass('ui-state-default')) { 4615 setTimeout(function () { 4616 closeCalendar(); 4617 }, 100); 4618 } else if ($(target).hasClass('ui-datepicker-prev')) { 4619 handlePrevClicks(); 4620 } else if ($(target).hasClass('ui-datepicker-next')) { 4621 handleNextClicks(); 4622 } 4623 } else if (32 === which) { 4624 if ($(target).hasClass('ui-datepicker-prev') || $(target).hasClass('ui-datepicker-next')) { 4625 target.click(); 4626 } 4627 } else if (33 === which) { // PAGE UP 4628 keyVent.preventDefault(); 4629 moveOneMonth(target, 'prev'); 4630 } else if (34 === which) { // PAGE DOWN 4631 keyVent.preventDefault(); 4632 moveOneMonth(target, 'next'); 4633 } else if (36 === which) { // HOME 4634 keyVent.preventDefault(); 4635 var firstOfMonth = $(target).closest('tbody').find('.ui-state-default')[0]; 4636 if (firstOfMonth) { 4637 firstOfMonth.focus(); 4638 setHighlightState(firstOfMonth, $('#ui-datepicker-div')[0]); 4639 } 4640 } else if (35 === which) { // END 4641 keyVent.preventDefault(); 4642 var $daysOfMonth = $(target).closest('tbody').find('.ui-state-default'); 4643 var lastDay = $daysOfMonth[$daysOfMonth.length - 1]; 4644 if (lastDay) { 4645 lastDay.focus(); 4646 setHighlightState(lastDay, $('#ui-datepicker-div')[0]); 4647 } 4648 } 4649 $(".ui-datepicker-current").hide(); 4650 }); 4651 } 4652 4653 /* -------------------------------------------------------------------------------------------------- 4654 * Close the datepicker control 4655 * 4656 * NOTE: We can remove this function and just call $(currentDateInput).datepicker('hide'); if it's more clear to do so 4657 * 4658 */ 4659 function closeCalendar() { 4660 $(currentDateInput).datepicker('hide'); 4661 } 4662 4663 /* -------------------------------------------------------------------------------------------------- 4664 * Move the calender forward or backwards one month (next/prev month) when the user presses PAGE-UP or PAGE-DOWN keys 4665 * 4666 * @param {HTMLElement} currentDate : The link <a> representing the currently highlighted date (received keyboard event) 4667 * @param {string} dir : The direction to move 'prev' / 'next' 4668 * 4669 */ 4670 function moveOneMonth(currentDate, dir) { 4671 var button = (dir === 'next') 4672 ? $('.ui-datepicker-next')[0] 4673 : $('.ui-datepicker-prev')[0]; 4674 4675 if (!button) { 4676 return; 4677 } 4678 4679 var ENABLED_SELECTOR = '#ui-datepicker-div tbody td:not(.ui-state-disabled)'; 4680 var $currentCells = $(ENABLED_SELECTOR); 4681 var currentIdx = $.inArray(currentDate.parentNode, $currentCells); 4682 4683 button.click(); 4684 setTimeout(function () { 4685 updateHeaderElements(); 4686 4687 var $newCells = $(ENABLED_SELECTOR); 4688 var newTd = $newCells[currentIdx]; 4689 var newAnchor = newTd && $(newTd).find('a')[0]; 4690 4691 while (!newAnchor) { 4692 currentIdx--; 4693 newTd = $newCells[currentIdx]; 4694 newAnchor = newTd && $(newTd).find('a')[0]; 4695 } 4696 4697 setHighlightState(newAnchor, $('#ui-datepicker-div')[0]); 4698 newAnchor.focus(); 4699 4700 }, 0); 4701 4702 } 4703 4704 /* -------------------------------------------------------------------------------------------------- 4705 * Handle clicks from the datepicker's [previous] button (top-left of the calendare title bar) 4706 * 4707 */ 4708 function handlePrevClicks() { 4709 setTimeout(function () { 4710 updateHeaderElements(); 4711 prepHighlightState(); 4712 $('.ui-datepicker-prev').trigger("focus"); 4713 $(".ui-datepicker-current").hide(); 4714 }, 0); 4715 } 4716 4717 /* -------------------------------------------------------------------------------------------------- 4718 * Handle clicks from the datepicker's [next] button (top-right of the calendare title bar) 4719 * 4720 */ 4721 function handleNextClicks() { 4722 setTimeout(function () { 4723 updateHeaderElements(); 4724 prepHighlightState(); 4725 $('.ui-datepicker-next').trigger("focus"); 4726 $(".ui-datepicker-current").hide(); 4727 }, 0); 4728 } 4729 4730 /* -------------------------------------------------------------------------------------------------- 4731 * Handles left arrow key navigation. 4732 * Attempt to move to the previous day. If not available, move to the previous week. 4733 * 4734 * @param {HTMLElement} dateLink : The link <a> representing the currently highlighted date (received keyboard event) 4735 * 4736 */ 4737 function previousDay(dateLink) { 4738 var container = document.getElementById('ui-datepicker-div'); 4739 if (!dateLink) { 4740 return; 4741 } 4742 var td = $(dateLink).closest('td'); 4743 if (!td) { 4744 return; 4745 } 4746 4747 var prevTd = $(td).prev(), 4748 prevDateLink = $('a.ui-state-default', prevTd)[0]; 4749 4750 if (prevTd && prevDateLink) { 4751 setHighlightState(prevDateLink, container); 4752 prevDateLink.focus(); 4753 } else { 4754 handlePrevious(dateLink); 4755 } 4756 } 4757 4758 4759 /* -------------------------------------------------------------------------------------------------- 4760 * Attempt to move to the previous week. If not possible, move to the previous month. 4761 * 4762 * @param {HTMLElement} target : The link <a> representing the currently highlighted date (received keyboard event) 4763 * 4764 */ 4765 function handlePrevious(target) { 4766 var container = document.getElementById('ui-datepicker-div'); 4767 if (!target) { 4768 return; 4769 } 4770 var currentRow = $(target).closest('tr'); 4771 if (!currentRow) { 4772 return; 4773 } 4774 var previousRow = $(currentRow).prev(); 4775 4776 if (!previousRow || previousRow.length === 0) { 4777 // there is not previous row, so we go to previous month... 4778 previousMonth(); 4779 } else { 4780 var prevRowDates = $('td a.ui-state-default', previousRow); 4781 var prevRowDate = prevRowDates[prevRowDates.length - 1]; 4782 4783 if (prevRowDate) { 4784 setTimeout(function () { 4785 setHighlightState(prevRowDate, container); 4786 prevRowDate.focus(); 4787 }, 0); 4788 } 4789 } 4790 } 4791 4792 /* -------------------------------------------------------------------------------------------------- 4793 * Move the calendar to the previous month. 4794 * 4795 */ 4796 function previousMonth() { 4797 var prevLink = $('.ui-datepicker-prev')[0]; 4798 var container = document.getElementById('ui-datepicker-div'); 4799 prevLink.click(); 4800 // focus last day of new month 4801 setTimeout(function () { 4802 var trs = $('tr', container), 4803 lastRowTdLinks = $('td a.ui-state-default', trs[trs.length - 1]), 4804 lastDate = lastRowTdLinks[lastRowTdLinks.length - 1]; 4805 4806 // updating the cached header elements 4807 updateHeaderElements(); 4808 4809 setHighlightState(lastDate, container); 4810 lastDate.focus(); 4811 4812 }, 0); 4813 } 4814 4815 /* -------------------------------------------------------------------------------------------------- 4816 * Handles right arrow key navigation. 4817 * Attempt to move to the next day. If not available, move to the next week. 4818 * 4819 * @param {HTMLElement} dateLink : The link <a> representing the currently highlighted date (received keyboard event) 4820 * 4821 */ 4822 function nextDay(dateLink) { 4823 var container = document.getElementById('ui-datepicker-div'); 4824 if (!dateLink) { 4825 return; 4826 } 4827 var td = $(dateLink).closest('td'); 4828 if (!td) { 4829 return; 4830 } 4831 var nextTd = $(td).next(), 4832 nextDateLink = $('a.ui-state-default', nextTd)[0]; 4833 4834 if (nextTd && nextDateLink) { 4835 setHighlightState(nextDateLink, container); 4836 nextDateLink.focus(); // the next day (same row) 4837 } else { 4838 handleNext(dateLink); 4839 } 4840 } 4841 4842 /* -------------------------------------------------------------------------------------------------- 4843 * Attempt to move to the next week. If not possible, move to the next month. 4844 * 4845 * @param {HTMLElement} target : The link <a> representing the currently highlighted date (received keyboard event) 4846 * 4847 */ 4848 function handleNext(target) { 4849 var container = document.getElementById('ui-datepicker-div'); 4850 if (!target) { 4851 return; 4852 } 4853 var currentRow = $(target).closest('tr'), 4854 nextRow = $(currentRow).next(); 4855 4856 if (!nextRow || nextRow.length === 0) { 4857 nextMonth(); 4858 } else { 4859 var nextRowFirstDate = $('a.ui-state-default', nextRow)[0]; 4860 if (nextRowFirstDate) { 4861 setHighlightState(nextRowFirstDate, container); 4862 nextRowFirstDate.focus(); 4863 } 4864 } 4865 } 4866 4867 /* -------------------------------------------------------------------------------------------------- 4868 * Move the calendar to the next month. 4869 * 4870 */ 4871 function nextMonth() { 4872 nextMon = $('.ui-datepicker-next')[0]; 4873 var container = document.getElementById('ui-datepicker-div'); 4874 nextMon.click(); 4875 // focus the first day of the new month 4876 setTimeout(function () { 4877 // updating the cached header elements 4878 updateHeaderElements(); 4879 4880 var firstDate = $('a.ui-state-default', container)[0]; 4881 setHighlightState(firstDate, container); 4882 firstDate.focus(); 4883 }, 0); 4884 } 4885 4886 /* -------------------------------------------------------------------------------------------------- 4887 * Handles up arrow navigation through dates in calendar. 4888 * Attempt to move up one week on the same day. If not possible, move to the previous month (same day) 4889 * 4890 * @param {HTMLElement} target : The link <a> representing the currently highlighted date (received keyboard event) 4891 * @param {Element} cont : The calendar container 4892 * @param {HTMLElement} prevLink : Link to navigate to previous month 4893 * 4894 */ 4895 function upHandler(target, cont, prevLink) { 4896 prevLink = $('.ui-datepicker-prev')[0]; 4897 var rowContext = $(target).closest('tr'); 4898 if (!rowContext) { 4899 return; 4900 } 4901 var rowTds = $('td', rowContext), 4902 rowLinks = $('a.ui-state-default', rowContext), 4903 targetIndex = $.inArray(target, rowLinks), 4904 prevRow = $(rowContext).prev(), 4905 prevRowTds = $('td', prevRow), 4906 parallel = prevRowTds[targetIndex], 4907 linkCheck = $('a.ui-state-default', parallel)[0]; 4908 4909 if (prevRow && parallel && linkCheck) { 4910 // there is a previous row, a td at the same index 4911 // of the target AND theres a link in that td 4912 setHighlightState(linkCheck, cont); 4913 linkCheck.focus(); 4914 } else { 4915 // we're either on the first row of a month, or we're on the 4916 // second and there is not a date link directly above the target 4917 prevLink.click(); 4918 setTimeout(function () { 4919 // updating the cached header elements 4920 updateHeaderElements(); 4921 var newRows = $('tr', cont), 4922 lastRow = newRows[newRows.length - 1], 4923 lastRowTds = $('td', lastRow), 4924 tdParallelIndex = $.inArray(target.parentNode, rowTds), 4925 newParallel = lastRowTds[tdParallelIndex], 4926 newCheck = $('a.ui-state-default', newParallel)[0]; 4927 4928 if (lastRow && newParallel && newCheck) { 4929 setHighlightState(newCheck, cont); 4930 newCheck.focus(); 4931 } else { 4932 // theres no date link on the last week (row) of the new month 4933 // meaning its an empty cell, so we'll try the 2nd to last week 4934 var secondLastRow = newRows[newRows.length - 2], 4935 secondTds = $('td', secondLastRow), 4936 targetTd = secondTds[tdParallelIndex], 4937 linkCheck = $('a.ui-state-default', targetTd)[0]; 4938 4939 if (linkCheck) { 4940 setHighlightState(linkCheck, cont); 4941 linkCheck.focus(); 4942 } 4943 4944 } 4945 }, 0); 4946 } 4947 } 4948 4949 /* -------------------------------------------------------------------------------------------------- 4950 * Handles down arrow navigation through dates in calendar. 4951 * Attempt to move down one week on the same day. If not possible, move to the next month (same day) 4952 * 4953 * @param {HTMLElement} target : The link <a> representing the currently highlighted date (received keyboard event) 4954 * @param {Element} cont : The calendar container 4955 * @param {HTMLElement} nextLink : Link to navigate to next month 4956 * 4957 */ 4958 function downHandler(target, cont, nextLink) { 4959 nextLink = $('.ui-datepicker-next')[0]; 4960 var targetRow = $(target).closest('tr'); 4961 if (!targetRow) { 4962 return; 4963 } 4964 var targetCells = $('td', targetRow), 4965 cellIndex = $.inArray(target.parentNode, targetCells), // the td (parent of target) index 4966 nextRow = $(targetRow).next(), 4967 nextRowCells = $('td', nextRow), 4968 nextWeekTd = nextRowCells[cellIndex], 4969 nextWeekCheck = $('a.ui-state-default', nextWeekTd)[0]; 4970 4971 if (nextRow && nextWeekTd && nextWeekCheck) { 4972 // theres a next row, a TD at the same index of `target`, 4973 // and theres an anchor within that td 4974 setHighlightState(nextWeekCheck, cont); 4975 nextWeekCheck.focus(); 4976 } else { 4977 nextLink.click(); 4978 4979 setTimeout(function () { 4980 // updating the cached header elements 4981 updateHeaderElements(); 4982 4983 var nextMonthTrs = $('tbody tr', cont), 4984 firstTds = $('td', nextMonthTrs[0]), 4985 firstParallel = firstTds[cellIndex], 4986 firstCheck = $('a.ui-
4986state-default', firstParallel)[0]; 4987 4988 if (firstParallel && firstCheck) { 4989 setHighlightState(firstCheck, cont); 4990 firstCheck.focus(); 4991 } else { 4992 // lets try the second row b/c we didnt find a 4993 // date link in the first row at the target's index 4994 var secondRow = nextMonthTrs[1], 4995 secondTds = $('td', secondRow), 4996 secondRowTd = secondTds[cellIndex], 4997 secondCheck = $('a.ui-state-default', secondRowTd)[0]; 4998 4999 if (secondRow && secondCheck) { 5000 setHighlightState(secondCheck, cont); 5001 secondCheck.focus(); 5002 } 5003 } 5004 }, 0); 5005 } 5006 } 5007 5008 /* -------------------------------------------------------------------------------------------------- 5009 * Determine the correct highligh position, then set the highlight 5010 * 5011 */ 5012 function prepHighlightState() { 5013 var highlight; 5014 var cage = document.getElementById('ui-datepicker-div'); 5015 highlight = $('.ui-state-highlight', cage)[0] || 5016 $('.ui-state-default', cage)[0]; 5017 if (highlight && cage) { 5018 setHighlightState(highlight, cage); 5019 } 5020 } 5021 5022 /* -------------------------------------------------------------------------------------------------- 5023 * Set the highlighted class to date elements, when focus is received 5024 * 5025 * @param {HTMLElement} newHighlight : The link <a> representing the new focus target (to receive keyboard focus; date to be highlighted) 5026 * @param {Element} container : The conainer DIV element for this datepicker control 5027 */ 5028 function setHighlightState(newHighlight, container) { 5029 var prevHighlight = getCurrentDate(container); 5030 // remove the highlight state from previously 5031 // highlighted date and add it to our newly active date 5032 $(prevHighlight).removeClass('ui-state-highlight'); 5033 $(newHighlight).addClass('ui-state-highlight'); 5034 } 5035 5036 /* -------------------------------------------------------------------------------------------------- 5037 * add an aria-label to the date link indicating the currently focused date 5038 * (formatted identically to the required format: mm/dd/yyyy) 5039 * 5040 * NOTE this may be overridden by code below; review and make sure we're only doing this once and doing it correctly 5041 * 5042 */ 5043 function monthDayYearText() { 5044 var cleanUps = $('.amaze-date'); 5045 5046 $(cleanUps).each(function (clean) { 5047 // each(cleanUps, function (clean) { 5048 clean.parentNode.removeChild(clean); 5049 }); 5050 5051 var datePickDiv = document.getElementById('ui-datepicker-div'); 5052 // in case we find no datepick div 5053 if (!datePickDiv) { 5054 return; 5055 } 5056 5057 var dates = $('a.ui-state-default', datePickDiv); 5058 $(dates).attr('role', 'button').on('keydown', function (e) { 5059 if (e.which === 32) { 5060 e.preventDefault(); 5061 e.target.click(); 5062 setTimeout(function () { 5063 closeCalendar(); 5064 }, 100); 5065 } 5066 }); 5067 $(dates).each(function (index, date) { 5068 var currentRow = $(date).closest('tr'), 5069 currentTds = $('td', currentRow), 5070 currentIndex = $.inArray(date.parentNode, currentTds), 5071 headThs = $('thead tr th', datePickDiv), 5072 dayIndex = headThs[currentIndex], 5073 daySpan = $('span', dayIndex)[0], 5074 monthName = $('.ui-datepicker-month', datePickDiv)[0].innerHTML, 5075 year = $('.ui-datepicker-year', datePickDiv)[0].innerHTML, 5076 number = date.innerHTML; 5077 5078 if (!daySpan || !monthName || !number || !year) { 5079 return; 5080 } 5081 5082 // AT Reads: {month} {date} {year} {day} 5083 // "December 18 2014 Thursday" 5084 var dateText = date.innerHTML + ' ' + monthName + ' ' + year + ' ' + daySpan.title; 5085 // AT Reads: {date(number)} {name of day} {name of month} {year(number)} 5086 // var dateText = date.innerHTML + ' ' + daySpan.title + ' ' + monthName + ' ' + year; 5087 // add an aria-label to the date link reading out the currently focused date 5088 date.setAttribute('aria-label', dateText); 5089 }); 5090 } 5091 5092 /* -------------------------------------------------------------------------------------------------- 5093 * update the cached header elements because we're in a new month or year 5094 * 5095 */ 5096 function updateHeaderElements() { 5097 var context = document.getElementById('ui-datepicker-div'); 5098 if (!context) { 5099 return; 5100 } 5101 5102 // $(context).find('table').first().attr('role', 'grid'); 5103 5104 prev = $('.ui-datepicker-prev', context)[0]; 5105 next = $('.ui-datepicker-next', context)[0]; 5106 5107 //make them click/focus - able 5108 //next.href = 'javascript:;'; 5109 //prev.href = 'javascript:;'; 5110 5111 //next.setAttribute('role', 'button'); 5112 //prev.setAttribute('role', 'button'); 5113 appendOffscreenMonthText(next); 5114 appendOffscreenMonthText(prev); 5115 5116 //$(next).on('click', handleNextClicks); 5117 //$(prev).on('click', handlePrevClicks); 5118 5119 // add month day year text 5120 monthDayYearText(); 5121 } 5122 5123 5124 /* -------------------------------------------------------------------------------------------------- 5125 * Appends logical next/prev month text to the buttons 5126 * - ex: Next Month, January 2015 5127 * Previous Month, November 2014 5128 * 5129 * @param {Element} button : The button to which the off screen month text is to be added 5130 * 5131 */ 5132 function appendOffscreenMonthText(button) { 5133 var buttonText; 5134 var isNext = $(button).hasClass('ui-datepicker-next'); 5135 var months = [ 5136 'january', 'february', 5137 'march', 'april', 5138 'may', 'june', 'july', 5139 'august', 'september', 5140 'october', 5141 'november', 'december' 5142 ]; 5143 5144 var currentMonth = $('.ui-datepicker-title .ui-datepicker-month').text().toLowerCase(); 5145 var monthIndex = $.inArray(currentMonth.toLowerCase(), months); 5146 var currentYear = $('.ui-datepicker-title .ui-datepicker-year').text().toLowerCase(); 5147 var adjacentIndex = (isNext) ? monthIndex + 1 : monthIndex - 1; 5148 5149 if (isNext && currentMonth === 'december') { 5150 currentYear = parseInt(currentYear, 10) + 1; 5151 adjacentIndex = 0; 5152 } else if (!isNext && currentMonth === 'january') { 5153 currentYear = parseInt(currentYear, 10) - 1; 5154 adjacentIndex = months.length - 1; 5155 } 5156 5157 buttonText = (isNext) 5158 ? 'Next Month, ' + firstToCap(months[adjacentIndex]) + ' ' + currentYear 5159 : 'Previous Month, ' + firstToCap(months[adjacentIndex]) + ' ' + currentYear; 5160 5161 $(button).find('.ui-icon').html(buttonText); 5162 5163 } 5164 5165 /* -------------------------------------------------------------------------------------------------- 5166 * grabs the current date based on the highlight class 5167 * 5168 * @param {Element} container : The conainer DIV element for this datepicker control 5169 * 5170 */ 5171 function getCurrentDate(container) { 5172 var currentDate = $('.ui-state-highlight', container)[0]; 5173 return currentDate; 5174 } 5175 5176 /* -------------------------------------------------------------------------------------------------- 5177 * Handle jumping forward over the next datepicker by pressing the CTRL key 5178 * When datepicker is surrounded by "skip" controls, user can press the CTRL key to jump over the datepicker (instead of entering/exiting the datepicker) 5179 * 5180 * For datepickers in the filter bar, the current implementation is <span><div/datepicker><span> where each span jumps to the other when pressing CTRL 5181 * For datepickers in forms, or in vertical UI (navigating top to bottom) the current implementation is <div><div/datepicker><div> where the surrounding div's skip to each other on CTRL 5182 * 5183 * @param {KeyboardEvent}
5183 event : Keydown event on the target 5184 * @param {string} focusId : Focus target if the event represents a CTRL keydown (typically the sibling skip-link on the other side of the datepicker control) 5185 * 5186 */ 5187 function aboveDatePickerOnKeyDown(event, focusId) { 5188 if (event.which == 17) { 5189 // CTRL key pressed 5190 var focusElem = document.getElementById(focusId); 5191 if (isEmpty(focusElem)) { 5192 console.error('focusElem empty for focusId=' + focusId); 5193 } else { 5194 focusElem.focus(); 5195 } 5196 } 5197 } 5198 5199 /* -------------------------------------------------------------------------------------------------- 5200 * Handle jumping backwards over the previous datepicker by pressing the CTRL key 5201 * When datepicker is surrounded by "skip" controls, user can press the CTRL key to jump over the datepicker (instead of entering/exiting the datepicker) 5202 * 5203 * For datepickers in the filter bar, the current implementation is <span><div/datepicker><span> where each span jumps to the other when pressing CTRL 5204 * For datepickers in forms, or in vertical UI (navigating top to bottom) the current implementation is <div><div/datepicker><div> where the surrounding div's skip to each other on CTRL 5205 * 5206 * @param {KeyboardEvent} event : Keydown event on the target 5207 * @param {string} focusId : Focus target if the event represents a CTRL keydown (typically the sibling skip-link on the other side of the datepicker control) 5208 * 5209 */ 5210 function belowDatePickerOnKeyDown(event, focusId) { 5211 if (event.which == 17) { 5212 // CTRL key pressed 5213 var focusElem = document.getElementById(focusId); 5214 if (isEmpty(focusElem)) { 5215 console.error('focusElem empty for focusId=' + focusId); 5216 } else { 5217 focusElem.focus(); 5218 } 5219 } 5220 } 5221 5222 /* -------------------------------------------------------------------------------------------------- 5223 * Show the datepicker (expand/make visible) when it receives focus 5224 * 5225 * NOTE if we permanently require no more work than calling $(datePickerId).datepicker('show'); we can remove this function and just make the call directly 5226 * 5227 * @param {string} datePickerId : ID of the datepicker (container div) 5228 * 5229 */ 5230 function datePickerReShow(datePickerId) { 5231 $(datePickerId).datepicker('show'); 5232 } 5233 5234 /* -------------------------------------------------------------------------------------------------- 5235 * Restart the datepicker when it closed. Setup the focus handler and wait to re
5235ceive focus again. 5236 * 5237 * @param {string} datePickerId : ID of the datepicker (container div) 5238 * 5239 */ 5240 function datePickerRestart(datePickerId) { 5241 // need to wait a few millis before restart (let other DOM tasks finish up) 5242 setTimeout(function () { 5243 $(datePickerId).on('focus', function() { 5244 // 5245 // need to disable the onFocus of the associated input first or it will be called continously (will be reset on datepicker exit/close) 5246 // 5247 $(datePickerId).off('focus'); 5248 datePickerReShow(datePickerId); 5249 }); 5250 }, 500); 5251 } 5252 5253 /* -------------------------------------------------------------------------------------------------- 5254 * Close the datepicker and setup to receive future focus. 5255 * 5256 * @param {string} datePickerId : ID of the datepicker (container div) 5257 * @param {string} dateText : The date selected. 5258 * @param {string} focusId : ID of the target element to receive focus after closing the datepicker 5259 * 5260 */ 5261 function datePickerOnClose(datePickerId, dateText, focusId) { 5262 if (isEmpty(datePickerId)) { console.error('datePickerId is empty'); return; } 5263 if (isEmpty(focusId)) { console.error('focusId is empty'); return; } 5264 if (!datePickerId.startsWith('#')) { datePickerId = '#' + datePickerId; } 5265 if (!focusId.startsWith('#')) { focusId = '#' + focusId; } 5266 if (!isEmpty(dateText)) { 5267 $(datePickerId).attr('value', dateText); 5268 $(datePickerId).datepicker('setDate', dateText); 5269 } 5270 if ($(focusId).length > 0) { 5271 $(focusId)[0].focus(); 5272 } 5273 $(datePickerId).removeClass('hasMonitor'); 5274 datePickerRestart(datePickerId); 5275 } 5276 5277 /* -------------------------------------------------------------------------------------------------- 5278 * Monitor the datepicker while open and update required elements/attributes when we change months 5279 * 5280 * @param {string} datePickerId : ID of the datepicker (container div) 5281 * @param {string} initialDate : The currently selected date when starting/opening the datepicker 5282 */ 5283 function monitorDatePicker(datePickerId, initialDate) { 5284 if (isEmpty(datePickerId)) { console.error('datePickerId is empty.'); return; } 5285 if (!datePickerId.startsWith('#')) { datePickerId = '#' + datePickerId; } 5286 if (isEmpty(initialDate)) { initialDate = null; } 5287 5288 if ($(datePickerId).hasClass('hasMonitor')) { return; } 5289 $(datePickerId).addClass('hasMonitor'); 5290 5291 var waitSeconds = 5; 5292 var epochStart = Date.now(); 5293 var intervalId = setInterval(function () { 5294 if (!$(datePickerId).hasClass('hasMonitor')) { 5295 clearInterval(intervalId); 5296 return; 5297 } 5298 var datePickerDivs = $('#ui-datepicker-div'); 5299 if (!isEmpty(datePickerDivs) && datePickerDivs.length > 0) { 5300 for (var i=0; i<datePickerDivs.length; i++) { 5301 if (datePickerDivs[i].style.display == 'none') { 5302 $(datePickerId).removeClass('hasMonitor'); 5303 clearInterval(intervalId); 5304 return; 5305 } else { 5306 var isWorkRequired = true; 5307 var datePickerTable = $('#ui-datepicker-div table'); 5308 if (datePickerTable !== undefined && datePickerTable.length > 0) { 5309 if (datePickerTable[0].getAttribute('role') === 'presentation') { 5310 isWorkRequired = false; 5311 } 5312 } 5313 if (isWorkRequired) { 5314 var focusElem = null; 5315 datePickerTable[0].setAttribute('role', 'presentation'); 5316 5317 var datePickerTitleMonths = $('div.ui-datepicker-title span.ui-datepicker-month'); 5318 if (datePickerTitleMonths !== undefined && datePickerTitleMonths.length > 0) { 5319 var monthName = datePickerTitleMonths[0].textContent; 5320 } 5321 var datePickerTitleYears = $('div.ui-datepicker-title span.ui-datepicker-year'); 5322 if (datePickerTitleYears !== undefined && datePickerTitleYears.length > 0) { 5323 var yearNumber = datePickerTitleYears[0].textContent; 5324 } 5325 var dataPickerRows = $(datePickerTable).find('> tbody > tr'); 5326 if (dataPickerRows !== undefined && dataPickerRows.length > 0) { 5327 for (var row=0; row<dataPickerRows.length; row++) { 5328 var tds = $(dataPickerRows[row]).find('td'); 5329 if (tds !== undefined && tds.length > 0) { 5330 for (var cell=0; cell<tds.length; cell++) { 5331 var links = $(tds[cell]).find('a'); 5332 if (links !== undefined && links.length > 0) { 5333 var dayOfWeek = daysOfWeek[cell]; 5334 var dayOfMonth = links[0].textContent; 5335 var dayOfMonthText = '' + dayOfMonth; 5336 if (dayOfMonthText.length < 2) { 5337 dayOfMonthText = '0' + dayOfMonthText; 5338 } 5339 var ariaLabel = '' + dayOfMonth + ' ' + monthName + ' ' + yearNumber + ' ' + dayOfWeek; 5340 var datePickerDateText1 = dayOfMonthText + ' ' + monthName.substring(0, 3) + ' ' + yearNumber; 5341 var datePickerDateText2 = dayOfMonthText + ' ' + monthName.substring(0, 3) + ' ' + (yearNumber % 100); 5342 if (!isEmpty(initialDate)) { 5343 if (initialDate === datePickerDateText1) { 5344 focusElem = links[0]; 5345 initialDate = null; 5346 } else if (initialDate === datePickerDateText2) { 5347 focusElem = links[0]; 5348 initialDate = null; 5349 } 5350 } 5351 links[0].setAttribute('aria-label', ariaLabel); 5352 } 5353 } 5354 } 5355 } 5356 } 5357 if (!isEmpty(focusElem)) { 5358 focusElem.focus(); 5359 } 5360 } 5361 } 5362 } 5363 } else { 5364 // datepicker element not found 5365 var deltaSeconds = getElapsedSeconds(epochStart); 5366 if (deltaSeconds > waitSeconds) { 5367 $(datePickerId).removeClass('hasMonitor'); 5368 clearInterval(intervalId); 5369 } 5370 } 5371 }, 100); 5372 } 5373 5374 /* -------------------------------------------------------------------------------------------------- 5375 * Setup/initialize the datepicker for use in our environment 5376 * - set onClose handler 5377 * - set focusin handler 5378 * 5379 */ 5380 (function($) { 5381 if ($.datepicker) { 5382 $.datepicker.setDefaults({ 5383 showOn: "", 5384 onClose: function(date, input) { 5385 console.log(input); 5386 $(input).focus(); 5387 currentDateInput = undefined; 5388 } 5389 }); 5390 } 5391 5392 var oldDatepicker = $.fn.datepicker; 5393 $.fn.datepicker = function() 5394 { 5395 var ret = oldDatepicker.apply(this, arguments); 5396 5397 this.focusin(function(ev) { 5398 if (currentDateInput != ev.target) { 5399 if (!datePickerMadeAccessible) { 5400 accessibilityMagic(); 5401 datePickerMadeAccessible = true; 5402 } 5403 5404 $(ev.target).datepicker('show'); 5405 5406 currentDateInput = ev.target; 5407 var today = $('.ui-datepicker-today a')[0]; 5408 5409 if (!today) { 5410 today = $('.ui-state-active')[0] || 5411 $('.ui-state-default')[0]; 5412 } 5413 if (today) { today.focus(); } 5414 } 5415 }); 5416 5417 return ret; 5418 }; 5419 })(jQuery); 5420 5421/*-----------------------------------------------------*\ 5422 * @FEEDS | @FEEDPOSTS 5423 * 5424 * Functions to manage accessibility for feed post tabs 5425 * and feed post content. 5426 * 5427\*-----------------------------------------------------*/ 5428 5429/* Function that flags the currently selected tab as aria-selected=true (and all other tabs to false) 5430 * @param {string} containerId : the ID of the element containing the tabs 5431 * @param {string} selectedLinkId : the ID of the tab link just clicked 5432 */ 5433function flagSelectedTab(containerId, selectedLinkId) { 5434 $('#' + containerId + ' a').attr('aria-selected','false'); 5435 $('#' + selectedLinkId).attr('aria-selected','true'); 5436} 5437 5438/* Function that adds additional real-time information to accessibility labels (aria-label) 5439 * ex. feed posts (both on load after aspx has processed and on "load more" scroll beyond bottom) 5440 */ 5441function processAriaLive() { 5442 let intervalId = setInterval(function () { 5443 let ariaLiveElements = $('[data-aria-live]'); 5444 for (var i=0; i<ariaLiveElements.length; i++) { 5445 let ariaLiveElement = ariaLiveElements[i]; 5446 let ariaContainers = $(ariaLiveElement).closest('[data-aria-container]'); 5447 if (ariaContainers.length > 0) { 5448 let ariaContainerLabel = $(ariaContainers[0]).attr('aria-label'); 5449 if (isEmpty(ariaContainerLabel)) { 5450 ariaContainerLabel = ''; 5451 } 5452 let linkAccLabel = $(ariaLiveElement).attr('aria-label') + ' ' + ariaContainerLabel; 5453 $(ariaLiveElement).attr('aria-label', getSafeAriaLabelTextTrim(linkAccLabel)); 5454 $(ariaLiveElement).removeAttr('data-aria-live'); 5455 } 5456 } 5457 }, 1000); 5458} 5459 5460/* Function that assigns accessibility labels to the list checkboxes (left on each list item) 5461 * so the screen reader will read a unique label for each checkbox. Note that pages require setup 5462 * (currently only configured for Group Dashboard >
5462 Officers) 5463 */ 5464function setupStrCheckboxForLists() { 5465 setTimeout(function() { 5466 let strCheckboxContainers = $('div.checkbox[data-acc-container]'); 5467 for (var i=0; i<strCheckboxContainers.length; i++) { 5468 let checkboxContainer = strCheckboxContainers[i]; 5469 let checkboxContainerAccLabel = $(checkboxContainer).attr('data-acc-label'); 5470 if (!isEmpty(checkboxContainerAccLabel)) { 5471 let checkboxInputs = $(checkboxContainer).children('input'); 5472 if (checkboxInputs.length > 0) { 5473 $(checkboxInputs).attr('aria-label', checkboxContainerAccLabel); 5474 } 5475 } 5476 } 5477 }, 999); // wait for the page to fully load - users will not tab to here in under a second 5478} 5479 5480/* Function that fixes the background color on the Tracks and Checklists pages (white text on background with some color) */ 5481function updateTracksAndChecklistsBackgroundColors() { 5482 let doWork = false; 5483 let isWorking = false; 5484 let intervalCount = 0; 5485 let intervalId = setInterval(function () { 5486 intervalCount++; 5487 if (intervalCount > 150) { 5488 clearInterval(intervalId); 5489 } 5490 let trackHeaders = document.querySelectorAll('div.track__header'); 5491 if (doWork && !isWorking) { 5492 isWorking = true; 5493 for (var i=0; i<trackHeaders.length; i++) { 5494 let trackHeader = trackHeaders[i]; 5495 if (isEmpty(trackHeader.getAttribute('data-ccr'))) { 5496 let backgroundColor = trackHeader.style.backgroundColor; 5497 if (isEmpty(backgroundColor)) { 5498 backgroundColor = '#087eb4'; // default for tracks and checklists 5499 trackHeader.style.background = backgroundColor; 5500 } 5501 backgroundColor = getVisibleRGB(backgroundColor, '#ffffff'); 5502 if (getColorContrastRatio('#ffffff', backgroundColor) < 4.5) { 5503 trackHeader.classList.add('dark-text'); 5504 } 5505 trackHeader.setAttribute('data-ccr', 'ok'); 5506 } 5507 } 5508 clearInterval(intervalId); 5509 } 5510 if (trackHeaders.length > 0) { 5511 doWork = true; 5512 } 5513 }, 83); 5514} 5515 5516/* Function that fixes the background color on the user/member tags (white text on background with some color) */ 5517function updateUserTagsBackgroundColors() { 5518 let doWork = false; 5519 let isWorking = false; 5520 let intervalCount = 0; 5521 let intervalId = setInterval(function () { 5522 intervalCount++; 5523 if (intervalCount > 150) { 5524 clearInterval(intervalId); 5525 } 5526 let tags = document.querySelectorAll('.label.label-tag'); 5527 if (doWork && !isWorking) { 5528 isWorking = true; 5529 for (var i=0; i<tags.length; i++) { 5530 let tag = tags[i]; 5531 if (isEmpty(tag.getAttribute('data-ccr'))) { 5532 let computedBackgroundColor = window.getComputedStyle(tag).backgroundColor; 5533 if (isEmpty(computedBackgroundColor)) { 5534 computedBackgroundColor = tag.style.backgroundColor; 5535 if (isEmpty(computedBackgroundColor)) { 5536 computedBackgroundColor = '#767676'; // default grey 5537 tag.style.backgroundColor = computedBackgroundColor; 5538 } 5539 } 5540 computedBackgroundColor = getVisibleRGB(computedBackgroundColor, '#ffffff'); 5541 if (getColorContrastRatio('#ffffff', computedBackgroundColor) < 4.5) { 5542 tag.classList.add('dark-text'); 5543 } 5544 tag.setAttribute('data-ccr', 'ok'); 5545 } 5546 } 5547 clearInterval(intervalId); 5548 } 5549 if (tags.length > 0) { 5550 doWork = true; 5551 } 5552 }, 79); 5553} 5554 5555/* Function that fixes the background color on badges (white text on background with some color) */
5556function updateBadgeBackgroundColors() { 5557 let doWork = false; 5558 let isWorking = false; 5559 let intervalCount = 0; 5560 let intervalId = setInterval(function () { 5561 intervalCount++; 5562 if (intervalCount > 150) { 5563 clearInterval(intervalId); 5564 } 5565 let badges = document.querySelectorAll('a.badge'); 5566 if (doWork && !isWorking) { 5567 isWorking = true; 5568 for (var i=0; i<badges.length; i++) { 5569 let badge = badges[i]; 5570 if (isEmpty(badge.getAttribute('data-ccr'))) { 5571 let computedBackgroundColor = window.getComputedStyle(badge).backgroundColor; 5572 if (isEmpty(computedBackgroundColor)) { 5573 computedBackgroundColor = badge.style.backgroundColor; 5574 if (isEmpty(computedBackgroundColor)) { 5575 computedBackgroundColor = '#767676'; // default grey 5576 badge.style.backgroundColor = computedBackgroundColor; 5577 } 5578 } 5579 computedBackgroundColor = getVisibleRGB(computedBackgroundColor, '#ffffff'); 5580 if (getColorContrastRatio('#ffffff', computedBackgroundColor) < 4.5) { 5581 badge.classList.add('dark-text'); 5582 } 5583 badge.setAttribute('data-ccr', 'ok'); 5584 } 5585 } 5586 clearInterval(intervalId); 5587 } 5588 if (badges.length > 0) { 5589 doWork = true; 5590 } 5591 }, 83); 5592} 5593 5594/* Function that closes a dropdown automatically when keyboard focus has left the dropdown group. 5595 * The dropdown group includes the dropdown button or "More" button that often has an icon with 3 vertical dots (class="caret") and not text, 5596 * as well as the associated dropdown list. When navigating with the keyboard, we want to automatically close this dropdown 5597 * when the user has left the dropdown list or the associated dropdown toggle button. 5598 */ 5599function dropdownMenuFocusoutA11yHandler(elem) { 5600 if (isAccKeyboardMode()) { 5601 setTimeout(function () { 5602 let focusElem = document.activeElement; 5603 let isInsideBtnGroup = ($(focusElem).parents('.btn-group.open').length > 0) ? true : false; 5604 let isInsideDropdown = ($(focusElem).parents('.dropdown-menu').length > 0) ? true : false; 5605 if (!isInsideBtnGroup) { 5606 $(elem).removeClass('open'); 5607 $(elem).find('[aria-expanded]').attr('aria-expanded', 'false'); 5608 } else if (isInsideBtnGroup && !isInsideDropdown) { 5609 $(elem).removeClass('open'); 5610 $(elem).find('[aria-expanded]').attr('aria-expanded', 'false'); 5611 } 5612 }, 667); 5613 } 5614} 5615 5616 5617/** 5618 * Moves focus to the first `.dropdown-toggle` inside the same `.btn-group` 5619 * as the given element. Used to restore focus after canceling an action. 5620 * @param {HTMLElement} el - The element (e.g., a Delete button, Duplicate button) whose parent 5621 * `.btn-group` will be searched. 5622 * Intended for cases where a user cancels an action (e.g., a delete), 5623 * and focus should return to the primary toggle control of the group. 5624 */ 5625function dropdownButtonFocusinA11yHandler(el) 5626{ 5627 if (isAccKeyboardMode()) { 5628 const group = el.closest('.btn-group'); 5629 const toggle = group && group.querySelector('.dropdown-toggle'); 5630 if (toggle) { 5631 toggle.focus(); 5632 } 5633 } 5634} 5635 5636/*-----------------------------------------------------*\ 5637 @ARIA-LIVE 5638 Status messages and updates specifically for the screen reader 5639\*-----------------------------------------------------*/ 5640 5641function a11yAnnounceToScreenReader(message) { 5642 // Create or get the aria-live region 5643 let liveRegion = document.getElementById('cg-aria-live-region'); 5644 if (liveRegion) { 5645 liveRegion.textContent = getSafeAriaLabelTextTrim(message); 5646 } else { 5647 // If the live region doesn't already exist we will need time for the screen reader to recognize it and register it's current (empty) contents 5648 liveRegion = document.createElement('div'); 5649 liveRegion.id = 'cg-aria-live-region'; 5650 liveRegion.setAttribute('aria-live', 'polite'); 5651 liveRegion.setAttribute('aria-atomic', 'true'); 5652 liveRegion.style.position = 'absolute'; 5653 liveRegion.style.left = '-9999px'; 5654 document.body.appendChild(liveRegion); 5655 setTimeout(function() { 5656 a11yAnnounceToScreenReader(message); 5657 }, 333);
5657 // wait 1/3 second for the screen reader to register new live region 5658 } 5659} 5660 5661/* Function that forces focus to the specified element (by cssSelector) with a screen reader announcement when complete. 5662 * For example: adding tags using the multiselect dropdown will submit the DB request then remove and re-define the DOM for the associated select, 5663 * and we want to force keyboard focus back to the select when it appears back in the DOM after DB update */ 5664function a11yFocusAfterReset(cssSelector, announcement, timeoutSeconds) { 5665 $(cssSelector).attr('data-far', 'true'); 5666 let waitForSelector = cssSelector + ':not([data-far="true"])'; 5667 function focusAfterReset(focusElem) { 5668 if (focusElem) { 5669 focusElem.focus(); 5670 a11yAnnounceToScreenReader(announcement); 5671 } 5672 } 5673 waitFor(waitForSelector, focusAfterReset, 111, timeoutSeconds * 1000); 5674} 5675 5676/*-----------------------------------------------------*\ 5677 @TRACKS AND CHECKLISTS 5678 Keyboard navigation and accessibility support for tracks and checklists 5679\*-----------------------------------------------------*/ 5680 5681function setupTracksAndChecklistsAccessibility() { 5682 5683 // Helper function to safely set tabindex only if not already defined 5684 function setTabIndexIfSafe(element) { 5685 if (isTabIndexSpecified(element)) { 5686 return; // nothing to do 5687 } 5688 $(element).attr('tabindex', '0'); 5689 } 5690 5691 // Add keyboard support for track toggle buttons 5692 $('.btn-icon[onclick*="toggleTrackChecklists"]').each(function() { 5693 setTabIndexIfSafe(this); 5694 $(this).keydown(function(e) { 5695 if (e.keyCode === 13 || e.keyCode === 32) { // Enter or Space 5696 e.preventDefault(); 5697 $(this).click(); 5698 } 5699 }); 5700 }); 5701 5702 // Add keyboard support for checklist toggle buttons 5703 $('.btn-icon[onclick*="toggleChecklist"]').each(function() { 5704 setTabIndexIfSafe(this); 5705 $(this).keydown(function(e) { 5706 if (e.keyCode === 13 || e.keyCode === 32) { // Enter or Space 5707 e.preventDefault(); 5708 $(this).click(); 5709 } 5710 }); 5711 }); 5712 5713 // Add keyboard support for checklist items 5714 $('.checklist-item input[type="checkbox"]').each(function() { 5715 setTabIndexIfSafe(this); 5716 $(this).keydown(function(e) { 5717 if (e.keyCode === 13 || e.keyCode === 32) { // Enter or Space 5718 e.preventDefault(); 5719 $(this).click(); 5720 } 5721 }); 5722 }); 5723} 5724 5725/*-----------------------------------------------------*\ 5726 @DRAG-DROP 5727 Keyboard support for list page drag/drop (.handle) elements. 5728 Status messages and screen reader updates specifically for the screen reader 5729\*-----------------------------------------------------*/ 5730 5731/* Function that returns the accessibility context for a given list item based on the list item index (1+) */ 5732function getListItemA11yContext(oneBasedIndex) { 5733 // Support both li (card view) and tr (list view) elements 5734 let listItem = $('#divAllItems li:nth-child(' + oneBasedIndex + '), #divAllItems tr:nth-child(' + oneBasedIndex + ')'); 5735 if (listItem.length < 1) { 5736 console.log('> getListItemA11yContext() nthIndex=' + oneBasedIndex + ' not found.'); 5737 return; 5738 } 5739 let legend = $(listItem).find('fieldset.cg-acc--sr-fieldset > legend'); 5740 if (legend.length === 1) { 5741 let legendLabel = legend.text(); 5742 if (!isEmpty(legendLabel)) { 5743 return legendLabel; 5744 } 5745 } 5746 let a11yContainer = $(listItem).find('div.listing-element[role="group"]'); 5747 if (a11yContainer.length < 1) { 5748 a11yContainer = $(listItem).find('div.row[role="group"]'); 5749 } 5750 if (a11yContainer.length === 1) { 5751 let accLabel = a11yContainer.attr('aria-label'); 5752 if (!isEmpty(accLabel)) { 5753 return accLabel; 5754 } 5755 } 5756 return ''; 5757} 5758 5759/* Function that checks the page context for drag/drop functionality and if found, registers associated event handlers for keyboard navigation */ 5760function setupA11yDragDropKeyboardSupport() {
5761 // Support both ul (card view) and tbody (list view) containers 5762 if ($('ul#divAllItems').length !== 1 && $('tbody#divAllItems').length !== 1) { 5763 return; // not supported 5764 } 5765 let dragDropHandles = $('.handle'); 5766 for (var i=0; i<dragDropHandles.length; i++) { 5767 let dragDropHandle = dragDropHandles[i]; 5768 5769 // drag/drop element not currently setup for keyboard support (ex. page load, load more or filter change) 5770 if (dragDropHandle.getAttribute('data-a11y-drag-drop') !== 'true') { 5771 dragDropHandle.setAttribute('data-a11y-drag-drop', 'true'); 5772 dragDropHandle.setAttribute('role', 'application'); 5773 dragDropHandle.setAttribute('aria-label', 'Drag and drop. Press enter to activate.'); 5774 dragDropHandle.setAttribute('onkeydown', 'handleA11yKeyboardDragDrop(event);'); 5775 } 5776 5777 // add event listener on body for mouse-down (will cancel an active drag/drop operation) 5778 if ($('body.a11y-drag-drop').length < 1) { 5779 document.body.classList.add('a11y-drag-drop'); 5780 document.body.removeEventListener('mousedown', handleA11yKeyboardDragDropMouseDown); 5781 document.body.addEventListener('mousedown', handleA11yKeyboardDragDropMouseDown); 5782 } 5783 } 5784 if (dragDropHandles.length > 0) { 5785 // Empty message to make sure the announcemeent region is defined (otherwise the first message will not be read by the screen reader) 5786 a11yAnnounceToScreenReader(''); 5787 } 5788} 5789 5790/* Function that returns the DOM link element for the active drag/drop operation (the active handle) */ 5791function getA11yDragDropHandleFocusLink() { 5792 let handleFocusLink = $('[data-a11y-drag-drop-focus="true"]'); 5793 if (handleFocusLink.length === 1) { 5794 return handleFocusLink[0]; 5795 } 5796} 5797 5798/* Function that returns the DOM li or tr element container for the active drag/drop operation (li/tr container for the active handle) */ 5799function getA11yDragDropHandleFocusLinkLi(handleLink) { 5800 // Support both li (card view) and tr (list view) elements 5801 let handleClosestItem = $(handleLink).closest('li, tr'); 5802 if (handleClosestItem.length !== 1) { 5803 console.error('> DRAG/DROP handleClosestItem (li or tr) not found'); 5804 handleA11yDragDropFailure(); 5805 cancelA11yDragDrop(); 5806 return; 5807 } 5808 return handleClosestItem[0]; 5809} 5810 5811/* Function that returns true if there is an active drag/drop operation initiated by the keyboard */ 5812function isA11yDragDropActive(handleFocusLink) { 5813 if (!handleFocusLink) { 5814 handleFocusLink = getA11yDragDropHandleFocusLink(); 5815 } 5816 if (handleFocusLink && handleFocusLink.getAttribute('data-a11y-drag-drop-focus') === 'true') { 5817 return true; 5818 } 5819 return false; 5820} 5821 5822/* Function that removes state attributes or classes used during a keyboard drag/drop operation (on save or cancel) */ 5823function a11yDragDropPickup(handleLink, dragDropIndex) { 5824 // this element will retain focus during the drag/drop operation (store the original index to restore on cancel) 5825 handleLink.setAttribute('data-a11y-drag-drop-focus', 'true'); 5826 handleLink.setAttribute('data-a11y-drag-drop-original-index', dragDropIndex); 5827 5828 let totalItems = getA11yDragDropTotalItems(); 5829 let itemPosition = getA11yDragDropItemPosition(handleLink); 5830 let a11yMessage = 'Drag and drop. Pickup from position ' + itemPosition + ' of ' + totalItems + '.'; 5831 let a11yContext = getListItemA11yContext(itemPosition); 5832 if (!isEmpty(a11yContext)) { 5833 a11yMessage += ' You picked up ' + a11yContext; 5834 } 5835 a11yAnnounceToScreenReader(a11yMessage); 5836} 5837 5838/* Function that removes state attributes or classes used during a keyboard drag/drop operation (on save or cancel) */ 5839function cleanupAfterA11yDragDrop() { 5840 let handleFocusLink = getA11yDragDropHandleFocusLink(); 5841 if (handleFocusLink) { 5842 handleFocusLink.focus(); 5843 } else { 5844 console.error('> DRAG/DROP handleFocusLink not found'); 5845 handleA11yDragDropFailure(); 5846 setFocusToContent(); 5847 } 5848 $('[data-a11y-drag-drop-focus]').removeAttr('data-a11y-drag-drop-focus'); 5849 $('[data-a11y-drag-drop-original-index]').removeAttr('data-a11y-drag-drop-original-index'); 5850} 5851 5852/* Function that cancels a currently active drag/drop operation initiated by the keyboard */ 5853function cancelA11yDragDrop() { 5854 let handleFocusLink = getA11yDragDropHandleFocusLink(); 5855 if (!handleFocusLink) { 5856 cleanupAfterA11yDragDrop(); 5857 return; 5858 } 5859 let handleLi = getA11yDragDropHandleFocusLinkLi(handleFocusLink); 5860 if (!handleLi) { 5861 return; 5862 } 5863 5864 let originalIndexText = handleFocusLink.getAttribute('data-a11y-drag-drop-original-index'); 5865 if (!originalIndexText) { 5866 console.error('> DRAG/DROP originalIndexText not found'); 5867 cleanupAfterA11yDragDrop(); 5868 return; 5869 } 5870 let originalIndex = parseInt(originalIndexText); 5871 let distanceFromOriginalPosition = $(handleLi).index() - originalIndex; 5872 5873 // Return the list item to it's original position 5874 while (distanceFromOriginalPosition > 0) {
5875 handleA11yKeyboardDragDropArrowUpDown(handleFocusLink, true, false); // move up 5876 distanceFromOriginalPosition--; 5877 } 5878 while (distanceFromOriginalPosition < 0) { 5879 handleA11yKeyboardDragDropArrowUpDown(handleFocusLink, false, false); // move down 5880 distanceFromOriginalPosition++; 5881 } 5882 5883 cleanupAfterA11yDragDrop(); 5884 a11yAnnounceToScreenReader('Drag and drop. Operation cancelled.'); 5885} 5886 5887/* Function that returns the total number of items in the drag/drop list */ 5888function getA11yDragDropTotalItems() { 5889 // Support both li (card view) and tr (list view) elements 5890 return $('#divAllItems > li:not(.unsortable_item), #divAllItems > tr:not(.unsortable_item)').length; 5891} 5892/* Function that returns the position of the provided handle link within the UL or TBODY */ 5893function getA11yDragDropItemPosition(elem) { 5894 // Support both li (card view) and tr (list view) elements 5895 if (elem && elem.tagName !== 'LI' && elem.tagName !== 'TR') { 5896 elem = getA11yDragDropHandleFocusLinkLi(elem); 5897 } 5898 if (!elem) { 5899 console.error('> DRAG/DROP elem is empty'); 5900 return; 5901 } 5902 return $(elem).prevAll(':not(.unsortable_item)').length + 1; 5903} 5904 5905/* Function that saves the list order after completing the drag/drop operation by dropping the handle link */ 5906function saveA11yDragDrop(handleLink) { 5907 let handleLi = getA11yDragDropHandleFocusLinkLi(handleLink); 5908 if (!handleLi) { 5909 return; 5910 } 5911 5912 let sortableContainer = $(handleLi).parents('.ui-sortable'); 5913 if (sortableContainer.length < 1) { 5914 console.error('> DRAG/DROP sortableContainer not found'); 5915 handleA11yDragDropFailure(); 5916 cleanupAfterA11yDragDrop(); 5917 return; 5918 } 5919 5920 let totalItems = getA11yDragDropTotalItems(); 5921 let itemPosition = getA11yDragDropItemPosition(handleLi); 5922 a11yAnnounceToScreenReader('Drag and drop. Drop into position ' + itemPosition + ' of ' + totalItems + '.'); 5923 if (a11yDragDropSaveOrdering) { 5924 a11yDragDropSaveOrdering(handleLi); 5925 } 5926 cleanupAfterA11yDragDrop(); 5927} 5928 5929/* Function that cancels the drag/drop operation if the mouse is clicked */ 5930function handleA11yKeyboardDragDropMouseDown() { 5931 if (isA11yDragDropActive()) { 5932 cancelA11yDragDrop(); 5933 } 5934} 5935 5936/* Function that generically notifies the user of an error in the drag/drop operation using a screen reader announcement */ 5937function handleA11yDragDropFailure() { 5938 a11yAnnounceToScreenReader('Drag and drop. Something went wrong, please wait a few minutes and try again.'); 5939} 5940 5941/** 5942 * Handles the arrow up or down key during an active drag/drop operation to move the current item 5943 * one position in the list (if possible). 5944 * 5945 * @param {HTMLElement} handleLink - The anchor (.handle) element that triggered the keydown event. 5946 * @param {boolean} isUp - `true` if the Arrow Up key was pressed; `false` if Arrow Down. 5947 * @param {boolean} [announceToScreenReader=true] - Whether to announce the change to the screen reader (optional, defaults to `true`). 5948 */ 5949function handleA11yKeyboardDragDropArrowUpDown(handleLink, isUp, announceToScreenReader = true) { 5950 if (!(isUp === true || isUp === false)) { 5951 console.error('> DRAG/DROP isUp must be true or false'); 5952 handleA11yDragDropFailure(); 5953 return; 5954 } 5955 if (!handleLink) { 5956 console.error('> DRAG/DROP handleLink is empty'); 5957 handleA11yDragDropFailure(); 5958 return; 5959 } 5960 let handleLi = getA11yDragDropHandleFocusLinkLi(handleLink); 5961 if (!handleLi) { 5962 return; 5963 } 5964 5965 let sortableContainer = $(handleLi).parents('.ui-sortable'); 5966 if (sortableContainer.length < 1) { 5967 console.error('> DRAG/DROP sortableContainer not found'); 5968 handleA11yDragDropFailure(); 5969 return; 5970 } 5971 try { 5972 let siblingLi = isUp ? $(handleLi).prev() : $(handleLi).next(); 5973 if(siblingLi.length === 0){ 5974 a11yAnnounceToScreenReader(`Drag and drop. You are at the ${isUp ? 'top' : 'bottom'} of the list.`); 5975 return; 5976 } 5977 if(isUp){ 5978 $(handleLi).insertBefore(siblingLi); 5979 } else { 5980 $(handleLi).insertAfter(siblingLi); 5981 } 5982 sortableContainer.sortable('refresh'); 5983 5984 let handleFocusLink = getA11yDragDropHandleFocusLink(); 5985 if (!handleFocusLink) { 5986 console.error('> DRAG/DROP handleFocusLink not found'); 5987 handleA11yDragDropFailure(); 5988 return; 5989 } 5990 handleFocusLink.focus(); 5991 if (announceToScreenReader) { 5992 let itemPosition = getA11yDragDropItemPosition(handleFocusLink); 5993 let message = `Drag and drop. Move ${isUp ? 'up' : 'down'} to position ${itemPosition} of ${getA11yDragDropTotalItems()}.`; 5994 let a11yContext = getListItemA11yContext(itemPosition + (isUp ? 1 : -1)); 5995 if (!isEmpty(a11yContext)) { 5996 message += " " + (isUp ? 'Above ' : 'Below ') + a11yContext; 5997 } 5998 a11yAnnounceToScreenReader(message); 5999 } 6000 } catch(e) { 6001 console.error('> DRAG/DROP Error e.message=' + e.message); 6002 handleA11yDragDropFailure(); 6003 } 6004} 6005 6006/* Function that handles keyboard support for drag/drop functionality. Handles the following keys when in drag/drop mode: 6007 * 6008 * Enter : Pick up (from it's current location) or drop an item (at it's current location) 6009 * Space : Same as Enter 6010 * Escape : Cancel the drag/drop operation 6011 * Arrow-Up : Move the item up one position if possible (if at the top of the list do nothing) 6012 * Arrow-Down : Move the item down on position if possible (if at the bottom of the list do nothing) 6013 * 6014 * The Screen reader should announce changes during the drag/drop operation: 6015 * 6016 * Pick up : Drag and drop. You have selected [item title] in position [N of M]. 6017 * Drop : Dropping [item title] into position [N of M] 6018 * Arrow up : Moving [item title] to position [N of M] 6019 * Arrow down : Moving [item title] to position [N of M] 6020 * 6021 * Top/Bottom : You're at the [top/bottom] of the list 6022 * Escape : Drag and drop has been cancelled. 6023 * Mouse down : Drag and drop has been cancelled. 6024 * 6025 * We'll call a11yAnnounceToScreenReader('status') during the drag/drop operation as status changes 6026 * 6027 */ 6028function handleA11yKeyboardDragDrop(event) 6029{ 6030 if (!isAccKeyboardMode()) { 6031 return; // nothing to do 6032 } 6033 let elem = document.activeElement; 6034 if (isEmpty(elem)) { 6035 console.error('> DRAG/DROP Error elem is empty (no active element found)'); 6036 return; 6037 } 6038 let isElemDragDropActive = isA11yDragDropActive(elem); 6039 if (isA11yDragDropActive() && !isElemDragDropActive) { 6040 console.error('> DRAG/DROP is active but not for this element (unexpected condition)'); 6041 handleA11yDragDropFailure(); 6042 cleanupAfterA11yDragDrop(); 6043 return; 6044 } 6045 switch (event.key) { 6046 case "ArrowUp": 6047 if (isElemDragDropActive) { 6048 // move item one position up the list 6049 event.preventDefault(); 6050 event.stopPropagation();
6051 handleA11yKeyboardDragDropArrowUpDown(elem, true); 6052 } 6053 break; 6054 case "ArrowDown": 6055 if (isElemDragDropActive) { 6056 // move item one position down the list 6057 event.preventDefault(); 6058 event.stopPropagation(); 6059 handleA11yKeyboardDragDropArrowUpDown(elem, false); 6060 } 6061 break; 6062 case "Enter": 6063 case "Space": 6064 event.preventDefault(); 6065 event.stopPropagation(); 6066 if (!isElemDragDropActive) { 6067 // Pick-up from current position 6068 let handleLi = getA11yDragDropHandleFocusLinkLi(elem); 6069 if (!handleLi) { 6070 return; 6071 } 6072 a11yDragDropPickup(elem, $(handleLi).index()); 6073 } else { 6074 // Drop item (into new location at current position) 6075 saveA11yDragDrop(elem); 6076 } 6077 break; 6078 case "Escape": 6079 // Cancel the drag/drop operation 6080 if (isElemDragDropActive) { 6081 event.preventDefault(); 6082 event.stopPropagation(); 6083 cancelA11yDragDrop(); 6084 } 6085 break; 6086 case "Tab": 6087 if (isElemDragDropActive) { 6088 if (event.ctrlKey || event.altKey || event.metaKey) { 6089 // do nothing (we only track tab/shift-tab here) 6090 return; 6091 } 6092 event.preventDefault(); 6093 event.stopPropagation(); 6094 a11yAnnounceToScreenReader('Drag and drop. Press escape or click the mouse to cancel the current drag and drop operation.'); 6095 } 6096 break; 6097 } 6098} 6099 6100/*-----------------------------------------------------*\ 6101 @TUI Image Editor 6102\*-----------------------------------------------------*/ 6103 6104function tuiEditorKeyboardClick(event, element) { 6105 if (event.key === 'Enter') { 6106 element.click(); 6107 } 6108} 6109 6110function setupTuiImageEditor() { 6111 const tuiMainContainer = document.querySelector('div.tui-image-editor-main-container'); 6112 if (tuiMainContainer) { 6113 tuiMainContainer.setAttribute('role', 'group'); 6114 tuiMainContainer.setAttribute('aria-label', 'T U I Image editor'); 6115 6116 let tuiImageEditorMenuItems = $('ul.tui-image-editor-menu li') 6117 for (var i=0; i<tuiImageEditorMenuItems.length; i++) { 6118 let tuiImageEditorMenuItem = tuiImageEditorMenuItems[i]; 6119 tuiImageEditorMenuItem.setAttribute('aria-label', tuiImageEditorMenuItem.getAttribute('title')); 6120 tuiImageEditorMenuItem.setAttribute('tabindex', "0"); 6121 tuiImageEditorMenuItem.setAttribute('onkeyup', "tuiEditorKeyboardClick(event,this);"); 6122 } 6123 6124 let firstHeaderButton = document.querySelector('div.tui-image-editor-header-buttons button'); 6125 if (firstHeaderButton) { 6126 firstHeaderButton.focus(); 6127 } 6128 } 6129} 6130 6131/*-----------------------------------------------------*\ 6132 @END 6133\*-----------------------------------------------------*/
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.