diff --git a/content/shared/js/skipto.js b/content/shared/js/skipto.js index a967121166..900da009e9 100644 --- a/content/shared/js/skipto.js +++ b/content/shared/js/skipto.js @@ -1,1250 +1,2346 @@ -/*! skipto - v4.2.0 - 2022-06-16 - * https://github.com/paypal/skipto - * Copyright (c) 2022 Jon Gunderson; Licensed BSD - * Copyright (c) 2021 PayPal Accessibility Team and University of Illinois; Licensed BSD */ -/*@cc_on @*/ -/*@if (@_jscript_version >= es6) @*/ /* ======================================================================== - * Copyright (c) <2022> (ver 4.2) Jon Gunderson - * Copyright (c) <2021> PayPal and University of Illinois + * Version: 5.1.3 + * Copyright (c) 2022, 2023 Jon Gunderson; Licensed BSD + * Copyright (c) 2021 PayPal Accessibility Team and University of Illinois; Licensed BSD * All rights reserved. * Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. - * Neither the name of PayPal or any of its subsidiaries or affiliates, nor the name of the University of Illinois, nor the names of any other contributors contributors may be used to endorse or promote products derived from this software without specific prior written permission. + * Neither the name of PayPal or any of its subsidiaries or affiliates, nor the name of the University of Illinois, nor the names of any other contributors may be used to endorse or promote products derived from this software without specific prior written permission. * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * + * Documentation: https://skipto-landmarks-headings.github.io/page-script-5 + * Code: https://github.com/skipto-landmarks-headings/page-script-5 + * Report Issues: https://github.com/skipto-landmarks-headings/page-script-5/issues * ======================================================================== */ (function () { 'use strict'; - const SkipTo = { - skipToId: 'id-skip-to-js-50', - skipToMenuId: 'id-skip-to-menu-50', - domNode: null, - buttonNode: null, - menuNode: null, - menuitemNodes: [], - firstMenuitem: false, - lastMenuitem: false, - firstChars: [], - headingLevels: [], - skipToIdIndex: 1, - showAllLandmarksSelector: - 'main, [role=main], [role=search], nav, [role=navigation], section[aria-label], section[aria-labelledby], section[title], [role=region][aria-label], [role=region][aria-labelledby], [role=region][title], form[aria-label], form[aria-labelledby], aside, [role=complementary], body > header, [role=banner], body > footer, [role=contentinfo]', - showAllHeadingsSelector: 'h1, h2, h3, h4, h5, h6', - // Default configuration values - config: { - // Feature switches - enableActions: false, - enableMofN: true, - enableHeadingLevelShortcuts: true, - - // Customization of button and menu - altShortcut: '0', // default shortcut key is the number zero - optionShortcut: 'º', // default shortcut key character associated with option+0 on mac - attachElement: 'body', - displayOption: 'static', // options: static (default), popup - // container element, use containerClass for custom styling - containerElement: 'div', - containerRole: '', - customClass: '', - - // Button labels and messages - buttonLabel: 'Skip To Content', - altLabel: 'Alt', - optionLabel: 'Option', - buttonShortcut: ' ($modifier+$key)', - altButtonAriaLabel: 'Skip To Content, shortcut Alt plus $key', - optionButtonAriaLabel: 'Skip To Content, shortcut Option plus $key', - - // Menu labels and messages - menuLabel: 'Landmarks and Headings', - landmarkGroupLabel: 'Landmarks', - headingGroupLabel: 'Headings', - mofnGroupLabel: ' ($m of $n)', - headingLevelLabel: 'Heading level', - mainLabel: 'main', - searchLabel: 'search', - navLabel: 'navigation', - regionLabel: 'region', - asideLabel: 'complementary', - footerLabel: 'contentinfo', - headerLabel: 'banner', - formLabel: 'form', - msgNoLandmarksFound: 'No landmarks found', - msgNoHeadingsFound: 'No headings found', - - // Action labels and messages - actionGroupLabel: 'Actions', - actionShowHeadingsHelp: - 'Toggles between showing "All" and "Selected" Headings.', - actionShowSelectedHeadingsLabel: 'Show Selected Headings ($num)', - actionShowAllHeadingsLabel: 'Show All Headings ($num)', - actionShowLandmarksHelp: - 'Toggles between showing "All" and "Selected" Landmarks.', - actionShowSelectedLandmarksLabel: 'Show Selected Landmarks ($num)', - actionShowAllLandmarksLabel: 'Show All Landmarks ($num)', - - actionShowSelectedHeadingsAriaLabel: 'Show $num selected headings', - actionShowAllHeadingsAriaLabel: 'Show all $num headings', - actionShowSelectedLandmarksAriaLabel: 'Show $num selected landmarks', - actionShowAllLandmarksAriaLabel: 'Show all $num landmarks', - - // Selectors for landmark and headings sections - landmarks: - 'main, [role="main"], [role="search"], nav, [role="navigation"], aside, [role="complementary"]', - headings: 'main h1, [role="main"] h1, main h2, [role="main"] h2', - - // Custom CSS position and colors - colorTheme: '', - fontFamily: '', - fontSize: '', - positionLeft: '', - menuTextColor: '', - menuBackgroundColor: '', - menuitemFocusTextColor: '', - menuitemFocusBackgroundColor: '', - focusBorderColor: '', - buttonTextColor: '', - buttonBackgroundColor: '', - }, - colorThemes: { - default: { - fontFamily: - 'Noto Sans, Trebuchet MS, Helvetica Neue, Arial, sans-serif', - fontSize: '14px', - positionLeft: 'unset', - menuTextColor: '#000', - menuBackgroundColor: '#def', - menuitemFocusTextColor: '#fff', - menuitemFocusBackgroundColor: '#005a9c', - focusBorderColor: '#005a9c', - buttonTextColor: '#005a9c', - buttonBackgroundColor: '#ddd', - }, - }, - defaultCSS: - '.skip-to.popup{position:absolute;top:-30em;left:0}.skip-to,.skip-to.popup.focus{position:absolute;top:0;left:$positionLeft;font-family:$fontFamily;font-size:$fontSize}.skip-to.fixed{position:fixed}.skip-to button{position:relative;margin:0;padding:6px 8px 6px 8px;border-width:0 1px 1px 1px;border-style:solid;border-radius:0 0 6px 6px;border-color:$buttonBackgroundColor;color:$menuTextColor;background-color:$buttonBackgroundColor;z-index:100000!important;font-family:$fontFamily;font-size:$fontSize}.skip-to [role=menu]{position:absolute;min-width:17em;display:none;margin:0;padding:.25rem;background-color:$menuBackgroundColor;border-width:2px;border-style:solid;border-color:$focusBorderColor;border-radius:5px;z-index:100000!important;overflow-x:hidden}.skip-to [role=group]{display:grid;grid-auto-rows:min-content;grid-row-gap:1px}.skip-to [role=separator]:first-child{border-radius:5px 5px 0 0}.skip-to [role=menuitem]{padding:3px;width:auto;border-width:0;border-style:solid;color:$menuTextColor;background-color:$menuBackgroundColor;z-index:100000!important;display:grid;overflow-y:clip;grid-template-columns:repeat(6,1.2rem) 1fr;grid-column-gap:2px;font-size:1em}.skip-to [role=menuitem] .label,.skip-to [role=menuitem] .level{font-size:100%;font-weight:400;color:$menuTextColor;display:inline-block;background-color:$menuBackgroundColor;line-height:inherit;display:inline-block}.skip-to [role=menuitem] .level{text-align:right;padding-right:4px}.skip-to [role=menuitem] .label{text-align:left;margin:0;padding:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.skip-to [role=menuitem] .label:first-letter,.skip-to [role=menuitem] .level:first-letter{text-decoration:underline;text-transform:uppercase}.skip-to [role=menuitem].skip-to-h1 .level{grid-column:1}.skip-to [role=menuitem].skip-to-h2 .level{grid-column:2}.skip-to [role=menuitem].skip-to-h3 .level{grid-column:3}.skip-to [role=menuitem].skip-to-h4 .level{grid-column:4}.skip-to [role=menuitem].skip-to-h5 .level{grid-column:5}.skip-to [role=menuitem].skip-to-h6 .level{grid-column:8}.skip-to [role=menuitem].skip-to-h1 .label{grid-column:2/8}.skip-to [role=menuitem].skip-to-h2 .label{grid-column:3/8}.skip-to [role=menuitem].skip-to-h3 .label{grid-column:4/8}.skip-to [role=menuitem].skip-to-h4 .label{grid-column:5/8}.skip-to [role=menuitem].skip-to-h5 .label{grid-column:6/8}.skip-to [role=menuitem].skip-to-h6 .label{grid-column:7/8}.skip-to [role=menuitem].skip-to-h1.no-level .label{grid-column:1/8}.skip-to [role=menuitem].skip-to-h2.no-level .label{grid-column:2/8}.skip-to [role=menuitem].skip-to-h3.no-level .label{grid-column:3/8}.skip-to [role=menuitem].skip-to-h4.no-level .label{grid-column:4/8}.skip-to [role=menuitem].skip-to-h5.no-level .label{grid-column:5/8}.skip-to [role=menuitem].skip-to-h6.no-level .label{grid-column:6/8}.skip-to [role=menuitem].skip-to-nesting-level-1 .nesting{grid-column:1}.skip-to [role=menuitem].skip-to-nesting-level-2 .nesting{grid-column:2}.skip-to [role=menuitem].skip-to-nesting-level-3 .nesting{grid-column:3}.skip-to [role=menuitem].skip-to-nesting-level-0 .label{grid-column:1/8}.skip-to [role=menuitem].skip-to-nesting-level-1 .label{grid-column:2/8}.skip-to [role=menuitem].skip-to-nesting-level-2 .label{grid-column:3/8}.skip-to [role=menuitem].skip-to-nesting-level-3 .label{grid-column:4/8}.skip-to [role=menuitem].action .label,.skip-to [role=menuitem].no-items .label{grid-column:1/8}.skip-to [role=separator]{margin:1px 0 1px 0;padding:3px;display:block;width:auto;font-weight:700;border-bottom-width:1px;border-bottom-style:solid;border-bottom-color:$menuTextColor;background-color:$menuBackgroundColor;color:$menuTextColor;z-index:100000!important}.skip-to [role=separator] .mofn{font-weight:400;font-size:85%}.skip-to [role=separator]:first-child{border-radius:5px 5px 0 0}.skip-to [role=menuitem].last{border-radius:0 0 5px 5px}.skip-to.focus{display:block}.skip-to button:focus,.skip-to button:hover{background-color:$menuBackgroundColor;color:$menuTextColor;outline:0}.skip-to button:focus{padding:6px 7px 5px 7px;border-width:0 2px 2px 2px;border-color:$focusBorderColor}.skip-to [role=menuitem]:focus{padding:1px;border-width:2px;border-style:solid;border-color:$focusBorderColor;background-color:$menuitemFocusBackgroundColor;color:$menuitemFocusTextColor;outline:0}.skip-to [role=menuitem]:focus .label,.skip-to [role=menuitem]:focus .level{background-color:$menuitemFocusBackgroundColor;color:$menuitemFocusTextColor}', - - // - // Functions related to configuring the features - // of skipTo - // - isNotEmptyString: function (str) { - return ( - typeof str === 'string' && str.length && str.trim() && str !== ' ' - ); - }, - isEmptyString: function (str) { - return typeof str !== 'string' || (str.length === 0 && !str.trim()); - }, - init: function (config) { - let node; - let buttonVisibleLabel; - let buttonAriaLabel; - - // Check if skipto is already loaded - - if (document.querySelector('style#' + this.skipToId)) { - return; - } - let attachElement = document.body; - if (config) { - this.setUpConfig(config); - } - if (typeof this.config.attachElement === 'string') { - node = document.querySelector(this.config.attachElement); - if (node && node.nodeType === Node.ELEMENT_NODE) { - attachElement = node; - } - } - this.addCSSColors(); - this.renderStyleElement(this.defaultCSS); - var elem = this.config.containerElement.toLowerCase().trim(); - if (!this.isNotEmptyString(elem)) { - elem = 'div'; - } - this.domNode = document.createElement(elem); - this.domNode.classList.add('skip-to'); - if (this.isNotEmptyString(this.config.customClass)) { - this.domNode.classList.add(this.config.customClass); - } - if (this.isNotEmptyString(this.config.containerRole)) { - this.domNode.setAttribute('role', this.config.containerRole); - } - var displayOption = this.config.displayOption; - if (typeof displayOption === 'string') { - displayOption = displayOption.trim().toLowerCase(); - if (displayOption.length) { - switch (this.config.displayOption) { - case 'fixed': - this.domNode.classList.add('fixed'); - break; - case 'onfocus': // Legacy option - case 'popup': - this.domNode.classList.add('popup'); + /* + * debug.js + * + * Usage + * import DebugLogging from './debug.js'; + * const debug = new DebugLogging('myLabel', true); // e.g. 'myModule' + * ... + * if (debug.flag) debug.log('myMessage'); + * + * Notes + * new DebugLogging() - calling the constructor with no arguments results + * in debug.flag set to false and debug.label set to 'debug'; + * constructor accepts 0, 1 or 2 arguments in any order + * @param flag [optional] {boolean} - sets debug.flag + * @param label [optional] {string} - sets debug.label + * Properties + * debug.flag {boolean} allows you to switch debug logging on or off; + * default value is false + * debug.label {string} rendered as a prefix to each log message; + * default value is 'debug' + * Methods + * debug.log calls console.log with label prefix and message + * @param message {object} - console.log calls toString() + * @param spaceAbove [optional] {boolean} + * + * debug.tag outputs tagName and textContent of DOM element + * @param node {DOM node reference} - usually an HTMLElement + * @param spaceAbove [optional] {boolean} + * + * debug.separator outputs only debug.label and a series of hyphens + * @param spaceAbove [optional] {boolean} + */ + + class DebugLogging { + constructor (...args) { + // Default values for cases where fewer than two arguments are provided + this._flag = false; + this._label = 'debug'; + + // The constructor may be called with zero, one or two arguments. If two + // arguments, they can be in any order: one is assumed to be the boolean + // value for '_flag' and the other one the string value for '_label'. + for (const [index, arg] of args.entries()) { + if (index < 2) { + switch (typeof arg) { + case 'boolean': + this._flag = arg; break; - default: + case 'string': + this._label = arg; break; } } } - - // Place skip to at the beginning of the document - if (attachElement.firstElementChild) { - attachElement.insertBefore( - this.domNode, - attachElement.firstElementChild - ); - } else { - attachElement.appendChild(this.domNode); + } + + get flag () { return this._flag; } + + set flag (value) { + if (typeof value === 'boolean') { + this._flag = value; + } + } + + get label () { return this._label; } + + set label (value) { + if (typeof value === 'string') { + this._label = value; + } + } + + log (message, spaceAbove) { + const newline = spaceAbove ? '\n' : ''; + console.log(`${newline}[${this._label}] ${message}`); + } + + tag (node, spaceAbove) { + if (node && node.tagName) { + const text = node.textContent.trim().replace(/\s+/g, ' '); + this.log(`[${node.tagName}]: ${text.substring(0, 40)}`, spaceAbove); + } + } + + separator (spaceAbove) { + this.log('-----------------------------', spaceAbove); + } + + } + + /* style.js */ + + /* Constants */ + const debug$5 = new DebugLogging('style', false); + debug$5.flag = false; + + const styleTemplate = document.createElement('template'); + styleTemplate.innerHTML = ` + +`; + + /* + * @function getTheme + * + * @desc Returns + * + * @param {Object} colorThemes - Javascript object with keyed color themes + * @param {String} colorTheme - A string identifying a color theme + * + * @returns {Object} see @desc + */ + function getTheme(colorThemes, colorTheme) { + if (typeof colorThemes[colorTheme] === 'object') { + return colorThemes[colorTheme]; + } + // if no theme defined, use urlSelectors + let hostnameMatch = ''; + let pathnameMatch = ''; + let hostandpathnameMatch = ''; + + const locationURL = new URL(location.href); + const hostname = locationURL.hostname; + const pathname = location.pathname; + + for (let item in colorThemes) { + const hostnameSelector = colorThemes[item].hostnameSelector; + const pathnameSelector = colorThemes[item].pathnameSelector; + let hostnameFlag = false; + let pathnameFlag = false; + + + if (hostnameSelector) { + if (hostname.indexOf(hostnameSelector) >= 0) { + if (!hostnameMatch || + (colorThemes[hostnameMatch].hostnameSelector.length < hostnameSelector.length)) { + hostnameMatch = item; + hostnameFlag = true; + pathnameMatch = ''; + } + else { + // if the same hostname is used in another theme, set the hostnameFlas in case the pathname + // matches + if (colorThemes[hostnameMatch].hostnameSelector.length === hostnameSelector.length) { + hostnameFlag = true; + } + } + } } - // Menu button - [buttonVisibleLabel, buttonAriaLabel] = this.getBrowserSpecificShortcut(); - - this.buttonNode = document.createElement('button'); - this.buttonNode.textContent = buttonVisibleLabel; - this.buttonNode.setAttribute('aria-label', buttonAriaLabel); - this.buttonNode.setAttribute('aria-haspopup', 'true'); - this.buttonNode.setAttribute('aria-expanded', 'false'); - this.buttonNode.setAttribute('aria-controls', this.skipToMenuId); - - this.buttonNode.addEventListener( - 'keydown', - this.handleButtonKeydown.bind(this) - ); - this.buttonNode.addEventListener( - 'click', - this.handleButtonClick.bind(this) - ); - - this.domNode.appendChild(this.buttonNode); - - this.menuNode = document.createElement('div'); - this.menuNode.setAttribute('role', 'menu'); - this.menuNode.setAttribute('aria-busy', 'true'); - this.menuNode.setAttribute('id', this.skipToMenuId); - - this.domNode.appendChild(this.menuNode); - this.domNode.addEventListener('focusin', this.handleFocusin.bind(this)); - this.domNode.addEventListener('focusout', this.handleFocusout.bind(this)); - window.addEventListener( - 'pointerdown', - this.handleBackgroundPointerdown.bind(this), - true - ); - - if (this.usesAltKey || this.usesOptionKey) { - document.addEventListener( - 'keydown', - this.handleDocumentKeydown.bind(this) - ); + if (pathnameSelector) { + if (pathname.indexOf(pathnameSelector) >= 0) { + if (!pathnameMatch || + (colorThemes[pathnameMatch].pathnameSelector.length < pathnameSelector.length)) { + pathnameMatch = item; + pathnameFlag = true; + } + } } - }, - updateStyle: function (stylePlaceholder, value, defaultValue) { - if (typeof value !== 'string' || value.length === 0) { - value = defaultValue; + if (hostnameFlag && pathnameFlag) { + hostandpathnameMatch = item; } - let index1 = this.defaultCSS.indexOf(stylePlaceholder); - let index2 = index1 + stylePlaceholder.length; - while (index1 >= 0 && index2 < this.defaultCSS.length) { - this.defaultCSS = - this.defaultCSS.substring(0, index1) + - value + - this.defaultCSS.substring(index2); - index1 = this.defaultCSS.indexOf(stylePlaceholder, index2); - index2 = index1 + stylePlaceholder.length; + } + + if (hostandpathnameMatch) { + return colorThemes[hostandpathnameMatch]; + } + else { + if (hostnameMatch) { + return colorThemes[hostnameMatch]; + } else { + if (pathnameMatch) { + return colorThemes[pathnameMatch]; + } } - }, - addCSSColors: function () { - let theme = this.colorThemes['default']; - if (typeof this.colorThemes[this.config.colorTheme] === 'object') { - theme = this.colorThemes[this.config.colorTheme]; + } + + // if no other theme is found use default theme + return colorThemes['default']; + } + + /* + * @function updateStyle + * + * @desc + * + * @param + * + * @returns + */ + function updateStyle(stylePlaceholder, configValue, themeValue, defaultValue) { + let value = defaultValue; + if (typeof configValue === 'string' && configValue) { + value = configValue; + } else { + if (typeof themeValue === 'string' && themeValue) { + value = themeValue; + } + } + + let cssContent = styleTemplate.innerHTML; + let index1 = cssContent.indexOf(stylePlaceholder); + let index2 = index1 + stylePlaceholder.length; + while (index1 >= 0 && index2 < cssContent.length) { + cssContent = cssContent.substring(0, index1) + value + cssContent.substring(index2); + index1 = cssContent.indexOf(stylePlaceholder, index2); + index2 = index1 + stylePlaceholder.length; + } + styleTemplate.innerHTML = cssContent; + } + + /* + * @function addCSSColors + * + * @desc Updates the styling information in the attached + * stylesheet to use the configured or default colors + * + * @param {Object} colorThemes - Object with theme information + * @param {Object} config - Configuration information object + */ + function addCSSColors (colorThemes, config) { + const theme = getTheme(colorThemes, config.colorTheme); + const defaultTheme = getTheme(colorThemes, 'default'); + + // Check for display option in theme + if ((typeof theme.displayOption === 'string') && + ('fixed popup static'.indexOf(theme.displayOption.toLowerCase())>= 0)) { + config.displayOption = theme.displayOption; + } + + updateStyle('$fontFamily', config.fontFamily, theme.fontFamily, defaultTheme.fontFamily); + updateStyle('$fontSize', config.fontSize, theme.fontSize, defaultTheme.fontSize); + + updateStyle('$positionLeft', config.positionLeft, theme.positionLeft, defaultTheme.positionLeft); + updateStyle('$mediaBreakPoint', config.mediaBreakPoint, theme.mediaBreakPoint, defaultTheme.mediaBreakPoint); + + updateStyle('$menuTextColor', config.menuTextColor, theme.menuTextColor, defaultTheme.menuTextColor); + updateStyle('$menuBackgroundColor', config.menuBackgroundColor, theme.menuBackgroundColor, defaultTheme.menuBackgroundColor); + + updateStyle('$menuitemFocusTextColor', config.menuitemFocusTextColor, theme.menuitemFocusTextColor, defaultTheme.menuitemFocusTextColor); + updateStyle('$menuitemFocusBackgroundColor', config.menuitemFocusBackgroundColor, theme.menuitemFocusBackgroundColor, defaultTheme.menuitemFocusBackgroundColor); + + updateStyle('$focusBorderColor', config.focusBorderColor, theme.focusBorderColor, defaultTheme.focusBorderColor); + + updateStyle('$buttonTextColor', config.buttonTextColor, theme.buttonTextColor, defaultTheme.buttonTextColor); + updateStyle('$buttonBackgroundColor', config.buttonBackgroundColor, theme.buttonBackgroundColor, defaultTheme.buttonBackgroundColor); + + updateStyle('$zIndex', config.zIndex, theme.zIndex, defaultTheme.zIndex); + + } + + /* + * @function enderStyleElement + * + * @desc Updates the style sheet template and then attaches it to the document + * + * @param {Object} colorThemes - Object with theme information + * @param {Object} config - Configuration information object + * @param {String} skipYToStyleId - Id used for the skipto container element + */ + function renderStyleElement (colorThemes, config, skipToId) { + styleTemplate.innerHTML = styleTemplate.innerHTML.replaceAll('$skipToId', '#' + skipToId); + addCSSColors(colorThemes, config); + const styleNode = styleTemplate.content.cloneNode(true); + const headNode = document.getElementsByTagName('head')[0]; + headNode.appendChild(styleNode); + } + + /* utils.js */ + + /* Constants */ + const debug$4 = new DebugLogging('Utils', false); + debug$4.flag = false; + + + /* + * @function getAttributeValue + * + * @desc Return attribute value if present on element, + * otherwise return empty string. + * + * @returns {String} see @desc + */ + function getAttributeValue (element, attribute) { + let value = element.getAttribute(attribute); + return (value === null) ? '' : normalize(value); + } + + /* + * @function normalize + * + * @desc Trim leading and trailing whitespace and condense all + * internal sequences of whitespace to a single space. Adapted from + * Mozilla documentation on String.prototype.trim polyfill. Handles + * BOM and NBSP characters. + * + * @return {String} see @desc + */ + function normalize (s) { + let rtrim = /^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g; + return s.replace(rtrim, '').replace(/\s+/g, ' '); + } + + /** + * @fuction isNotEmptyString + * + * @desc Returns true if the string has content, otherwise false + * + * @param {Boolean} see @desc + */ + function isNotEmptyString (str) { + return (typeof str === 'string') && str.length && str.trim() && str !== " "; + } + + /** + * @fuction isVisible + * + * @desc Returns true if the element is visible in the graphical rendering + * + * @param {node} elem - DOM element node of a labelable element + */ + function isVisible (element) { + + function isDisplayNone(el) { + if (!el || (el.nodeType !== Node.ELEMENT_NODE)) { + return false; + } + + if (el.hasAttribute('hidden')) { + return true; + } + + const style = window.getComputedStyle(el, null); + const display = style.getPropertyValue("display"); + if (display === 'none') { + return true; + } + + // check ancestors for display none + if (el.parentNode) { + return isDisplayNone(el.parentNode); + } + + return false; + } + + const computedStyle = window.getComputedStyle(element); + let visibility = computedStyle.getPropertyValue('visibility'); + if ((visibility === 'hidden') || (visibility === 'collapse')) { + return false; + } + + return !isDisplayNone(element); + } + + /* + * namefrom.js + */ + + /* constants */ + + const debug$3 = new DebugLogging('nameFrom', false); + debug$3.flag = false; + + // + // LOW-LEVEL HELPER FUNCTIONS (NOT EXPORTED) + + /* + * @function isDisplayNone + * + * @desc Returns true if the element or parent element has set the CSS + * display property to none or has the hidden attribute, + * otherwise false + * + * @param {Object} node - a DOM node + * + * @returns {Boolean} see @desc + */ + + function isDisplayNone (node) { + + if (!node) { + return false; + } + + if (node.nodeType === Node.TEXT_NODE) { + node = node.parentNode; + } + + if (node.nodeType === Node.ELEMENT_NODE) { + + if (node.hasAttribute('hidden')) { + return true; + } + + // aria-hidden attribute with the value "true" is an same as + // setting the hidden attribute for name calcuation + if (node.hasAttribute('aria-hidden')) { + if (node.getAttribute('aria-hidden').toLowerCase() === 'true') { + return true; + } } - this.updateStyle('$fontFamily', this.config.fontFamily, theme.fontFamily); - this.updateStyle('$fontSize', this.config.fontSize, theme.fontSize); - - this.updateStyle( - '$positionLeft', - this.config.positionLeft, - theme.positionLeft - ); - - this.updateStyle( - '$menuTextColor', - this.config.menuTextColor, - theme.menuTextColor - ); - this.updateStyle( - '$menuBackgroundColor', - this.config.menuBackgroundColor, - theme.menuBackgroundColor - ); - - this.updateStyle( - '$menuitemFocusTextColor', - this.config.menuitemFocusTextColor, - theme.menuitemFocusTextColor - ); - this.updateStyle( - '$menuitemFocusBackgroundColor', - this.config.menuitemFocusBackgroundColor, - theme.menuitemFocusBackgroundColor - ); - - this.updateStyle( - '$focusBorderColor', - this.config.focusBorderColor, - theme.focusBorderColor - ); - - this.updateStyle( - '$buttonTextColor', - this.config.buttonTextColor, - theme.buttonTextColor - ); - this.updateStyle( - '$buttonBackgroundColor', - this.config.buttonBackgroundColor, - theme.buttonBackgroundColor - ); - }, - - getBrowserSpecificShortcut: function () { - const platform = navigator.platform.toLowerCase(); - const userAgent = navigator.userAgent.toLowerCase(); - - const hasWin = platform.indexOf('win') >= 0; - const hasMac = platform.indexOf('mac') >= 0; - const hasLinux = - platform.indexOf('linux') >= 0 || platform.indexOf('bsd') >= 0; - const hasAndroid = userAgent.indexOf('android') >= 0; - - this.usesAltKey = hasWin || (hasLinux && !hasAndroid); - this.usesOptionKey = hasMac; - - let label = this.config.buttonLabel; - let ariaLabel = this.config.buttonLabel; - let buttonShortcut; - - // Check to make sure a shortcut key is defined - if (this.config.altShortcut && this.config.optionShortcut) { - if (this.usesAltKey || this.usesOptionKey) { - buttonShortcut = this.config.buttonShortcut.replace( - '$key', - this.config.altShortcut - ); + + const style = window.getComputedStyle(node, null); + + const display = style.getPropertyValue("display"); + + if (display) { + return display === 'none'; + } + } + return false; + } + + /* + * @function isVisibilityHidden + * + * @desc Returns true if the node (or it's parrent) has the CSS visibility + * property set to "hidden" or "collapse", otherwise false + * + * @param {Object} node - DOM node + * + * @return see @desc + */ + + function isVisibilityHidden(node) { + + if (!node) { + return false; + } + + if (node.nodeType === Node.TEXT_NODE) { + node = node.parentNode; + } + + if (node.nodeType === Node.ELEMENT_NODE) { + const style = window.getComputedStyle(node, null); + + const visibility = style.getPropertyValue("visibility"); + if (visibility) { + return (visibility === 'hidden') || (visibility === 'collapse'); + } + } + return false; + } + + /* + * @function isAriaHiddenFalse + * + * @desc Returns true if the node has the aria-hidden property set to + * "false", otherwise false. + * NOTE: This function is important in the accessible namce + * calculation, since content hidden with a CSS technique + * can be included in the accessible name calculation when + * aria-hidden is set to false + * + * @param {Object} node - DOM node + * + * @return see @desc + */ + + function isAriaHIddenFalse(node) { + + if (!node) { + return false; + } + + if (node.nodeType === Node.TEXT_NODE) { + node = node.parentNode; + } + + if (node.nodeType === Node.ELEMENT_NODE) { + return (node.hasAttribute('aria-hidden') && + (node.getAttribute('aria-hidden').toLowerCase() === 'false')); + } + + return false; + } + + /* + * @function includeContentInName + * + * @desc Checks the CSS display and hidden properties, and + * the aria-hidden property to see if the content + * should be included in the accessible name + * calculation. Returns true if it should be + * included, otherwise false + * + * @param {Object} node - DOM node + * + * @return see @desc + */ + + function includeContentInName(node) { + const flag = isAriaHIddenFalse(node) || + (!isVisibilityHidden(node) && + !isDisplayNone(node)); + return flag; + } + + /* + * @function getNodeContents + * + * @desc Recursively process element and text nodes by aggregating + * their text values for an ARIA accessible name or description + * calculation. + * + * NOTE: This includes special handling of elements with 'alt' + * text and embedded controls. + * + * @param {Object} node - A DOM node + * + * @return {String} The text content for an accessible name or description + */ + function getNodeContents (node) { + let contents = ''; + let nc; + let arr = []; + + switch (node.nodeType) { + case Node.ELEMENT_NODE: + // If aria-label is present, node recursion stops and + // aria-label value is returned + if (node.hasAttribute('aria-label')) { + if (includeContentInName(node)) { + contents = node.getAttribute('aria-label'); + } } - if (this.usesAltKey) { - buttonShortcut = buttonShortcut.replace( - '$modifier', - this.config.altLabel - ); - label = label + buttonShortcut; - ariaLabel = this.config.altButtonAriaLabel.replace( - '$key', - this.config.altShortcut - ); + else { + if (node instanceof HTMLSlotElement) { + // if no slotted elements, check for default slotted content + const assignedNodes = node.assignedNodes().length ? node.assignedNodes() : node.assignedNodes({ flatten: true }); + assignedNodes.forEach( assignedNode => { + nc = getNodeContents(assignedNode); + if (nc.length) arr.push(nc); + }); + contents = (arr.length) ? arr.join(' ') : ''; + } else { + if (couldHaveAltText(node) && includeContentInName(node)) { + contents = getAttributeValue(node, 'alt'); + } + else { + if (node.hasChildNodes()) { + let children = Array.from(node.childNodes); + children.forEach( child => { + nc = getNodeContents(child); + if (nc.length) arr.push(nc); + }); + contents = (arr.length) ? arr.join(' ') : ''; + } + } + // For all branches of the ELEMENT_NODE case... + } } + contents = addCssGeneratedContent(node, contents); + break; - if (this.usesOptionKey) { - buttonShortcut = buttonShortcut.replace( - '$modifier', - this.config.optionLabel - ); - label = label + buttonShortcut; - ariaLabel = this.config.optionButtonAriaLabel.replace( - '$key', - this.config.altShortcut - ); + case Node.TEXT_NODE: + if (includeContentInName(node)) { + contents = normalize(node.textContent); } - } - return [label, ariaLabel]; - }, - setUpConfig: function (appConfig) { - let localConfig = this.config, - name, - appConfigSettings = - typeof appConfig.settings !== 'undefined' - ? appConfig.settings.skipTo - : {}; - for (name in appConfigSettings) { - //overwrite values of our local config, based on the external config - if ( - typeof localConfig[name] !== 'undefined' && - ((typeof appConfigSettings[name] === 'string' && - appConfigSettings[name].length > 0) || - typeof appConfigSettings[name] === 'boolean') - ) { - localConfig[name] = appConfigSettings[name]; - } else { - throw new Error( - '** SkipTo Problem with user configuration option "' + name + '".' - ); + break; + } + + return contents; + } + + /* + * @function couldHaveAltText + * + * @desc Based on HTML5 specification, returns true if + * the element could have an 'alt' attribute, + * otherwise false. + * + * @param {Object} element - DOM eleemnt node + * + * @return {Boolean} see @desc + */ + function couldHaveAltText (element) { + let tagName = element.tagName.toLowerCase(); + + switch (tagName) { + case 'img': + case 'area': + return true; + case 'input': + return (element.type && element.type === 'image'); + } + + return false; + } + + /* + * @function addCssGeneratedContent + * + * @desc Adds CSS-generated content for pseudo-elements + * :before and :after. According to the CSS spec, test that content + * value is other than the default computed value of 'none'. + * + * Note: Even if an author specifies content: 'none', because browsers + * add the double-quote character to the beginning and end of + * computed string values, the result cannot and will not be + * equal to 'none'. + * + * @param {Object} element - DOM node element + * @param {String} contents - Text content for DOM node + * + * @returns {String} see @desc + * + */ + function addCssGeneratedContent (element, contents) { + + let result = contents, + prefix = getComputedStyle(element, ':before').content, + suffix = getComputedStyle(element, ':after').content; + + if (prefix !== 'none') { + result = prefix.replaceAll('"', '') + result; + } + if (suffix !== 'none') { + result = result + suffix.replaceAll('"', ''); + } + + return result; + } + + /* accName.js */ + + /* Constants */ + const debug$2 = new DebugLogging('accName', false); + debug$2.flag = false; + + /** + * @fuction getAccessibleName + * + * @desc Returns the accessible name for an heading or landamrk + * + * @paramn {Object} dom - Document of the current element + * @param {node} element - DOM element node for either a heading or + * landmark + * @param {Boolean} fromContent - if true will compute name from content + * + * @return {String} The accessible name for the landmark or heading element + */ + + function getAccessibleName (doc, element, fromContent=false) { + let accName = ''; + + accName = nameFromAttributeIdRefs(doc, element, 'aria-labelledby'); + + if (accName === '' && element.hasAttribute('aria-label')) { + accName = element.getAttribute('aria-label').trim(); + } + + if (accName === '' && fromContent) { + accName = getNodeContents(element); + } + + if (accName === '' && element.title.trim() !== '') { + accName = element.title.trim(); + } + + return accName; + } + + /* + * @function nameFromAttributeIdRefs + * + * @desc Get the value of attrName on element (a space- + * separated list of IDREFs), visit each referenced element in the order it + * appears in the list and obtain its accessible name (skipping recursive + * aria-labelledby or aria-describedby calculations), and return an object + * with name property set to a string that is a space-separated concatena- + * tion of those results if any, otherwise return empty string. + * + * @param {Object} doc - Browser document object + * @param {Object} element - DOM element node + * @param {String} attribute - Attribute name (e.g. "aria-labelledby", "aria-describedby", + * or "aria-errormessage") + * + * @returns {String} see @desc + */ + function nameFromAttributeIdRefs (doc, element, attribute) { + const value = getAttributeValue(element, attribute); + const arr = []; + + if (value.length) { + const idRefs = value.split(' '); + + for (let i = 0; i < idRefs.length; i++) { + const refElement = doc.getElementById(idRefs[i]); + if (refElement) { + const accName = getNodeContents(refElement); + if (accName && accName.length) arr.push(accName); } } - }, - renderStyleElement: function (cssString) { - const styleNode = document.createElement('style'); - const headNode = document.getElementsByTagName('head')[0]; - const css = document.createTextNode(cssString); - - styleNode.setAttribute('type', 'text/css'); - // ID is used to test whether skipto is already loaded - styleNode.id = this.skipToId; - styleNode.appendChild(css); - headNode.appendChild(styleNode); - }, - - // - // Functions related to creating and populating the - // the popup menu - // - - getFirstChar: function (menuitem) { - const label = menuitem.querySelector('.label'); - if (label && this.isNotEmptyString(label.textContent)) { - return label.textContent.trim()[0].toLowerCase(); - } - return ''; - }, + } + + if (arr.length) { + return arr.join(' '); + } + + return ''; + } + + /* landmarksHeadings.js */ + + /* Constants */ + const debug$1 = new DebugLogging('landmarksHeadings', false); + debug$1.flag = false; + + const skipableElements = [ + 'base', + 'content', + 'frame', + 'iframe', + 'input[type=hidden]', + 'link', + 'meta', + 'noscript', + 'script', + 'style', + 'template', + 'shadow', + 'title' + ]; + + const allowedLandmarkSelectors = [ + 'banner', + 'complementary', + 'contentinfo', + 'form', + 'main', + 'navigation', + 'region', + 'search' + ]; + + const higherLevelElements = [ + 'article', + 'aside', + 'footer', + 'header', + 'main', + 'nav', + 'region', + 'section' + ]; + + + let idIndex = 0; + + /* + * @function getSkipToIdIndex + * + * @desc Returns the current skipto index used for generating + * id for target elements + * + * @returns {Number} see @desc + */ + function getSkipToIdIndex () { + return idIndex; + } + + /* + * @function incSkipToIdIndex + * + * @desc Adds one to the skipto index + */ + function incSkipToIdIndex () { + idIndex += 1; + } + + /* + * @function isSkipableElement + * + * @desc Returns true if the element is skipable, otherwise false + * + * @param {Object} element - DOM element node + * + * @returns {Boolean} see @desc + */ + function isSkipableElement(element) { + const tagName = element.tagName.toLowerCase(); + const type = element.hasAttribute('type') ? element.getAttribute('type') : ''; + const elemSelector = (tagName === 'input') && type.length ? + `${tagName}[type=${type}]` : + tagName; + return skipableElements.includes(elemSelector); + } + + /* + * @function isCustomElement + * + * @desc Reuturns true if the element is a custom element, otherwise + * false + * + * @param {Object} element - DOM element node + * + * @returns {Boolean} see @desc + */ + function isCustomElement(element) { + return element.tagName.indexOf('-') >= 0; + } + + /* + * @function sSlotElement + * + * @desc Reuturns true if the element is a slot element, otherwise + * false + * + * @param {Object} element - DOM element node + * + * @returns {Boolean} see @desc + */ + function isSlotElement(node) { + return (node instanceof HTMLSlotElement); + } + + /** + * @function isTopLevel + * + * @desc Tests the node to see if it is in the content of any other + * elements with default landmark roles or is the descendant + * of an element with a defined landmark role + * + * @param {Object} node - Element node from a berowser DOM + * + * @reutrn {Boolean} Returns true if top level landmark, otherwise false + */ + + function isTopLevel (node) { + node = node && node.parentNode; + while (node && (node.nodeType === Node.ELEMENT_NODE)) { + const tagName = node.tagName.toLowerCase(); + let role = node.getAttribute('role'); + if (role) { + role = role.toLowerCase(); + } + + if (higherLevelElements.includes(tagName) || + allowedLandmarkSelectors.includes(role)) { + return false; + } + node = node.parentNode; + } + return true; + } + + /* + * @function checkForLandmark + * + * @desc Re=trns the lamdnark name if a landmark, otherwise an + * empty string + * + * @param {Object} element - DOM element node + * + * @returns {String} see @desc + */ + function checkForLandmark (element) { + if (element.hasAttribute('role')) { + const role = element.getAttribute('role').toLowerCase(); + if (allowedLandmarkSelectors.indexOf(role) >= 0) { + return role; + } + } else { + const tagName = element.tagName.toLowerCase(); - getHeadingLevelFromAttribute: function (menuitem) { - if (menuitem.hasAttribute('data-level')) { - return menuitem.getAttribute('data-level'); - } - return ''; - }, - - updateKeyboardShortCuts: function () { - let mi; - this.firstChars = []; - this.headingLevels = []; - - for (let i = 0; i < this.menuitemNodes.length; i += 1) { - mi = this.menuitemNodes[i]; - this.firstChars.push(this.getFirstChar(mi)); - this.headingLevels.push(this.getHeadingLevelFromAttribute(mi)); - } - }, + switch (tagName) { + case 'aside': + return 'complementary'; - updateMenuitems: function () { - let menuitemNodes = this.menuNode.querySelectorAll('[role=menuitem'); + case 'main': + return 'main'; - this.menuitemNodes = []; - for (let i = 0; i < menuitemNodes.length; i += 1) { - this.menuitemNodes.push(menuitemNodes[i]); - } + case 'nav': + return 'navigation'; - this.firstMenuitem = this.menuitemNodes[0]; - this.lastMenuitem = this.menuitemNodes[this.menuitemNodes.length - 1]; - this.lastMenuitem.classList.add('last'); - this.updateKeyboardShortCuts(); - }, + case 'header': + if (isTopLevel(element)) { + return 'banner'; + } + break; - renderMenuitemToGroup: function (groupNode, mi) { - let tagNode, tagNodeChild, labelNode, nestingNode; + case 'footer': + if (isTopLevel(element)) { + return 'contentinfo'; + } + break; - let menuitemNode = document.createElement('div'); - menuitemNode.setAttribute('role', 'menuitem'); - menuitemNode.classList.add(mi.class); - if (this.isNotEmptyString(mi.tagName)) { - menuitemNode.classList.add('skip-to-' + mi.tagName.toLowerCase()); - } - menuitemNode.setAttribute('data-id', mi.dataId); - menuitemNode.tabIndex = -1; - if (this.isNotEmptyString(mi.ariaLabel)) { - menuitemNode.setAttribute('aria-label', mi.ariaLabel); + case 'section': + // Sections need an accessible name for be considered a "region" landmark + if (element.hasAttribute('aria-label') || element.hasAttribute('aria-labelledby')) { + return 'region'; + } + break; } - - // add event handlers - menuitemNode.addEventListener( - 'keydown', - this.handleMenuitemKeydown.bind(this) - ); - menuitemNode.addEventListener( - 'click', - this.handleMenuitemClick.bind(this) - ); - menuitemNode.addEventListener( - 'pointerenter', - this.handleMenuitemPointerenter.bind(this) - ); - - groupNode.appendChild(menuitemNode); - - // add heading level and label - if (mi.class.includes('heading')) { - if (this.config.enableHeadingLevelShortcuts) { - tagNode = document.createElement('span'); - tagNodeChild = document.createElement('span'); - tagNodeChild.appendChild(document.createTextNode(mi.level)); - tagNode.append(tagNodeChild); - tagNode.appendChild(document.createTextNode(')')); - tagNode.classList.add('level'); - menuitemNode.append(tagNode); + } + return ''; + } + + + /** + * @function queryDOMForSkipToId + * + * @desc Returns DOM node associated with the id, if id not found returns null + * + * @param {String} targetId - dom node element to attach button and menu + * + * @returns (Object) @desc + */ + function queryDOMForSkipToId (targetId) { + function transverseDOMForSkipToId(startingNode) { + var targetNode = null; + for (let node = startingNode.firstChild; node !== null; node = node.nextSibling ) { + if (node.nodeType === Node.ELEMENT_NODE) { + if (node.getAttribute('data-skip-to-id') === targetId) { + return node; + } + if (!isSkipableElement(node)) { + // check for slotted content + if (isSlotElement(node)) { + // if no slotted elements, check for default slotted content + const assignedNodes = node.assignedNodes().length ? + node.assignedNodes() : + node.assignedNodes({ flatten: true }); + for (let i = 0; i < assignedNodes.length; i += 1) { + const assignedNode = assignedNodes[i]; + if (assignedNode.nodeType === Node.ELEMENT_NODE) { + if (assignedNode.getAttribute('data-skip-to-id') === targetId) { + return assignedNode; + } + targetNode = transverseDOMForSkipToId(assignedNode); + if (targetNode) { + return targetNode; + } + } + } + } else { + // check for custom elements + if (isCustomElement(node)) { + if (node.shadowRoot) { + targetNode = transverseDOMForSkipToId(node.shadowRoot); + if (targetNode) { + return targetNode; + } + } + } else { + targetNode = transverseDOMForSkipToId(node); + if (targetNode) { + return targetNode; + } + } + } + } + } // end if + } // end for + return false; + } // end function + return transverseDOMForSkipToId(document.body); + } + + /** + * @function findVisibleElement + * + * @desc Returns the first isible decsendant DOM node that matches a set of element tag names + * + * @param {node} startingNode - dom node to start search for element + * @param {Array} tagNames - Array of tag names + * + * @returns (node} Returns first descendmt element, if not found returns false + */ + function findVisibleElement (startingNode, tagNames) { + + function transverseDOMForVisibleElement(startingNode, targetTagName) { + var targetNode = null; + for (let node = startingNode.firstChild; node !== null; node = node.nextSibling ) { + if (node.nodeType === Node.ELEMENT_NODE) { + if (!isSkipableElement(node)) { + // check for slotted content + if (isSlotElement(node)) { + // if no slotted elements, check for default slotted content + const assignedNodes = node.assignedNodes().length ? + node.assignedNodes() : + node.assignedNodes({ flatten: true }); + for (let i = 0; i < assignedNodes.length; i += 1) { + const assignedNode = assignedNodes[i]; + if (assignedNode.nodeType === Node.ELEMENT_NODE) { + const tagName = assignedNode.tagName.toLowerCase(); + if (tagName === targetTagName){ + if (isVisible(assignedNode)) { + return assignedNode; + } + } + targetNode = transverseDOMForVisibleElement(assignedNode, targetTagName); + if (targetNode) { + return targetNode; + } + } + } + } else { + // check for custom elements + if (isCustomElement(node)) { + if (node.shadowRoot) { + targetNode = transverseDOMForVisibleElement(node.shadowRoot, targetTagName); + if (targetNode) { + return targetNode; + } + } + } else { + const tagName = node.tagName.toLowerCase(); + if (tagName === targetTagName){ + if (isVisible(node)) { + return node; + } + } + targetNode = transverseDOMForVisibleElement(node, targetTagName); + if (targetNode) { + return targetNode; + } + } + } + } + } // end if + } // end for + return false; + } // end function + let targetNode = false; + + // Go through the tag names one at a time + for (let i = 0; i < tagNames.length; i += 1) { + targetNode = transverseDOMForVisibleElement(startingNode, tagNames[i]); + if (targetNode) { + break; + } + } + return targetNode ? targetNode : startingNode; + } + + /* + * @function skipToElement + * + * @desc Moves focus to the element identified by the memu item + * + * @param {Object} menutim - DOM element in the menu identifying the target element. + */ + function skipToElement(menuitem) { + + let focusNode = false; + let scrollNode = false; + let elem; + + const searchSelectors = ['input', 'button', 'a']; + const navigationSelectors = ['a', 'input', 'button']; + const landmarkSelectors = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'section', 'article', 'p', 'li', 'a']; + + const isLandmark = menuitem.classList.contains('landmark'); + const isSearch = menuitem.classList.contains('skip-to-search'); + const isNav = menuitem.classList.contains('skip-to-nav'); + + elem = queryDOMForSkipToId(menuitem.getAttribute('data-id')); + + if (elem) { + if (isSearch) { + focusNode = findVisibleElement(elem, searchSelectors); + } + if (isNav) { + focusNode = findVisibleElement(elem, navigationSelectors); + } + if (focusNode && isVisible(focusNode)) { + if (focusNode.tabIndex >= 0) { + focusNode.focus(); } else { - menuitemNode.classList.add('no-level'); - } - menuitemNode.setAttribute('data-level', mi.level); - if (this.isNotEmptyString(mi.tagName)) { - menuitemNode.classList.add('skip-to-' + mi.tagName); + focusNode.tabIndex = 0; + focusNode.focus(); + focusNode.tabIndex = -1; } + focusNode.scrollIntoView({block: 'center'}); } - - // add nesting level for landmarks - if (mi.class.includes('landmark')) { - menuitemNode.setAttribute('data-nesting', mi.nestingLevel); - menuitemNode.classList.add('skip-to-nesting-level-' + mi.nestingLevel); - - if (mi.nestingLevel > 0 && mi.nestingLevel > this.lastNestingLevel) { - nestingNode = document.createElement('span'); - nestingNode.classList.add('nesting'); - menuitemNode.append(nestingNode); + else { + if (isLandmark) { + scrollNode = findVisibleElement(elem, landmarkSelectors); + if (scrollNode) { + elem = scrollNode; + } } - this.lastNestingLevel = mi.nestingLevel; - } - - labelNode = document.createElement('span'); - labelNode.appendChild(document.createTextNode(mi.name)); - labelNode.classList.add('label'); - menuitemNode.append(labelNode); - - return menuitemNode; - }, - - renderGroupLabel: function (groupLabelId, title, m, n) { - let titleNode, mofnNode, s; - let groupLabelNode = document.getElementById(groupLabelId); - - titleNode = groupLabelNode.querySelector('.title'); - mofnNode = groupLabelNode.querySelector('.mofn'); + if (elem.tabIndex >= 0) { + elem.focus(); + } else { + elem.tabIndex = 0; + elem.focus(); + elem.tabIndex = -1; + } + elem.scrollIntoView({block: 'center'}); + } + } + } + + /* + * @function getHeadingTargets + * + * @desc Returns an array of heading tag names to include in menu + * NOTE: It uses "includes" method to maximimze compatibility with + * previous versions of SkipTo which used CSS selectors for + * identifying targets. + * + * @param {String} targets - A space with the heading tags to inclucde + * + * @returns {Array} Array of heading element tag names to include in menu + */ + function getHeadingTargets(targets) { + let targetHeadings = []; + ['h1','h2','h3','h4','h5','h6'].forEach( h => { + if (targets.includes(h)) { + targetHeadings.push(h); + } + }); + return targetHeadings; + } + + /* + * @function isMain + * + * @desc Returns true if the element is a main landamrk + * + * @param {Object} element - DOM element node + * + * @returns {Boolean} see @desc + */ + function isMain (element) { + const tagName = element.tagName.toLowerCase(); + const role = element.hasAttribute('role') ? element.getAttribute('role').toLowerCase() : ''; + return (tagName === 'main') || (role === 'main'); + } + + /* + * @function queryDOMForLandmarksAndHeadings + * + * @desc Recursive function to return two arrays, one an array of the DOM element nodes for + * landmarks and the other an array of DOM element ndoes for headings + * + * @param {Array} landamrkTargets - An array of strings representing landmark regions + * @param {Array} headingTargets - An array of strings representing headings + * + * @returns {Array} @see @desc + */ + function queryDOMForLandmarksAndHeadings (landmarkTargets, headingTargets) { + let headingInfo = []; + let landmarkInfo = []; + let targetLandmarks = getLandmarkTargets(landmarkTargets.toLowerCase()); + let targetHeadings = getHeadingTargets(headingTargets.toLowerCase()); + let onlyInMain = headingTargets.includes('main'); + + function transverseDOM(startingNode, doc, parentDoc=null, inMain = false) { + for (let node = startingNode.firstChild; node !== null; node = node.nextSibling ) { + if (node.nodeType === Node.ELEMENT_NODE) { + const tagName = node.tagName.toLowerCase(); + if (targetLandmarks.indexOf(checkForLandmark(node)) >= 0) { + landmarkInfo.push({ node: node, name: getAccessibleName(doc, node)}); + } + if (targetHeadings.indexOf(tagName) >= 0) { + if (!onlyInMain || inMain) { + headingInfo.push({ node: node, name: getAccessibleName(doc, node, true)}); + } + } - titleNode.textContent = title; + if (isMain(node)) { + inMain = true; + } - if (this.config.enableActions && this.config.enableMofN) { - if (typeof m === 'number' && typeof n === 'number') { - s = this.config.mofnGroupLabel; - s = s.replace('$m', m); - s = s.replace('$n', n); - mofnNode.textContent = s; + if (!isSkipableElement(node)) { + // check for slotted content + if (isSlotElement(node)) { + // if no slotted elements, check for default slotted content + const slotContent = node.assignedNodes().length > 0; + const assignedNodes = slotContent ? + node.assignedNodes() : + node.assignedNodes({ flatten: true }); + const nameDoc = slotContent ? + parentDoc : + doc; + for (let i = 0; i < assignedNodes.length; i += 1) { + const assignedNode = assignedNodes[i]; + if (assignedNode.nodeType === Node.ELEMENT_NODE) { + const tagName = assignedNodes[i].tagName.toLowerCase(); + if (targetLandmarks.indexOf(checkForLandmark(assignedNode)) >= 0) { + landmarkInfo.push({ node: assignedNode, name: getAccessibleName(nameDoc, assignedNode)}); + } + + if (targetHeadings.indexOf(tagName) >= 0) { + if (!onlyInMain || inMain) { + headingInfo.push({ node: assignedNode, name: getAccessibleName(nameDoc, assignedNode, true)}); + } + } + if (slotContent) { + transverseDOM(assignedNode, parentDoc, null, inMain); + } else { + transverseDOM(assignedNode, doc, parentDoc, inMain); + } + } + } + } else { + // check for custom elements + if (isCustomElement(node)) { + if (node.shadowRoot) { + transverseDOM(node.shadowRoot, node.shadowRoot, doc, inMain); + } + } else { + transverseDOM(node, doc, parentDoc, inMain); + } + } + } + } // end if + } // end for + } // end function + + transverseDOM(document.body, document); + + // If no elements found when onlyInMain is set, try + // to find any headings + if ((headingInfo.length === 0) && onlyInMain) { + onlyInMain = false; + transverseDOM(document.body, document); + } + + return [landmarkInfo, headingInfo]; + } + + /* + * @function getLandmarksAndHeadings + * + * @desc Returns two arrays of of DOM node elements with, one for landmark regions + * the other for headings with additional information needed to create + * menuitems + * + * @param {Object} config - Object with configuration information + * + * @return see @desc + */ + + function getLandmarksAndHeadings (config) { + + let landmarkTargets = config.landmarks; + if (typeof landmarkTargets !== 'string') { + landmarkTargets = 'main search navigation'; + } + + let headingTargets = config.headings; + // If targets undefined, use default settings + if (typeof headingTargets !== 'string') { + headingTargets = 'h1 h2'; + } + + const [landmarks, headings] = queryDOMForLandmarksAndHeadings(landmarkTargets, headingTargets); + + return [getLandmarks(config, landmarks), getHeadings(config, headings)]; + } + + /* + * @function getHeadings + * + * @desc Returns an array of heading menu elements + * + * @param {Object} config - Object with configuration information + * @param {Object} headings - Array of dome node elements that are headings + * + * @returns see @desc + */ + function getHeadings (config, headings) { + let dataId, level; + let headingElementsArr = []; + + for (let i = 0, len = headings.length; i < len; i += 1) { + let heading = headings[i]; + let role = heading.node.getAttribute('role'); + if ((typeof role === 'string') && (role === 'presentation')) continue; + if (isVisible(heading.node) && isNotEmptyString(heading.node.innerHTML)) { + if (heading.node.hasAttribute('data-skip-to-id')) { + dataId = heading.node.getAttribute('data-skip-to-id'); + } else { + dataId = getSkipToIdIndex(); + heading.node.setAttribute('data-skip-to-id', dataId); } - } - }, - - renderMenuitemGroup: function (groupId, title) { - let labelNode, groupNode, spanNode; - let menuNode = this.menuNode; - if (this.isNotEmptyString(title)) { - labelNode = document.createElement('div'); - labelNode.id = groupId + '-label'; - labelNode.setAttribute('role', 'separator'); - menuNode.appendChild(labelNode); - - spanNode = document.createElement('span'); - spanNode.classList.add('title'); - spanNode.textContent = title; - labelNode.append(spanNode); - - spanNode = document.createElement('span'); - spanNode.classList.add('mofn'); - labelNode.append(spanNode); - - groupNode = document.createElement('div'); - groupNode.setAttribute('role', 'group'); - groupNode.setAttribute('aria-labelledby', labelNode.id); - groupNode.id = groupId; - menuNode.appendChild(groupNode); - menuNode = groupNode; - } - return groupNode; - }, - - removeMenuitemGroup: function (groupId) { - let node = document.getElementById(groupId); - this.menuNode.removeChild(node); - node = document.getElementById(groupId + '-label'); - this.menuNode.removeChild(node); - }, - - renderMenuitemsToGroup: function (groupNode, menuitems, msgNoItemsFound) { - groupNode.innerHTML = ''; - this.lastNestingLevel = 0; - - if (menuitems.length === 0) { - const item = {}; - item.name = msgNoItemsFound; - item.tagName = ''; - item.class = 'no-items'; - item.dataId = ''; - this.renderMenuitemToGroup(groupNode, item); - } else { - for (var i = 0; i < menuitems.length; i += 1) { - this.renderMenuitemToGroup(groupNode, menuitems[i]); + level = heading.node.tagName.substring(1); + const headingItem = {}; + headingItem.dataId = dataId.toString(); + headingItem.class = 'heading'; + headingItem.name = heading.name; + headingItem.ariaLabel = headingItem.name + ', '; + headingItem.ariaLabel += config.headingLevelLabel + ' ' + level; + headingItem.tagName = heading.node.tagName.toLowerCase(); + headingItem.role = 'heading'; + headingItem.level = level; + headingElementsArr.push(headingItem); + incSkipToIdIndex(); + } + } + return headingElementsArr; + } + + /* + * @function getLocalizedLandmarkName + * + * @desc Localizes a landmark name and adds accessible name if defined + * + * @param {Object} config - Object with configuration information + * @param {String} tagName - String with landamrk and/or tag names + * @param {String} AccName - Accessible name for therlandmark, maybe an empty string + * + * @returns {String} A localized string for a landmark name + */ + function getLocalizedLandmarkName (config, tagName, accName) { + let n; + switch (tagName) { + case 'aside': + n = config.asideLabel; + break; + case 'footer': + n = config.footerLabel; + break; + case 'form': + n = config.formLabel; + break; + case 'header': + n = config.headerLabel; + break; + case 'main': + n = config.mainLabel; + break; + case 'nav': + n = config.navLabel; + break; + case 'section': + case 'region': + n = config.regionLabel; + break; + case 'search': + n = config.searchLabel; + break; + // When an ID is used as a selector, assume for main content + default: + n = tagName; + break; + } + if (isNotEmptyString(accName)) { + n += ': ' + accName; + } + return n; + } + + /* + * @function getLandmarkTargets + * + * @desc Analyzes a configuration string for landamrk and tag names + * NOTE: This function is included to maximize compatibility + * with confiuguration strings that use CSS selectors + * in previous versions of SkipTo + * + * @param {String} targets - String with landamrk and/or tag names + * + * @returns {Array} A normailized array of landmark names based on target configuration + */ + function getLandmarkTargets (targets) { + let targetLandmarks = []; + targets = targets.toLowerCase(); + if (targets.includes('main')) { + targetLandmarks.push('main'); + } + if (targets.includes('search')) { + targetLandmarks.push('search'); + } + if (targets.includes('nav')) { + targetLandmarks.push('navigation'); + } + if (targets.includes('complementary') || + targets.includes('aside')) { + targetLandmarks.push('complementary'); + } + if (targets.includes('banner') || + targets.includes('header')) { + targetLandmarks.push('banner'); + } + if (targets.includes('contentinfo') || + targets.includes('footer')) { + targetLandmarks.push('contentinfo'); + } + if (targets.includes('region') || + targets.includes('section')) { + targetLandmarks.push('region'); + } + return targetLandmarks; + } + + + /* + * @function getLandmarks + * + * @desc Returns an array of objects with information to build the + * the landmarks menu, ordering in the array by the type of landmark + * region + * + * @param {Object} config - Object with configuration information + * @param {Array} landmarks - Array of objects containing the DOM node and + * accessible name for landmarks + * + * @returns {Array} see @desc + */ + function getLandmarks(config, landmarks) { + let mainElements = []; + let searchElements = []; + let navElements = []; + let asideElements = []; + let footerElements = []; + let regionElements = []; + let otherElements = []; + let dataId = ''; + for (let i = 0, len = landmarks.length; i < len; i += 1) { + let landmark = landmarks[i]; + if (landmark.node.id === 'id-skip-to') { + continue; + } + let role = landmark.node.getAttribute('role'); + let tagName = landmark.node.tagName.toLowerCase(); + if ((typeof role === 'string') && (role === 'presentation')) continue; + if (isVisible(landmark.node)) { + if (!role) role = tagName; + // normalize tagNames + switch (role) { + case 'banner': + tagName = 'header'; + break; + case 'complementary': + tagName = 'aside'; + break; + case 'contentinfo': + tagName = 'footer'; + break; + case 'form': + tagName = 'form'; + break; + case 'main': + tagName = 'main'; + break; + case 'navigation': + tagName = 'nav'; + break; + case 'region': + tagName = 'section'; + break; + case 'search': + tagName = 'search'; + break; + } + // if using ID for selectQuery give tagName as main + if (['aside', 'footer', 'form', 'header', 'main', 'nav', 'section', 'search'].indexOf(tagName) < 0) { + tagName = 'main'; + } + if (landmark.node.hasAttribute('aria-roledescription')) { + tagName = landmark.node.getAttribute('aria-roledescription').trim().replace(' ', '-'); + } + if (landmark.node.hasAttribute('data-skip-to-id')) { + dataId = landmark.node.getAttribute('data-skip-to-id'); + } else { + dataId = getSkipToIdIndex(); + landmark.node.setAttribute('data-skip-to-id', dataId); + } + const landmarkItem = {}; + landmarkItem.dataId = dataId.toString(); + landmarkItem.class = 'landmark'; + landmarkItem.hasName = landmark.name.length > 0; + landmarkItem.name = getLocalizedLandmarkName(config, tagName, landmark.name); + landmarkItem.tagName = tagName; + landmarkItem.nestingLevel = 0; + incSkipToIdIndex(); + + // For sorting landmarks into groups + switch (tagName) { + case 'main': + mainElements.push(landmarkItem); + break; + case 'search': + searchElements.push(landmarkItem); + break; + case 'nav': + navElements.push(landmarkItem); + break; + case 'aside': + asideElements.push(landmarkItem); + break; + case 'footer': + footerElements.push(landmarkItem); + break; + case 'section': + // Regions must have accessible name to be included + if (landmarkItem.hasName) { + regionElements.push(landmarkItem); + } + break; + default: + otherElements.push(landmarkItem); + break; } } - }, - - getShowMoreHeadingsSelector: function (option) { - if (option === 'all') { - return this.showAllHeadingsSelector; - } - return this.config.headings; - }, - - getShowMoreHeadingsLabel: function (option, n) { - let label = this.config.actionShowSelectedHeadingsLabel; - if (option === 'all') { - label = this.config.actionShowAllHeadingsLabel; - } - return label.replace('$num', n); - }, - - getShowMoreHeadingsAriaLabel: function (option, n) { - let label = this.config.actionShowSelectedHeadingsAriaLabel; - - if (option === 'all') { - label = this.config.actionShowAllHeadingsAriaLabel; - } - - return label.replace('$num', n); - }, - - renderActionMoreHeadings: function (groupNode) { - let item, menuitemNode; - let option = 'all'; - - let selectedHeadingsLen = this.getHeadings( - this.getShowMoreHeadingsSelector('selected') - ).length; - let allHeadingsLen = this.getHeadings( - this.getShowMoreHeadingsSelector('all') - ).length; - let noAction = selectedHeadingsLen === allHeadingsLen; - let headingsLen = allHeadingsLen; - - if (option !== 'all') { - headingsLen = selectedHeadingsLen; - } - - if (!noAction) { - item = {}; - item.tagName = ''; - item.role = 'menuitem'; - item.class = 'action'; - item.dataId = 'skip-to-more-headings'; - item.name = this.getShowMoreHeadingsLabel(option, headingsLen); - item.ariaLabel = this.getShowMoreHeadingsAriaLabel(option, headingsLen); - - menuitemNode = this.renderMenuitemToGroup(groupNode, item); - menuitemNode.setAttribute('data-show-heading-option', option); - menuitemNode.title = this.config.actionShowHeadingsHelp; - } - return noAction; - }, - - updateHeadingGroupMenuitems: function (option) { - let headings, headingsLen, labelNode, groupNode; - - const selectedHeadings = this.getHeadings( - this.getShowMoreHeadingsSelector('selected') - ); - const selectedHeadingsLen = selectedHeadings.length; - const allHeadings = this.getHeadings( - this.getShowMoreHeadingsSelector('all') - ); - const allHeadingsLen = allHeadings.length; - - // Update list of headings - if (option === 'all') { - headings = allHeadings; - } else { - headings = selectedHeadings; - } - - this.renderGroupLabel( - 'id-skip-to-group-headings-label', - this.config.headingGroupLabel, - headings.length, - allHeadings.length - ); - - groupNode = document.getElementById('id-skip-to-group-headings'); - this.renderMenuitemsToGroup( - groupNode, - headings, - this.config.msgNoHeadingsFound - ); - this.updateMenuitems(); - - // Move focus to first heading menuitem - if (groupNode.firstElementChild) { - groupNode.firstElementChild.focus(); - } - - // Update heading action menuitem - if (option === 'all') { - option = 'selected'; - headingsLen = selectedHeadingsLen; - } else { - option = 'all'; - headingsLen = allHeadingsLen; - } - - const menuitemNode = this.menuNode.querySelector( - '[data-id=skip-to-more-headings]' - ); - menuitemNode.setAttribute('data-show-heading-option', option); - menuitemNode.setAttribute( - 'aria-label', - this.getShowMoreHeadingsAriaLabel(option, headingsLen) - ); - - labelNode = menuitemNode.querySelector('span.label'); - labelNode.textContent = this.getShowMoreHeadingsLabel( - option, - headingsLen - ); - }, - - getShowMoreLandmarksSelector: function (option) { - if (option === 'all') { - return this.showAllLandmarksSelector; - } - return this.config.landmarks; - }, - - getShowMoreLandmarksLabel: function (option, n) { - let label = this.config.actionShowSelectedLandmarksLabel; - - if (option === 'all') { - label = this.config.actionShowAllLandmarksLabel; - } - return label.replace('$num', n); - }, + } + return [].concat(mainElements, searchElements, navElements, asideElements, regionElements, footerElements, otherElements); + } + + /* skiptoMenuButton.js */ + + /* Constants */ + const debug = new DebugLogging('SkipToButton', false); + debug.flag = false; + + /** + * @class SkiptoMenuButton + * + * @desc Constructor for creating a button to open a menu of headings and landmarks on + * a web page + * + * @param {Object} attachNode - DOM eleemnt node to attach button and menu container element + * + * @returns {Object} DOM element node that is the contatiner for the button and the menu + */ + class SkiptoMenuButton { + + constructor (attachNode, config, id) { + this.config = config; + + this.containerNode = document.createElement(config.containerElement); + if (config.containerElement === 'nav') { + this.containerNode.setAttribute('aria-label', config.buttonLabel); + } - getShowMoreLandmarksAriaLabel: function (option, n) { - let label = this.config.actionShowSelectedLandmarksAriaLabel; + this.containerNode.id = id; - if (option === 'all') { - label = this.config.actionShowAllLandmarksAriaLabel; - } + if (isNotEmptyString(config.customClass)) { + this.containerNode.classList.add(config.customClass); + } - return label.replace('$num', n); - }, + let displayOption = config.displayOption; + if (typeof displayOption === 'string') { + displayOption = displayOption.trim().toLowerCase(); + if (displayOption.length) { + switch (config.displayOption) { + case 'fixed': + this.containerNode.classList.add('fixed'); + break; + case 'onfocus': // Legacy option + case 'popup': + this.containerNode.classList.add('popup'); + break; + } + } + } - renderActionMoreLandmarks: function (groupNode) { - let item, menuitemNode; - let option = 'all'; + // Create button + + const [buttonVisibleLabel, buttonAriaLabel] = this.getBrowserSpecificShortcut(config); + + this.buttonNode = document.createElement('button'); + this.buttonNode.setAttribute('aria-label', buttonAriaLabel); + this.buttonNode.addEventListener('keydown', this.handleButtonKeydown.bind(this)); + this.buttonNode.addEventListener('click', this.handleButtonClick.bind(this)); + this.containerNode.appendChild(this.buttonNode); + + this.buttonTextNode = document.createElement('span'); + this.buttonTextNode.classList.add('text'); + this.buttonTextNode.textContent = buttonVisibleLabel; + this.buttonNode.appendChild(this.buttonTextNode); + + const imageNode = document.createElement('img'); + imageNode.src = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAABGdBTUEAALGPC/xhBQAAAAlwSFlzAAALEwAACxMBAJqcGAAAAVlpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IlhNUCBDb3JlIDUuNC4wIj4KICAgPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICAgICAgPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIKICAgICAgICAgICAgeG1sbnM6dGlmZj0iaHR0cDovL25zLmFkb2JlLmNvbS90aWZmLzEuMC8iPgogICAgICAgICA8dGlmZjpPcmllbnRhdGlvbj4xPC90aWZmOk9yaWVudGF0aW9uPgogICAgICA8L3JkZjpEZXNjcmlwdGlvbj4KICAgPC9yZGY6UkRGPgo8L3g6eG1wbWV0YT4KTMInWQAABP5JREFUWAm9V11MXEUUnjN7KbuwKD/L8qOp2kpbWCSFpkZN2qIxjQYWaC2JD/4kxsTE1MQHeedBY6K+mPSpJsakhcSgFaQk+qCh1DZRQmioSypVa0q1IKC1bFlw753jN5e9txfobmkBJ1nm3DPnb87fHEhkWGWVBx9QYqFCkSxk4nuFYMpA7jkilkIsMHNCGnI8m9VPl8/3/e0hcMEVAosjTc+wsl5iomcFc75LuTYA4ug7IajDZ+Qfnxw5dsMR5xoQqm0qF/OqgwXXO4cbscOQCwb5npuI9Yxq+bYBJTUHwqaZHMSNN99USjNE4jQoxuD5KeBnBSm+eZ4BYglWsQkhCzKLLWB6DHC1wwEj4j6DaiZHei8ZGmklkx+CwFZOgqalT772+qG67vb2duUwrXUPVzfVKMv6GMbsQm4ETVO8D5mHqHxXNLSQUFcFC23MdSmz9k3Fus+tVeGt+IsjrUFWc2MwokyfS5lTZiQTYndKuY5H33LlYCplv+Xmyq0Ep8PRvI+nYl0TzjngeKiqoQP63lrEzT9sIK5hIFKLhh1I76HKxjal5t4Tc17s6mEtNlQVbZse7f3A5SJx0dWn+D5DsbwmhBvqCpcQQDDb+ChuKmTrauvfy61h4qAhz0x70JJ8PYpUm5aZFaBvRElN9KGiyga2f1UN46W1rcUe+g0BkdwSiWiH1f4TqmocAGKP1oYqGPEZfGDyfN+vG6J9mVDbgNKqaMRkHkQTCtjnTBa8960U8hRyZAxljT4g44IsN1uWyVn6SRJ0dEP4xOzh5rqJTOXsZnf4kcbHlcUn0DhKl0pb2xc8mkC8R5nk11lCdE6M9sa8El0DNFLXqVCJN+GJF3GFbV7CdYK1Z45RoTw8febLWS1ziQFeJeHqlq2CrZ2WEBUkuBBt+p5M9F5eJDgJKfwot3xcBnJoB7yAB3JxoRUPEwX26r6Q1gCHeD32+yOthfNi/mVc4m0ke44tk+jIzOjJN/4XA5xLhKuj+y3FvTBkE7ww5zMKwrYBoUj0VSAr/RR450qs6y+HYSN2tOJuJHpzygtPG7oRmUl1FAhaEAndtN51FBdVNp5A/PGU3uXCs5uKtfseoCq+R14sGsBUbiAmuuxsT6Ahl3tV+SR1YAIY8eLuDObZzf6CGT1MOIsFwcOL7QTtwm/PA87h8j2nuKgvPnv97HL8ar+DlkoMDR1NZqLPaED8z+mz8FBtJgGZzvQIVV7d/OAfP/aMp6PLaABmtxdMqbakY749nuOZlGt+tzncXtjGUBiWItNNCko9RildJlvHWd19CJDbK0NAnOsMJKiIpJFNcmLBGUiY6rz3DIZDTyAJC7y4O4F1Ev52rhsDz5L1qPNFQl0lPRwc+XTwCgqjDD3bkpKfnBrtO+0QredeHGnZycocQB/IQ+EnA5RT6uvv7+dAeHsRFO0BUj/kB3NLtsfmpsbG1lN5KNIYFUp9DuW2R0lS50Ssu9NuQBjNc/5N8BBKDq9WahFdRhccQBx/JhbX8MDNI6SLHcShSbdjggFtALEuwA9zJu2DYrfJ4fBSdl7W7t9/+GLGfYz0LGguzH2GPr03ndz1wEP5KZKB551x3TXAEV5UGW0h4lfwOO3HdbMd/Fp2/fLBu1/B7Z9Mx072emWtMMA5rK9vNy5MDW8zhbUVLsxDqebil5be4VvcETQ9ExL9g3/rf3lqh/9iV1cXZpuV6z8QFu9El3GwrAAAAABJRU5ErkJggg=="; + imageNode.setAttribute('alt', ''); + this.buttonNode.appendChild(imageNode); + + // Create menu container + + this.menuNode = document.createElement('div'); + this.menuNode.id = 'id-skip-to-menu'; + this.menuNode.setAttribute('role', 'menu'); + this.menuNode.setAttribute('aria-label', config.menuLabel); + this.menuNode.setAttribute('aria-busy', 'true'); + this.containerNode.appendChild(this.menuNode); + + const landmarkGroupLabelNode = document.createElement('div'); + landmarkGroupLabelNode.id = 'id-skip-to-menu-landmark-group-label'; + landmarkGroupLabelNode.setAttribute('role', 'separator'); + landmarkGroupLabelNode.textContent = this.config.landmarkGroupLabel; + this.menuNode.appendChild(landmarkGroupLabelNode); + + this.landmarkGroupNode = document.createElement('div'); + this.landmarkGroupNode.setAttribute('role', 'group'); + this.landmarkGroupNode.setAttribute('aria-labelledby', landmarkGroupLabelNode.id); + this.landmarkGroupNode.id = '#id-skip-to-menu-landmark-group'; + this.menuNode.appendChild(this.landmarkGroupNode); + + const headingGroupLabelNode = document.createElement('div'); + headingGroupLabelNode.id = 'id-skip-to-menu-heading-group-label'; + headingGroupLabelNode.setAttribute('role', 'separator'); + headingGroupLabelNode.textContent = this.config.headingGroupLabel; + this.menuNode.appendChild(headingGroupLabelNode); + + this.headingGroupNode = document.createElement('div'); + this.headingGroupNode.setAttribute('role', 'group'); + this.headingGroupNode.setAttribute('aria-labelledby', headingGroupLabelNode.id); + this.headingGroupNode.id = '#id-skip-to-menu-heading-group'; + this.menuNode.appendChild(this.headingGroupNode); + + this.containerNode.addEventListener('focusin', this.handleFocusin.bind(this)); + this.containerNode.addEventListener('focusout', this.handleFocusout.bind(this)); + window.addEventListener('pointerdown', this.handleBackgroundPointerdown.bind(this), true); - const selectedLandmarksLen = this.getLandmarks( - this.getShowMoreLandmarksSelector('selected') - ).length; - const allLandmarksLen = this.getLandmarks( - this.getShowMoreLandmarksSelector('all') - ).length; - const noAction = selectedLandmarksLen === allLandmarksLen; - let landmarksLen = allLandmarksLen; + if (this.usesAltKey || this.usesOptionKey) { + document.addEventListener( + 'keydown', + this.handleDocumentKeydown.bind(this) + ); + } - if (option !== 'all') { - landmarksLen = selectedLandmarksLen; - } + attachNode.insertBefore(this.containerNode, attachNode.firstElementChild); + + return this.containerNode; + + } + + /* + * @method getBrowserSpecificShortcut + * + * @desc Identifies the operating system and updates labels for + * shortcut key to use either the "alt" or the "option" + * label + * + * @param {Object} - SkipTp configure object + * + * @return {Array} - An array of two strings used for the button label + */ + getBrowserSpecificShortcut (config) { + const platform = navigator.platform.toLowerCase(); + const userAgent = navigator.userAgent.toLowerCase(); + + const hasWin = platform.indexOf('win') >= 0; + const hasMac = platform.indexOf('mac') >= 0; + const hasLinux = platform.indexOf('linux') >= 0 || platform.indexOf('bsd') >= 0; + const hasAndroid = userAgent.indexOf('android') >= 0; + + this.usesAltKey = hasWin || (hasLinux && !hasAndroid); + this.usesOptionKey = hasMac; + + let label = config.buttonLabel; + let ariaLabel = config.buttonLabel; + let buttonShortcut; + + // Check to make sure a shortcut key is defined + if (config.altShortcut && config.optionShortcut) { + if (this.usesAltKey || this.usesOptionKey) { + buttonShortcut = config.buttonShortcut.replace( + '$key', + config.altShortcut + ); + } + if (this.usesAltKey) { + buttonShortcut = buttonShortcut.replace( + '$modifier', + config.altLabel + ); + label = label + buttonShortcut; + ariaLabel = config.altButtonAriaLabel.replace('$key', config.altShortcut); + } - if (!noAction) { - item = {}; - item.tagName = ''; - item.role = 'menuitem'; - item.class = 'action'; - item.dataId = 'skip-to-more-landmarks'; - item.name = this.getShowMoreLandmarksLabel(option, landmarksLen); - item.ariaLabel = this.getShowMoreLandmarksAriaLabel( - option, - landmarksLen - ); - - menuitemNode = this.renderMenuitemToGroup(groupNode, item); - - menuitemNode.setAttribute('data-show-landmark-option', option); - menuitemNode.title = this.config.actionShowLandmarksHelp; - } - return noAction; - }, - - updateLandmarksGroupMenuitems: function (option) { - let landmarks, landmarksLen, labelNode, groupNode; - - const selectedLandmarks = this.getLandmarks( - this.getShowMoreLandmarksSelector('selected') - ); - const selectedLandmarksLen = selectedLandmarks.length; - const allLandmarks = this.getLandmarks( - this.getShowMoreLandmarksSelector('all'), - true - ); - const allLandmarksLen = allLandmarks.length; - - // Update landmark menu items - if (option === 'all') { - landmarks = allLandmarks; - } else { - landmarks = selectedLandmarks; + if (this.usesOptionKey) { + buttonShortcut = buttonShortcut.replace( + '$modifier', + config.optionLabel + ); + label = label + buttonShortcut; + ariaLabel = config.optionButtonAriaLabel.replace('$key', config.altShortcut); + } + } + return [label, ariaLabel]; + } + + /* + * @method getFirstChar + * + * @desc Gets the first character in a menuitem to use as a shortcut key + * + * @param {Object} menuitem - DOM element node + * + * @returns {String} see @desc + */ + getFirstChar(menuitem) { + const label = menuitem.querySelector('.label'); + if (label && isNotEmptyString(label.textContent)) { + return label.textContent.trim()[0].toLowerCase(); + } + return ''; + } + + /* + * @method getHeadingLevelFromAttribute + * + * @desc Returns the the heading level of the menu item + * + * @param {Object} menuitem - DOM element node + * + * @returns {String} see @desc + */ + getHeadingLevelFromAttribute(menuitem) { + if (menuitem.hasAttribute('data-level')) { + return menuitem.getAttribute('data-level'); + } + return ''; + } + + /* + * @method updateKeyboardShortCuts + * + * @desc Updates the keyboard short cuts for the curent menu items + */ + updateKeyboardShortCuts () { + let mi; + this.firstChars = []; + this.headingLevels = []; + + for(let i = 0; i < this.menuitemNodes.length; i += 1) { + mi = this.menuitemNodes[i]; + this.firstChars.push(this.getFirstChar(mi)); + this.headingLevels.push(this.getHeadingLevelFromAttribute(mi)); + } } - this.renderGroupLabel( - 'id-skip-to-group-landmarks-label', - this.config.landmarkGroupLabel, - landmarks.length, - allLandmarks.length - ); - - groupNode = document.getElementById('id-skip-to-group-landmarks'); - this.renderMenuitemsToGroup( - groupNode, - landmarks, - this.config.msgNoLandmarksFound - ); - this.updateMenuitems(); - - // Move focus to first landmark menuitem - if (groupNode.firstElementChild) { - groupNode.firstElementChild.focus(); - } + /* + * @method updateMenuitems + * + * @desc Updates the menu information with the current manu items + * used for menu navgation commands + */ + updateMenuitems () { + let menuitemNodes = this.menuNode.querySelectorAll('[role=menuitem'); - // Update landmark action menuitem - if (option === 'all') { - option = 'selected'; - landmarksLen = selectedLandmarksLen; - } else { - option = 'all'; - landmarksLen = allLandmarksLen; - } + this.menuitemNodes = []; + for(let i = 0; i < menuitemNodes.length; i += 1) { + this.menuitemNodes.push(menuitemNodes[i]); + } - const menuitemNode = this.menuNode.querySelector( - '[data-id=skip-to-more-landmarks]' - ); - menuitemNode.setAttribute('data-show-landmark-option', option); - menuitemNode.setAttribute( - 'aria-label', - this.getShowMoreLandmarksAriaLabel(option, landmarksLen) - ); - - labelNode = menuitemNode.querySelector('span.label'); - labelNode.textContent = this.getShowMoreLandmarksLabel( - option, - landmarksLen - ); - }, - - renderMenu: function () { - let groupNode, - selectedLandmarks, - allLandmarks, - landmarkElements, - selectedHeadings, - allHeadings, - headingElements, - selector, - option, - hasNoAction1, - hasNoAction2; - // remove current menu items from menu - while (this.menuNode.lastElementChild) { - this.menuNode.removeChild(this.menuNode.lastElementChild); - } + this.firstMenuitem = this.menuitemNodes[0]; + this.lastMenuitem = this.menuitemNodes[this.menuitemNodes.length-1]; + this.lastMenuitem.classList.add('last'); + this.updateKeyboardShortCuts(); + } + + /* + * @method renderMenuitemToGroup + * + * @desc Renders a menuitem using an information object about the menuitem + * + * @param {Object} groupNode - DOM element node for the menu group + * @param {Object} mi - object with menuitem information + */ + renderMenuitemToGroup (groupNode, mi) { + let tagNode, tagNodeChild, labelNode, nestingNode; + + let menuitemNode = document.createElement('div'); + menuitemNode.setAttribute('role', 'menuitem'); + menuitemNode.classList.add(mi.class); + if (isNotEmptyString(mi.tagName)) { + menuitemNode.classList.add('skip-to-' + mi.tagName.toLowerCase()); + } + menuitemNode.setAttribute('data-id', mi.dataId); + menuitemNode.tabIndex = -1; + if (isNotEmptyString(mi.ariaLabel)) { + menuitemNode.setAttribute('aria-label', mi.ariaLabel); + } - option = 'selected'; - // Create landmarks group - selector = this.getShowMoreLandmarksSelector('all'); - allLandmarks = this.getLandmarks(selector, true); - selector = this.getShowMoreLandmarksSelector('selected'); - selectedLandmarks = this.getLandmarks(selector); - landmarkElements = selectedLandmarks; + // add event handlers + menuitemNode.addEventListener('keydown', this.handleMenuitemKeydown.bind(this)); + menuitemNode.addEventListener('click', this.handleMenuitemClick.bind(this)); + menuitemNode.addEventListener('pointerenter', this.handleMenuitemPointerenter.bind(this)); + + groupNode.appendChild(menuitemNode); + + // add heading level and label + if (mi.class.includes('heading')) { + if (this.config.enableHeadingLevelShortcuts) { + tagNode = document.createElement('span'); + tagNodeChild = document.createElement('span'); + tagNodeChild.appendChild(document.createTextNode(mi.level)); + tagNode.append(tagNodeChild); + tagNode.appendChild(document.createTextNode(')')); + tagNode.classList.add('level'); + menuitemNode.append(tagNode); + } else { + menuitemNode.classList.add('no-level'); + } + menuitemNode.setAttribute('data-level', mi.level); + if (isNotEmptyString(mi.tagName)) { + menuitemNode.classList.add('skip-to-' + mi.tagName); + } + } - if (option === 'all') { - landmarkElements = allLandmarks; - } + // add nesting level for landmarks + if (mi.class.includes('landmark')) { + menuitemNode.setAttribute('data-nesting', mi.nestingLevel); + menuitemNode.classList.add('skip-to-nesting-level-' + mi.nestingLevel); - groupNode = this.renderMenuitemGroup( - 'id-skip-to-group-landmarks', - this.config.landmarkGroupLabel - ); - this.renderMenuitemsToGroup( - groupNode, - landmarkElements, - this.config.msgNoLandmarksFound - ); - this.renderGroupLabel( - 'id-skip-to-group-landmarks-label', - this.config.landmarkGroupLabel, - landmarkElements.length, - allLandmarks.length - ); - - // Create headings group - selector = this.getShowMoreHeadingsSelector('all'); - allHeadings = this.getHeadings(selector); - selector = this.getShowMoreHeadingsSelector('selected'); - selectedHeadings = this.getHeadings(selector); - headingElements = selectedHeadings; - - if (option === 'all') { - headingElements = allHeadings; - } + if (mi.nestingLevel > 0 && (mi.nestingLevel > this.lastNestingLevel)) { + nestingNode = document.createElement('span'); + nestingNode.classList.add('nesting'); + menuitemNode.append(nestingNode); + } + this.lastNestingLevel = mi.nestingLevel; + } - groupNode = this.renderMenuitemGroup( - 'id-skip-to-group-headings', - this.config.headingGroupLabel - ); - this.renderMenuitemsToGroup( - groupNode, - headingElements, - this.config.msgNoHeadingsFound - ); - this.renderGroupLabel( - 'id-skip-to-group-headings-label', - this.config.headingGroupLabel, - headingElements.length, - allHeadings.length - ); - - // Create actions, if enabled - if (this.config.enableActions) { - groupNode = this.renderMenuitemGroup( - 'id-skip-to-group-actions', - this.config.actionGroupLabel - ); - hasNoAction1 = this.renderActionMoreLandmarks(groupNode); - hasNoAction2 = this.renderActionMoreHeadings(groupNode); - // Remove action label if no actions are available - if (hasNoAction1 && hasNoAction2) { - this.removeMenuitemGroup('id-skip-to-group-actions'); + labelNode = document.createElement('span'); + labelNode.appendChild(document.createTextNode(mi.name)); + labelNode.classList.add('label'); + menuitemNode.append(labelNode); + + return menuitemNode; + } + + /* + * @method renderMenuitemsToGroup + * + * @desc Renders either the landmark region or headings menu group + * + * @param {Object} groupNode - DOM element node for the menu group + * @param {Array} menuitems - Array of objects with menu item information + * @param {String} msgNoItesmFound - Message to render if there are no menu items + */ + renderMenuitemsToGroup(groupNode, menuitems, msgNoItemsFound) { + groupNode.innerHTML = ''; + this.lastNestingLevel = 0; + + if (menuitems.length === 0) { + const item = {}; + item.name = msgNoItemsFound; + item.tagName = ''; + item.class = 'no-items'; + item.dataId = ''; + this.renderMenuitemToGroup(groupNode, item); + } + else { + for (let i = 0; i < menuitems.length; i += 1) { + this.renderMenuitemToGroup(groupNode, menuitems[i]); + } } } - // Update list of menuitems - this.updateMenuitems(); - }, - - // - // Menu scripting event functions and utilities - // + /* + * @method renderMenu + * + * @desc + */ + renderMenu() { + // remove landmark menu items + while (this.landmarkGroupNode.lastElementChild) { + this.landmarkGroupNode.removeChild(this.landmarkGroupNode.lastElementChild); + } + // remove heading menu items + while (this.headingGroupNode.lastElementChild) { + this.headingGroupNode.removeChild(this.headingGroupNode.lastElementChild); + } - setFocusToMenuitem: function (menuitem) { - if (menuitem) { - menuitem.focus(); + // Create landmarks group + const [landmarkElements, headingElements] = getLandmarksAndHeadings(this.config); + this.renderMenuitemsToGroup(this.landmarkGroupNode, landmarkElements, this.config.msgNoLandmarksFound); + this.renderMenuitemsToGroup(this.headingGroupNode, headingElements, this.config.msgNoHeadingsFound); + + // Update list of menuitems + this.updateMenuitems(); + } + + // + // Menu scripting helper functions and event handlers + // + + /* + * @method setFocusToMenuitem + * + * @desc Moves focus to menu item + * + * @param {Object} menuItem - DOM element node used as a menu item + */ + setFocusToMenuitem(menuitem) { + if (menuitem) { + menuitem.focus(); + } } - }, - - setFocusToFirstMenuitem: function () { - this.setFocusToMenuitem(this.firstMenuitem); - }, - - setFocusToLastMenuitem: function () { - this.setFocusToMenuitem(this.lastMenuitem); - }, - setFocusToPreviousMenuitem: function (menuitem) { - let newMenuitem, index; - if (menuitem === this.firstMenuitem) { - newMenuitem = this.lastMenuitem; - } else { - index = this.menuitemNodes.indexOf(menuitem); - newMenuitem = this.menuitemNodes[index - 1]; - } - this.setFocusToMenuitem(newMenuitem); - return newMenuitem; - }, - - setFocusToNextMenuitem: function (menuitem) { - let newMenuitem, index; - if (menuitem === this.lastMenuitem) { - newMenuitem = this.firstMenuitem; - } else { - index = this.menuitemNodes.indexOf(menuitem); - newMenuitem = this.menuitemNodes[index + 1]; - } - this.setFocusToMenuitem(newMenuitem); - return newMenuitem; - }, - - setFocusByFirstCharacter: function (menuitem, char) { - let start, index; - if (char.length > 1) { - return; - } - char = char.toLowerCase(); + /* + * @method setFocusToFirstMenuitem + * + * @desc Moves focus to first menu item + */ + setFocusToFirstMenuitem() { + this.setFocusToMenuitem(this.firstMenuitem); + } + + /* + * @method setFocusToLastMenuitem + * + * @desc Moves focus to last menu item + */ + setFocusToLastMenuitem() { + this.setFocusToMenuitem(this.lastMenuitem); + } + + /* + * @method setFocusToPreviousMenuitem + * + * @desc Moves focus to previous menu item + * + * @param {Object} menuItem - DOM element node + */ + setFocusToPreviousMenuitem(menuitem) { + let newMenuitem, index; + if (menuitem === this.firstMenuitem) { + newMenuitem = this.lastMenuitem; + } else { + index = this.menuitemNodes.indexOf(menuitem); + newMenuitem = this.menuitemNodes[index - 1]; + } + this.setFocusToMenuitem(newMenuitem); + return newMenuitem; + } + + /* + * @method setFocusToNextMenuitem + * + * @desc Moves focus to next menu item + * + * @param {Object} menuItem - DOM element node + */ + setFocusToNextMenuitem(menuitem) { + let newMenuitem, index; + if (menuitem === this.lastMenuitem) { + newMenuitem = this.firstMenuitem; + } else { + index = this.menuitemNodes.indexOf(menuitem); + newMenuitem = this.menuitemNodes[index + 1]; + } + this.setFocusToMenuitem(newMenuitem); + return newMenuitem; + } + + /* + * @method setFocusByFirstCharacter + * + * @desc Moves focus to next menu item based on shortcut key + * + * @param {Object} menuItem - Starting DOM element node + * @param {String} char - Shortcut key to identify the + * next menu item + */ + setFocusByFirstCharacter(menuitem, char) { + let start, index; + if (char.length > 1) { + return; + } + char = char.toLowerCase(); - // Get start index for search based on position of currentItem - start = this.menuitemNodes.indexOf(menuitem) + 1; - if (start >= this.menuitemNodes.length) { - start = 0; - } + // Get start index for search based on position of currentItem + start = this.menuitemNodes.indexOf(menuitem) + 1; + if (start >= this.menuitemNodes.length) { + start = 0; + } - // Check remaining items in the menu - index = this.firstChars.indexOf(char, start); + // Check remaining items in the menu + index = this.firstChars.indexOf(char, start); - // If not found in remaining items, check headings - if (index === -1) { - index = this.headingLevels.indexOf(char, start); - } + // If not found in remaining items, check headings + if (index === -1) { + index = this.headingLevels.indexOf(char, start); + } - // If not found in remaining items, check from beginning - if (index === -1) { - index = this.firstChars.indexOf(char, 0); - } + // If not found in remaining items, check from beginning + if (index === -1) { + index = this.firstChars.indexOf(char, 0); + } - // If not found in remaining items, check headings from beginning - if (index === -1) { - index = this.headingLevels.indexOf(char, 0); - } + // If not found in remaining items, check headings from beginning + if (index === -1) { + index = this.headingLevels.indexOf(char, 0); + } - // If match was found... - if (index > -1) { - this.setFocusToMenuitem(this.menuitemNodes[index]); + // If match was found... + if (index > -1) { + this.setFocusToMenuitem(this.menuitemNodes[index]); + } } - }, - // Utilities - getIndexFirstChars: function (startIndex, char) { - for (let i = startIndex; i < this.firstChars.length; i += 1) { - if (char === this.firstChars[i]) { - return i; + /* + * @method getIndexFirstChars + * + * @desc + * + * @returns {Number} + */ + getIndexFirstChars(startIndex, char) { + for (let i = startIndex; i < this.firstChars.length; i += 1) { + if (char === this.firstChars[i]) { + return i; + } } - } - return -1; - }, - // Popup menu methods - openPopup: function () { - this.menuNode.setAttribute('aria-busy', 'true'); - const h = (80 * window.innerHeight) / 100; - this.menuNode.style.maxHeight = h + 'px'; - this.renderMenu(); - this.menuNode.style.display = 'block'; - this.menuNode.removeAttribute('aria-busy'); - this.buttonNode.setAttribute('aria-expanded', 'true'); - }, - - closePopup: function () { - if (this.isOpen()) { - this.buttonNode.setAttribute('aria-expanded', 'false'); - this.menuNode.style.display = 'none'; - } - }, - isOpen: function () { - return this.buttonNode.getAttribute('aria-expanded') === 'true'; - }, - // Menu event handlers - handleFocusin: function () { - this.domNode.classList.add('focus'); - }, - handleFocusout: function () { - this.domNode.classList.remove('focus'); - }, - handleButtonKeydown: function (event) { - let key = event.key, - flag = false; - switch (key) { - case ' ': - case 'Enter': - case 'ArrowDown': - case 'Down': - this.openPopup(); - this.setFocusToFirstMenuitem(); - flag = true; - break; - case 'Esc': - case 'Escape': - this.closePopup(); - this.buttonNode.focus(); - flag = true; - break; - case 'Up': - case 'ArrowUp': - this.openPopup(); - this.setFocusToLastMenuitem(); - flag = true; - break; - default: - break; - } - if (flag) { - event.stopPropagation(); - event.preventDefault(); - } - }, - handleButtonClick: function (event) { - if (this.isOpen()) { - this.closePopup(); - this.buttonNode.focus(); - } else { - this.openPopup(); - this.setFocusToFirstMenuitem(); - } - event.stopPropagation(); - event.preventDefault(); - }, - handleDocumentKeydown: function (event) { - let key = event.key, - flag = false; - - let altPressed = - this.usesAltKey && - event.altKey && - !event.ctrlKey && - !event.shiftKey && - !event.metaKey; - - let optionPressed = - this.usesOptionKey && - event.altKey && - !event.ctrlKey && - !event.shiftKey && - !event.metaKey; - - if ( - (optionPressed && this.config.optionShortcut === key) || - (altPressed && this.config.altShortcut === key) - ) { - this.openPopup(); - this.setFocusToFirstMenuitem(); - flag = true; - } - if (flag) { - event.stopPropagation(); - event.preventDefault(); - } - }, - skipToElement: function (menuitem) { - const isVisible = this.isVisible; - let focusNode = false; - let scrollNode = false; - let elem; - - function findVisibleElement(e, selectors) { - if (e) { - for (let j = 0; j < selectors.length; j += 1) { - const elems = e.querySelectorAll(selectors[j]); - for (let i = 0; i < elems.length; i += 1) { - if (isVisible(elems[i])) { - return elems[i]; - } - } + return -1; + } + + /* + * @method openPopup + * + * @desc Opens the memu of landmark regions and headings + */ + openPopup() { + this.menuNode.setAttribute('aria-busy', 'true'); + const h = (80 * window.innerHeight) / 100; + this.menuNode.style.maxHeight = h + 'px'; + this.renderMenu(); + this.menuNode.style.display = 'block'; + const buttonRect = this.buttonNode.getBoundingClientRect(); + const menuRect = this.menuNode.getBoundingClientRect(); + const diff = window.innerWidth - buttonRect.left - menuRect.width - 8; + if (diff < 0) { + if (buttonRect.left + diff < 0) { + this.menuNode.style.left = (8 - buttonRect.left) + 'px'; + } else { + this.menuNode.style.left = diff + 'px'; } } - return e; + this.menuNode.removeAttribute('aria-busy'); + this.buttonNode.setAttribute('aria-expanded', 'true'); } - const searchSelectors = [ - 'input', - 'button', - 'input[type=button]', - 'input[type=submit]', - 'a', - ]; - const navigationSelectors = [ - 'a', - 'input', - 'button', - 'input[type=button]', - 'input[type=submit]', - ]; - const landmarkSelectors = [ - 'h1', - 'h2', - 'h3', - 'h4', - 'h5', - 'h6', - 'section', - 'article', - 'p', - 'li', - 'a', - ]; - - const isLandmark = menuitem.classList.contains('landmark'); - const isSearch = menuitem.classList.contains('skip-to-search'); - const isNav = menuitem.classList.contains('skip-to-nav'); - - elem = document.querySelector( - '[data-skip-to-id="' + menuitem.getAttribute('data-id') + '"]' - ); - - if (elem) { - if (isSearch) { - focusNode = findVisibleElement(elem, searchSelectors); - } - if (isNav) { - focusNode = findVisibleElement(elem, navigationSelectors); - } - if (focusNode && this.isVisible(focusNode)) { - focusNode.focus(); - focusNode.scrollIntoView({ block: 'nearest' }); - } else { - if (isLandmark) { - scrollNode = findVisibleElement(elem, landmarkSelectors); - if (scrollNode) { - elem = scrollNode; - } - } - elem.tabIndex = -1; - elem.focus(); - elem.scrollIntoView({ block: 'center' }); + /* + * @method closePopup + * + * @desc Closes the memu of landmark regions and headings + */ + closePopup() { + if (this.isOpen()) { + this.buttonNode.setAttribute('aria-expanded', 'false'); + this.menuNode.style.display = 'none'; } } - }, - handleMenuitemAction: function (tgt) { - let option; - switch (tgt.getAttribute('data-id')) { - case '': - // this means there were no headings or landmarks in the list - break; - case 'skip-to-more-headings': - option = tgt.getAttribute('data-show-heading-option'); - this.updateHeadingGroupMenuitems(option); - break; + /* + * @method isOpen + * + * @desc Returns true if menu is open, otherwise false + * + * @returns {Boolean} see @desc + */ + isOpen() { + return this.buttonNode.getAttribute('aria-expanded') === 'true'; + } - case 'skip-to-more-landmarks': - option = tgt.getAttribute('data-show-landmark-option'); - this.updateLandmarksGroupMenuitems(option); - break; + // Menu event handlers - default: - this.closePopup(); - this.skipToElement(tgt); - break; + handleFocusin() { + this.containerNode.classList.add('focus'); } - }, - handleMenuitemKeydown: function (event) { - let tgt = event.currentTarget, - key = event.key, - flag = false; - - function isPrintableCharacter(str) { - return str.length === 1 && str.match(/\S/); - } - if (event.ctrlKey || event.altKey || event.metaKey) { - return; + + handleFocusout() { + this.containerNode.classList.remove('focus'); } - if (event.shiftKey) { - if (isPrintableCharacter(key)) { - this.setFocusByFirstCharacter(tgt, key); - flag = true; - } - if (event.key === 'Tab') { - this.buttonNode.focus(); - this.closePopup(); - flag = true; - } - } else { + + handleButtonKeydown(event) { + let key = event.key, + flag = false; switch (key) { - case 'Enter': case ' ': - this.handleMenuitemAction(tgt); + case 'Enter': + case 'ArrowDown': + case 'Down': + this.openPopup(); + this.setFocusToFirstMenuitem(); flag = true; break; case 'Esc': @@ -1255,391 +2351,399 @@ break; case 'Up': case 'ArrowUp': - this.setFocusToPreviousMenuitem(tgt); - flag = true; - break; - case 'ArrowDown': - case 'Down': - this.setFocusToNextMenuitem(tgt); - flag = true; - break; - case 'Home': - case 'PageUp': - this.setFocusToFirstMenuitem(); - flag = true; - break; - case 'End': - case 'PageDown': + this.openPopup(); this.setFocusToLastMenuitem(); flag = true; break; - case 'Tab': - this.closePopup(); - break; - default: - if (isPrintableCharacter(key)) { - this.setFocusByFirstCharacter(tgt, key); - flag = true; - } - break; + } + if (flag) { + event.stopPropagation(); + event.preventDefault(); } } - if (flag) { - event.stopPropagation(); - event.preventDefault(); - } - }, - handleMenuitemClick: function (event) { - this.handleMenuitemAction(event.currentTarget); - event.stopPropagation(); - event.preventDefault(); - }, - handleMenuitemPointerenter: function (event) { - let tgt = event.currentTarget; - tgt.focus(); - }, - handleBackgroundPointerdown: function (event) { - if (!this.domNode.contains(event.target)) { + + handleButtonClick(event) { if (this.isOpen()) { this.closePopup(); this.buttonNode.focus(); - } - } - }, - // methods to extract landmarks, headings and ids - normalizeName: function (name) { - if (typeof name === 'string') - return name.replace(/\w\S*/g, function (txt) { - return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase(); - }); - return ''; - }, - getTextContent: function (elem) { - function getText(e, strings) { - // If text node get the text and return - if (e.nodeType === Node.TEXT_NODE) { - strings.push(e.data); } else { - // if an element for through all the children elements looking for text - if (e.nodeType === Node.ELEMENT_NODE) { - // check to see if IMG or AREA element and to use ALT content if defined - let tagName = e.tagName.toLowerCase(); - if (tagName === 'img' || tagName === 'area') { - if (e.alt) { - strings.push(e.alt); - } - } else { - let c = e.firstChild; - while (c) { - getText(c, strings); - c = c.nextSibling; - } // end loop - } - } - } - } // end function getStrings - // Create return object - let str = 'Test', - strings = []; - getText(elem, strings); - if (strings.length) str = strings.join(' '); - return str; - }, - getAccessibleName: function (elem) { - let labelledbyIds = elem.getAttribute('aria-labelledby'), - label = elem.getAttribute('aria-label'), - title = elem.getAttribute('title'), - name = ''; - if (labelledbyIds && labelledbyIds.length) { - let str, - strings = [], - ids = labelledbyIds.split(' '); - if (!ids.length) ids = [labelledbyIds]; - for (let i = 0, l = ids.length; i < l; i += 1) { - let e = document.getElementById(ids[i]); - if (e) str = this.getTextContent(e); - if (str && str.length) strings.push(str); - } - name = strings.join(' '); - } else { - if (this.isNotEmptyString(label)) { - name = label; - } else { - if (this.isNotEmptyString(title)) { - name = title; - } - } - } - return name; - }, - isVisible: function (element) { - function isVisibleRec(el) { - if (el.parentNode.nodeType !== 1 || el.parentNode.tagName === 'BODY') { - return true; - } - const computedStyle = window.getComputedStyle(el); - const display = computedStyle.getPropertyValue('display'); - const visibility = computedStyle.getPropertyValue('visibility'); - const hidden = el.getAttribute('hidden'); - if (display === 'none' || visibility === 'hidden' || hidden !== null) { - return false; + this.openPopup(); + this.setFocusToFirstMenuitem(); } - const isVis = isVisibleRec(el.parentNode); - return isVis; + event.stopPropagation(); + event.preventDefault(); } - return isVisibleRec(element); - }, - getHeadings: function (targets) { - let dataId, level; - if (typeof targets !== 'string') { - targets = this.config.headings; - } - let headingElementsArr = []; - if (typeof targets !== 'string' || targets.length === 0) return; - const headings = document.querySelectorAll(targets); - for (let i = 0, len = headings.length; i < len; i += 1) { - let heading = headings[i]; - let role = heading.getAttribute('role'); - if (typeof role === 'string' && role === 'presentation') continue; + handleDocumentKeydown (event) { + let key = event.key, + flag = false; + + let altPressed = + this.usesAltKey && + event.altKey && + !event.ctrlKey && + !event.shiftKey && + !event.metaKey; + + let optionPressed = + this.usesOptionKey && + event.altKey && + !event.ctrlKey && + !event.shiftKey && + !event.metaKey; + if ( - this.isVisible(heading) && - this.isNotEmptyString(heading.innerHTML) + (optionPressed && this.config.optionShortcut === key) || + (altPressed && this.config.altShortcut === key) ) { - if (heading.hasAttribute('data-skip-to-id')) { - dataId = heading.getAttribute('data-skip-to-id'); - } else { - heading.setAttribute('data-skip-to-id', this.skipToIdIndex); - dataId = this.skipToIdIndex; - } - level = heading.tagName.substring(1); - const headingItem = {}; - headingItem.dataId = dataId.toString(); - headingItem.class = 'heading'; - headingItem.name = this.getTextContent(heading); - headingItem.ariaLabel = headingItem.name + ', '; - headingItem.ariaLabel += this.config.headingLevelLabel + ' ' + level; - headingItem.tagName = heading.tagName.toLowerCase(); - headingItem.role = 'heading'; - headingItem.level = level; - headingElementsArr.push(headingItem); - this.skipToIdIndex += 1; + this.openPopup(); + this.setFocusToFirstMenuitem(); + flag = true; } - } - return headingElementsArr; - }, - getLocalizedLandmarkName: function (tagName, name) { - let n; - switch (tagName) { - case 'aside': - n = this.config.asideLabel; - break; - case 'footer': - n = this.config.footerLabel; - break; - case 'form': - n = this.config.formLabel; - break; - case 'header': - n = this.config.headerLabel; - break; - case 'main': - n = this.config.mainLabel; - break; - case 'nav': - n = this.config.navLabel; - break; - case 'section': - case 'region': - n = this.config.regionLabel; - break; - case 'search': - n = this.config.searchLabel; - break; - // When an ID is used as a selector, assume for main content - default: - n = tagName; - break; - } - if (this.isNotEmptyString(name)) { - n += ': ' + name; - } - return n; - }, - getNestingLevel: function (landmark, landmarks) { - let nestingLevel = 0; - let parentNode = landmark.parentNode; - while (parentNode) { - for (let i = 0; i < landmarks.length; i += 1) { - if (landmarks[i] === parentNode) { - nestingLevel += 1; - // no more than 3 levels of nesting supported - if (nestingLevel === 3) { - return 3; - } - continue; - } + if (flag) { + event.stopPropagation(); + event.preventDefault(); } - parentNode = parentNode.parentNode; } - return nestingLevel; - }, - getLandmarks: function (targets, allFlag) { - if (typeof allFlag !== 'boolean') { - allFlag = false; - } - if (typeof targets !== 'string') { - targets = this.config.landmarks; + + handleMenuitemAction(tgt) { + switch (tgt.getAttribute('data-id')) { + case '': + // this means there were no headings or landmarks in the list + break; + + default: + this.closePopup(); + skipToElement(tgt); + break; + } } - let landmarks = document.querySelectorAll(targets); - let mainElements = []; - let searchElements = []; - let navElements = []; - let asideElements = []; - let footerElements = []; - let regionElements = []; - let otherElements = []; - let allLandmarks = []; - let dataId = ''; - for (let i = 0, len = landmarks.length; i < len; i += 1) { - let landmark = landmarks[i]; - // if skipto is a landmark don't include it in the list - if (landmark === this.domNode) { - continue; - } - let role = landmark.getAttribute('role'); - let tagName = landmark.tagName.toLowerCase(); - if (typeof role === 'string' && role === 'presentation') continue; - if (this.isVisible(landmark)) { - if (!role) role = tagName; - let name = this.getAccessibleName(landmark); - if (typeof name !== 'string') { - name = ''; + + handleMenuitemKeydown(event) { + let tgt = event.currentTarget, + key = event.key, + flag = false; + + function isPrintableCharacter(str) { + return str.length === 1 && str.match(/\S/); + } + if (event.ctrlKey || event.altKey || event.metaKey) { + return; + } + if (event.shiftKey) { + if (isPrintableCharacter(key)) { + this.setFocusByFirstCharacter(tgt, key); + flag = true; } - // normalize tagNames - switch (role) { - case 'banner': - tagName = 'header'; - break; - case 'complementary': - tagName = 'aside'; + if (event.key === 'Tab') { + this.buttonNode.focus(); + this.closePopup(); + flag = true; + } + } else { + switch (key) { + case 'Enter': + case ' ': + this.handleMenuitemAction(tgt); + flag = true; break; - case 'contentinfo': - tagName = 'footer'; + case 'Esc': + case 'Escape': + this.closePopup(); + this.buttonNode.focus(); + flag = true; break; - case 'form': - tagName = 'form'; + case 'Up': + case 'ArrowUp': + this.setFocusToPreviousMenuitem(tgt); + flag = true; break; - case 'main': - tagName = 'main'; + case 'ArrowDown': + case 'Down': + this.setFocusToNextMenuitem(tgt); + flag = true; break; - case 'navigation': - tagName = 'nav'; + case 'Home': + case 'PageUp': + this.setFocusToFirstMenuitem(); + flag = true; break; - case 'region': - tagName = 'section'; + case 'End': + case 'PageDown': + this.setFocusToLastMenuitem(); + flag = true; break; - case 'search': - tagName = 'search'; + case 'Tab': + this.closePopup(); break; default: + if (isPrintableCharacter(key)) { + this.setFocusByFirstCharacter(tgt, key); + flag = true; + } break; } - // if using ID for selectQuery give tagName as main - if ( - [ - 'aside', - 'footer', - 'form', - 'header', - 'main', - 'nav', - 'section', - 'search', - ].indexOf(tagName) < 0 - ) { - tagName = 'main'; + } + if (flag) { + event.stopPropagation(); + event.preventDefault(); + } + } + + handleMenuitemClick(event) { + this.handleMenuitemAction(event.currentTarget); + event.stopPropagation(); + event.preventDefault(); + } + + handleMenuitemPointerenter(event) { + let tgt = event.currentTarget; + tgt.focus(); + } + + handleBackgroundPointerdown(event) { + if (!this.containerNode.contains(event.target)) { + if (this.isOpen()) { + this.closePopup(); + this.buttonNode.focus(); } - if (landmark.hasAttribute('aria-roledescription')) { - tagName = landmark - .getAttribute('aria-roledescription') - .trim() - .replace(' ', '-'); + } + } + } + + (function() { + + const SkipTo = { + skipToId: 'id-skip-to', + domNode: null, + buttonNode: null, + menuNode: null, + menuitemNodes: [], + firstMenuitem: false, + lastMenuitem: false, + firstChars: [], + headingLevels: [], + skipToIdIndex: 1, + // Default configuration values + config: { + // Feature switches + enableHeadingLevelShortcuts: true, + + // Customization of button and menu + altShortcut: '0', // default shortcut key is the number zero + optionShortcut: 'º', // default shortcut key character associated with option+0 on mac + attachElement: 'body', + displayOption: 'static', // options: static (default), popup, fixed + // container element, use containerClass for custom styling + containerElement: 'div', + containerRole: '', + customClass: '', + + // Button labels and messages + buttonLabel: 'Skip To Content', + altLabel: 'Alt', + optionLabel: 'Option', + buttonShortcut: ' ($modifier+$key)', + altButtonAriaLabel: 'Skip To Content, shortcut Alt plus $key', + optionButtonAriaLabel: 'Skip To Content, shortcut Option plus $key', + + // Menu labels and messages + menuLabel: 'Landmarks and Headings', + landmarkGroupLabel: 'Landmark Regions', + headingGroupLabel: 'Headings', + headingLevelLabel: 'Heading level', + mainLabel: 'main', + searchLabel: 'search', + navLabel: 'navigation', + regionLabel: 'region', + asideLabel: 'complementary', + footerLabel: 'contentinfo', + headerLabel: 'banner', + formLabel: 'form', + msgNoLandmarksFound: 'No landmarks found', + msgNoHeadingsFound: 'No headings found', + + // Selectors for landmark and headings sections + landmarks: 'main search navigation complementary', + headings: 'main h1 h2', + + // Place holders for configuration + colorTheme: 'aria', + fontFamily: '', + fontSize: '', + positionLeft: '', + mediaBreakPoint: '', + menuTextColor: '', + menuBackgroundColor: '', + menuitemFocusTextColor: '', + menuitemFocusBackgroundColor: '', + focusBorderColor: '', + buttonTextColor: '', + buttonBackgroundColor: '', + zIndex: '', + }, + colorThemes: { + 'default': { + fontFamily: 'inherit', + fontSize: 'inherit', + positionLeft: '46%', + mediaBreakPoint: '540', + menuTextColor: '#1a1a1a', + menuBackgroundColor: '#dcdcdc', + menuitemFocusTextColor: '#eeeeee', + menuitemFocusBackgroundColor: '#1a1a1a', + focusBorderColor: '#1a1a1a', + buttonTextColor: '#1a1a1a', + buttonBackgroundColor: '#eeeeee', + zIndex: '100000', + }, + 'aria': { + hostnameSelector: 'w3.org', + pathnameSelector: 'ARIA/apg', + fontFamily: 'sans-serif', + fontSize: '10pt', + positionLeft: '7%', + menuTextColor: '#000', + menuBackgroundColor: '#def', + menuitemFocusTextColor: '#fff', + menuitemFocusBackgroundColor: '#005a9c', + focusBorderColor: '#005a9c', + buttonTextColor: '#005a9c', + buttonBackgroundColor: '#ddd', + }, + 'illinois': { + hostnameSelector: 'illinois.edu', + menuTextColor: '#00132c', + menuBackgroundColor: '#cad9ef', + menuitemFocusTextColor: '#eeeeee', + menuitemFocusBackgroundColor: '#00132c', + focusBorderColor: '#ff552e', + buttonTextColor: '#444444', + buttonBackgroundColor: '#dddede', + }, + 'skipto': { + hostnameSelector: 'skipto-landmarks-headings.github.io', + fontSize: '14px', + menuTextColor: '#00132c', + menuBackgroundColor: '#cad9ef', + menuitemFocusTextColor: '#eeeeee', + menuitemFocusBackgroundColor: '#00132c', + focusBorderColor: '#ff552e', + buttonTextColor: '#444444', + buttonBackgroundColor: '#dddede', + }, + 'uic': { + hostnameSelector: 'uic.edu', + menuTextColor: '#001e62', + menuBackgroundColor: '#f8f8f8', + menuitemFocusTextColor: '#ffffff', + menuitemFocusBackgroundColor: '#001e62', + focusBorderColor: '#d50032', + buttonTextColor: '#ffffff', + buttonBackgroundColor: '#001e62', + }, + 'uillinois': { + hostnameSelector: 'uillinois.edu', + menuTextColor: '#001e62', + menuBackgroundColor: '#e8e9ea', + menuitemFocusTextColor: '#f8f8f8', + menuitemFocusBackgroundColor: '#13294b', + focusBorderColor: '#dd3403', + buttonTextColor: '#e8e9ea', + buttonBackgroundColor: '#13294b', + }, + 'uis': { + hostnameSelector: 'uis.edu', + menuTextColor: '#036', + menuBackgroundColor: '#fff', + menuitemFocusTextColor: '#fff', + menuitemFocusBackgroundColor: '#036', + focusBorderColor: '#dd3444', + buttonTextColor: '#fff', + buttonBackgroundColor: '#036', + } + }, + + /* + * @method init + * + * @desc Initializes the skipto button and menu with default and user + * defined options + * + * @param {object} config - Reference to configuration object + * can be undefined + */ + init: function(config) { + let node; + + // Check if skipto is already loaded + if (document.querySelector('style#' + this.skipToId)) { + return; + } + + let attachElement = document.body; + if (config) { + this.setupConfig(config); + } + if (typeof this.config.attachElement === 'string') { + node = document.querySelector(this.config.attachElement); + if (node && node.nodeType === Node.ELEMENT_NODE) { + attachElement = node; } - if (landmark.hasAttribute('data-skip-to-id')) { - dataId = landmark.getAttribute('data-skip-to-id'); - } else { - landmark.setAttribute('data-skip-to-id', this.skipToIdIndex); - dataId = this.skipToIdIndex; + } + // Add skipto style sheet to document + renderStyleElement(this.colorThemes, this.config, this.skipToId); + + new SkiptoMenuButton(attachElement, this.config, this.skipToId); + }, + + /* + * @method setupConfig + * + * @desc Get configuration information from user configuration to change + * default settings + * + * @param {object} appConfig - Javascript object with configuration information + */ + setupConfig: function(appConfig) { + let appConfigSettings; + // Support version 4.1 configuration object structure + // If found use it + if ((typeof appConfig.settings === 'object') && + (typeof appConfig.settings.skipTo === 'object')) { + appConfigSettings = appConfig.settings.skipTo; + } + else { + // Version 5.0 removes the requirement for the "settings" and "skipto" properties + // to reduce the complexity of configuring skipto + if ((typeof appConfig === 'undefined') || + (typeof appConfig !== 'object')) { + appConfigSettings = {}; } - const landmarkItem = {}; - landmarkItem.dataId = dataId.toString(); - landmarkItem.class = 'landmark'; - landmarkItem.hasName = name.length > 0; - landmarkItem.name = this.getLocalizedLandmarkName(tagName, name); - landmarkItem.tagName = tagName; - landmarkItem.nestingLevel = 0; - if (allFlag) { - landmarkItem.nestingLevel = this.getNestingLevel( - landmark, - landmarks - ); + else { + appConfigSettings = appConfig; } - this.skipToIdIndex += 1; - allLandmarks.push(landmarkItem); + } - // For sorting landmarks into groups - switch (tagName) { - case 'main': - mainElements.push(landmarkItem); - break; - case 'search': - searchElements.push(landmarkItem); - break; - case 'nav': - navElements.push(landmarkItem); - break; - case 'aside': - asideElements.push(landmarkItem); - break; - case 'footer': - footerElements.push(landmarkItem); - break; - case 'section': - // Regions must have accessible name to be included - if (landmarkItem.hasName) { - regionElements.push(landmarkItem); - } - break; - default: - otherElements.push(landmarkItem); - break; + for (const name in appConfigSettings) { + //overwrite values of our local config, based on the external config + if ((typeof this.config[name] !== 'undefined') && + ((typeof appConfigSettings[name] === 'string') && + (appConfigSettings[name].length > 0 ) || + typeof appConfigSettings[name] === 'boolean') + ) { + this.config[name] = appConfigSettings[name]; + } else { + console.warn('[SkipTo]: Unsuported or deprecated configuration option "' + name + '".'); } } } - if (allFlag) { - return allLandmarks; - } - return [].concat( - mainElements, - searchElements, - navElements, - asideElements, - regionElements, - footerElements, - otherElements - ); - }, - }; - // Initialize skipto menu button with onload event - window.addEventListener('load', function () { - SkipTo.init( - window.SkipToConfig || - (typeof window.Joomla === 'object' && - typeof window.Joomla.getOptions === 'function' - ? window.Joomla.getOptions('skipto-settings', {}) - : {}) - ); - }); + }; + + // Initialize skipto menu button with onload event + window.addEventListener('load', function() { + SkipTo.init(window.SkipToConfig); + }); + })(); + })(); -/*@end @*/