PageSourceSearch

https://mason360.gmu.edu/js/accessibility.js?v=20251112.1

js gmu.edu collected 2026-09-24 07:03:19 UTC 280,504 bytes, 6,133 lines download raw bytes

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, '&#171;', '<');
3089        htmlContent = replaceAll(htmlContent, '&#187;', '>');
3090        htmlContent = replaceAll(htmlContent, '&#60;', '<');
3091        htmlContent = replaceAll(htmlContent, '&#62;', '>');
3092
3093        htmlContent = decodeHtmlCodes(htmlContent);
3094
3095        // convert non-breaking space to regular space (the decoded nbsp is ascii 160)
3096        htmlContent = replaceAll(htmlContent, '&para;', ' ');
3097        htmlContent = replaceAll(htmlContent, '&nbsp;', ' ');
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, '&amp;', '&');
3135        htmlContent = replaceAll(htmlContent, '&apos;', "’");
3136        htmlContent = replaceAll(htmlContent, '&copy;', ' copyright ');
3137        htmlContent = replaceAll(htmlContent, '&deg;', ' degrees ');
3138        htmlContent = replaceAll(htmlContent, '&divide;', ' divided by ');
3139        htmlContent = replaceAll(htmlContent, '&euro;', ' euros ');
3140        htmlContent = replaceAll(htmlContent, '&gt;', '>');
3141        htmlContent = replaceAll(htmlContent, '&laquo;', '<');
3142        htmlContent = replaceAll(htmlContent, '&lt;', '<');
3143        htmlContent = replaceAll(htmlContent, '&middot;', '-');                     // minus sign
3144        htmlContent = replaceAll(htmlContent, '&ndash;', '-');
3145        htmlContent = replaceAll(htmlContent, '&quot;', '"');                       // double-quote
3146        htmlContent = replaceAll(htmlContent, '&raquo;', '>');
3147        htmlContent = replaceAll(htmlContent, '&reg;', ' registered trade mark ');
3148        htmlContent = replaceAll(htmlContent, '&times;', ' x ');                    // letter x
3149        htmlContent = replaceAll(htmlContent, '&uml;', '"');                        // 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 ');   // &copy;
3209        safeAttributeText = replaceAll(safeAttributeText, String.fromCharCode(183), '-');             // &middot;
3210        safeAttributeText = replaceAll(safeAttributeText, String.fromCharCode(215), ' times ');           // &times;
3211        safeAttributeText = replaceAll(safeAttributeText, String.fromCharCode(247), ' divided by');   // &divide;
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 ');                              // &amp; ampersand
3227        safeAttributeText = replaceAll(safeAttributeText, '<', ' less than ');                        // &lt;
3228        safeAttributeText = replaceAll(safeAttributeText, '>', ' greater than ');                     // &gt;
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('<', '&lt;');
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.