/*
* Copyright 2011-2014 University of Illinois
* Authors: Nicholas Hoyt
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
"use strict";
var _typeof = typeof Symbol === "function" && typeof Symbol.iterator === "symbol" ? function (obj) { return typeof obj; } : function (obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol ? "symbol" : typeof obj; };
/*
* headings.js: highlight heading elements
*/
function initHeadings() {
addPolyfills();
var targetList = [{ selector: "h1", color: "navy", label: "h1" },
{ selector: "h2", color: "olive", label: "h2" },
{ selector: "h3", color: "purple", label: "h3" },
{ selector: "h4", color: "green", label: "h4" },
{ selector: "h5", color: "gray", label: "h5" },
{ selector: "h6", color: "brown", label: "h6" }];
var selectors = targetList.map(function (tgt) {
return tgt.selector;
}).join(', ');
function getInfo(element, target) {
var info = new InfoObject(element, 'HEADING INFO');
info.addProps('level ' + target.label.substring(1));
return info;
}
var params = {
appName: "Headings",
cssClass: getCssClass("Headings"),
msgText: "No heading elements (" + selectors + ") found.",
targetList: targetList,
getInfo: getInfo,
dndFlag: true
};
return new Bookmarklet(params);
}
/*
* landmarks.js: highlight ARIA landmarks
*/
function initLandmarks() {
addPolyfills();
// Filter function called on a list of elements returned by selector
// 'footer, [role="contentinfo"]'. It returns true for the following
// conditions: (1) element IS NOT a footer element; (2) element IS a
// footer element AND IS NOT a descendant of article or section.
function isContentinfo(element) {
if (element.tagName.toLowerCase() !== 'footer') return true;
if (!isDescendantOf(element, ['article', 'section'])) return true;
return false;
}
// Filter function called on a list of elements returned by selector
// 'header, [role="banner"]'. It returns true for the following
// conditions: (1) element IS NOT a header element; (2) element IS a
// header element AND IS NOT a descendant of article or section.
function isBanner(element) {
if (element.tagName.toLowerCase() !== 'header') return true;
if (!isDescendantOf(element, ['article', 'section'])) return true;
return false;
}
var targetList = [{ selector: 'aside:not([role]), [role~="complementary"], [role~="COMPLEMENTARY"]', color: "maroon", label: "complementary" },
{ selector: 'form[aria-labelledby], form[aria-label], form[title], [role~="form"], [role~="form"]', color: "maroon", label: "form" },
{ selector: 'footer, [role~="contentinfo"], [role~="CONTENTINFO"]', filter: isContentinfo, color: "olive", label: "contentinfo" },
{ selector: '[role~="application"], [role~="APPLICATION"]', color: "black", label: "application" },
{ selector: 'nav, [role~="navigation"], [role~="NAVIGATION"]', color: "green", label: "navigation" },
{ selector: '[role~="region"][aria-labelledby], [role~="REGION"][aria-labelledby]', color: "teal", label: "region" },
{ selector: '[role~="region"][aria-label], [role~="REGION"][aria-label]', color: "teal", label: "region" },
{ selector: 'section[aria-labelledby], section[aria-label]', color: "teal", label: "region" },
{ selector: 'header, [role~="banner"], [role~="BANNER"]', filter: isBanner, color: "gray", label: "banner" },
{ selector: '[role~="search"], [role~="SEARCH"]', color: "purple", label: "search" },
{ selector: 'main, [role~="main"], [role~="MAIN"]', color: "navy", label: "main" }];
var selectors = targetList.map(function (tgt) {
return '
' + tgt.selector + '';
}).join('');
function getInfo(element, target) {
return new InfoObject(element, 'LANDMARK INFO');
}
var params = {
appName: "Landmarks",
cssClass: getCssClass("Landmarks"),
msgText: "No elements with ARIA Landmark roles found: ",
targetList: targetList,
getInfo: getInfo,
dndFlag: true
};
return new Bookmarklet(params);
}
/*
* Bookmarklet.js
*/
/* eslint no-console: 0 */
function logVersionInfo(appName) {
console.log(getTitle() + ' : v' + getVersion() + ' : ' + appName);
}
function Bookmarklet(params) {
var globalName = getGlobalName(params.appName);
// use singleton pattern
if (_typeof(window[globalName]) === 'object') return window[globalName];
this.appName = params.appName;
this.cssClass = params.cssClass;
this.msgText = params.msgText;
this.params = params;
this.show = false;
var dialog = new MessageDialog();
window.addEventListener('resize', function () {
removeNodes(this.cssClass);
dialog.resize();
this.show = false;
});
window[globalName] = this;
logVersionInfo(this.appName);
}
Bookmarklet.prototype.run = function () {
var dialog = new MessageDialog();
dialog.hide();
this.show = !this.show;
if (this.show) {
if (addNodes(this.params) === 0) {
dialog.show(this.appName, this.msgText);
this.show = false;
}
} else {
removeNodes(this.cssClass);
}
return this.show;
};
/*
* InfoObject.js
*/
/*
* nameIncludesDescription: Determine whether accName object's name
* property includes the accDesc object's name property content.
*/
function nameIncludesDescription(accName, accDesc) {
if (accName === null || accDesc === null) return false;
var name = accName.name,
desc = accDesc.name;
if (typeof name === 'string' && typeof desc === 'string') {
return name.toLowerCase().includes(desc.toLowerCase());
}
return false;
}
function InfoObject(element, title) {
this.title = title;
this.element = getElementInfo(element);
this.grpLabels = getGroupingLabels(element);
this.accName = getAccessibleName(element);
this.accDesc = getAccessibleDesc(element);
this.role = getAriaRole(element);
// Ensure that accessible description is not a duplication
// of accessible name content. If it is, nullify the desc.
if (nameIncludesDescription(this.accName, this.accDesc)) {
this.accDesc = null;
}
}
InfoObject.prototype.addProps = function (val) {
this.props = val;
};
/*
* constants.js
*/
var CONSTANTS = {};
Object.defineProperty(CONSTANTS, 'classPrefix', { value: 'a11yGfdXALm' });
Object.defineProperty(CONSTANTS, 'globalPrefix', { value: 'a11y' });
Object.defineProperty(CONSTANTS, 'title', { value: 'oaa-tools/bookmarklets' });
Object.defineProperty(CONSTANTS, 'version', { value: '0.2.2' });
function getCssClass(appName) {
var prefix = CONSTANTS.classPrefix;
switch (appName) {
case 'Forms':
return prefix + '0';
case 'Headings':
return prefix + '1';
case 'Images':
return prefix + '2';
case 'Landmarks':
return prefix + '3';
case 'Lists':
return prefix + '4';
case 'Interactive':
return prefix + '5';
}
return 'unrecognizedName';
}
function getGlobalName(appName) {
return CONSTANTS.globalPrefix + appName;
}
function getTitle() {
return CONSTANTS.title;
}
function getVersion() {
return CONSTANTS.version;
}
/*
* dialog.js: functions for creating, modifying and deleting message dialog
*/
/*
* setBoxGeometry: Set the width and position of message dialog based on
* the width of the browser window. Called by functions resizeMessage and
* createMsgOverlay.
*/
function setBoxGeometry(dialog) {
var width = window.innerWidth / 3.2;
var left = window.innerWidth / 2 - width / 2;
var scroll = getScrollOffsets();
dialog.style.width = width + "px";
dialog.style.left = scroll.x + left + "px";
dialog.style.top = scroll.y + 30 + "px";
}
/*
* createMsgDialog: Construct and position the message dialog whose
* purpose is to alert the user when no target elements are found by
* a bookmarklet.
*/
function createMsgDialog(cssClass, handler) {
var dialog = document.createElement("div");
var button = document.createElement("button");
dialog.className = cssClass;
setBoxGeometry(dialog);
button.onclick = handler;
dialog.appendChild(button);
document.body.appendChild(dialog);
return dialog;
}
/*
* deleteMsgDialog: Use reference to delete message dialog.
*/
function deleteMsgDialog(dialog) {
if (dialog) document.body.removeChild(dialog);
}
/*
* MessageDialog: Wrapper for show, hide and resize methods
*/
function MessageDialog() {
this.GLOBAL_NAME = 'a11yMessageDialog';
this.CSS_CLASS = 'oaa-message-dialog';
}
/*
* show: show message dialog
*/
MessageDialog.prototype.show = function (title, message) {
var MSG_DIALOG = this.GLOBAL_NAME;
var h2, div;
if (!window[MSG_DIALOG]) window[MSG_DIALOG] = createMsgDialog(this.CSS_CLASS, function () {
this.hide();
});
h2 = document.createElement("h2");
h2.innerHTML = title;
window[MSG_DIALOG].appendChild(h2);
div = document.createElement("div");
div.innerHTML = message;
window[MSG_DIALOG].appendChild(div);
};
/*
* hide: hide message dialog
*/
MessageDialog.prototype.hide = function () {
var MSG_DIALOG = this.GLOBAL_NAME;
if (window[MSG_DIALOG]) {
deleteMsgDialog(window[MSG_DIALOG]);
delete window[MSG_DIALOG];
}
};
/*
* resize: resize message dialog
*/
MessageDialog.prototype.resize = function () {
var MSG_DIALOG = this.GLOBAL_NAME;
if (window[MSG_DIALOG]) setBoxGeometry(window[MSG_DIALOG]);
};
/*
* dom.js: functions and constants for adding and removing DOM overlay elements
*/
/*
* isVisible: Recursively check element properties from getComputedStyle
* until document element is reached, to determine whether element or any
* of its ancestors has properties set that affect its visibility. Called
* by addNodes function.
*/
function isVisible(element) {
function isVisibleRec(el) {
if (el.nodeType === Node.DOCUMENT_NODE) return true;
var computedStyle = window.getComputedStyle(el, null);
var display = computedStyle.getPropertyValue('display');
var visibility = computedStyle.getPropertyValue('visibility');
var hidden = el.getAttribute('hidden');
var ariaHidden = el.getAttribute('aria-hidden');
if (display === 'none' || visibility === 'hidden' || hidden !== null || ariaHidden === 'true') {
return false;
}
return isVisibleRec(el.parentNode);
}
return isVisibleRec(element);
}
/*
* countChildrenWithTagNames: For the specified DOM element, return the
* number of its child elements with tagName equal to one of the values
* in the tagNames array.
*/
function countChildrenWithTagNames(element, tagNames) {
var count = 0;
var child = element.firstElementChild;
while (child) {
if (tagNames.indexOf(child.tagName) > -1) count += 1;
child = child.nextElementSibling;
}
return count;
}
/*
* isDescendantOf: Determine whether element is a descendant of any
* element in the DOM with a tagName in the list of tagNames.
*/
function isDescendantOf(element, tagNames) {
if (typeof element.closest === 'function') {
return tagNames.some(function (name) {
return element.closest(name) !== null;
});
}
return false;
}
/*
* hasParentWithName: Determine whether element has a parent with
* tagName in the list of tagNames.
*/
function hasParentWithName(element, tagNames) {
var parentTagName = element.parentElement.tagName.toLowerCase();
if (parentTagName) {
return tagNames.some(function (name) {
return parentTagName === name;
});
}
return false;
}
/*
* addNodes: Use targetList to generate nodeList of elements and to
* each of these, add an overlay with a unique CSS class name.
* Optionally, if getInfo is specified, add tooltip information;
* if dndFlag is set, add drag-and-drop functionality.
*/
function addNodes(params) {
var targetList = params.targetList,
cssClass = params.cssClass,
getInfo = params.getInfo,
evalInfo = params.evalInfo,
dndFlag = params.dndFlag;
var counter = 0;
targetList.forEach(function (target) {
// Collect elements based on selector defined for target
var elements = document.querySelectorAll(target.selector);
// Filter elements if target defines a filter function
if (typeof target.filter === 'function') elements = Array.prototype.filter.call(elements, target.filter);
Array.prototype.forEach.call(elements, function (element) {
if (isVisible(element)) {
var info = getInfo(element, target);
if (evalInfo) evalInfo(info, target);
var boundingRect = element.getBoundingClientRect();
var overlayNode = createOverlay(target, boundingRect, cssClass);
if (dndFlag) addDragAndDrop(overlayNode);
var labelNode = overlayNode.firstChild;
labelNode.title = formatInfo(info);
document.body.appendChild(overlayNode);
counter += 1;
}
});
});
return counter;
}
/*
* removeNodes: Use the unique CSS class name supplied to addNodes
* to remove all instances of the overlay nodes.
*/
function removeNodes(cssClass) {
var selector = "div." + cssClass;
var elements = document.querySelectorAll(selector);
Array.prototype.forEach.call(elements, function (element) {
document.body.removeChild(element);
});
}
/*
* embedded.js
*/
// LOW-LEVEL FUNCTIONS
/*
* getInputValue: Get current value of 'input' or 'textarea' element.
*/
function getInputValue(element) {
return normalize(element.value);
}
/*
* getRangeValue: Get current value of control with role 'spinbutton'
* or 'slider' (i.e., subclass of abstract 'range' role).
*/
function getRangeValue(element) {
var value;
value = getAttributeValue(element, 'aria-valuetext');
if (value.length) return value;
value = getAttributeValue(element, 'aria-valuenow');
if (value.length) return value;
return getInputValue(element);
}
// HELPER FUNCTIONS FOR SPECIFIC ROLES
function getTextboxValue(element) {
var inputTypes = ['email', 'password', 'search', 'tel', 'text', 'url'],
tagName = element.tagName.toLowerCase(),
type = element.type;
if (tagName === 'input' && inputTypes.indexOf(type) !== -1) {
return getInputValue(element);
}
if (tagName === 'textarea') {
return getInputValue(element);
}
return '';
}
function getComboboxValue(element) {
var inputTypes = ['email', 'search', 'tel', 'text', 'url'],
tagName = element.tagName.toLowerCase(),
type = element.type;
if (tagName === 'input' && inputTypes.indexOf(type) !== -1) {
return getInputValue(element);
}
return '';
}
function getSliderValue(element) {
var tagName = element.tagName.toLowerCase(),
type = element.type;
if (tagName === 'input' && type === 'range') {
return getRangeValue(element);
}
return '';
}
function getSpinbuttonValue(element) {
var tagName = element.tagName.toLowerCase(),
type = element.type;
if (tagName === 'input' && type === 'number') {
return getRangeValue(element);
}
return '';
}
function getListboxValue(element) {
var tagName = element.tagName.toLowerCase();
if (tagName === 'select') {
var arr = [],
selectedOptions = element.selectedOptions;
for (var i = 0; i < selectedOptions.length; i++) {
var option = selectedOptions[i];
var value = normalize(option.value);
if (value.length) arr.push(value);
}
if (arr.length) return arr.join(' ');
}
return '';
}
/*
* isEmbeddedControl: Determine whether element has a role that corresponds
* to an HTML form control that could be embedded within text content.
*/
function isEmbeddedControl(element) {
var embeddedControlRoles = ['textbox', 'combobox', 'listbox', 'slider', 'spinbutton'];
var role = getAriaRole(element);
return embeddedControlRoles.indexOf(role) !== -1;
}
/*
* getEmbeddedControlValue: Based on the role of element, use native semantics
* of HTML to get the corresponding text value of the embedded control.
*/
function getEmbeddedControlValue(element) {
var role = getAriaRole(element);
switch (role) {
case 'textbox':
return getTextboxValue(element);
case 'combobox':
return getComboboxValue(element);
case 'listbox':
return getListboxValue(element);
case 'slider':
return getSliderValue(element);
case 'spinbutton':
return getSpinbuttonValue(element);
}
return '';
}
/*
* getaccname.js
*
* Note: Information in this module is based on the following documents:
* 1. HTML Accessibility API Mappings (latest editors draft) (http://rawgit.com/w3c/aria/master/html-aam/html-aam.html)
* 2. SVG Accessibility API Mappings (http://rawgit.com/w3c/aria/master/svg-aam/svg-aam.html)
*/
/*
* getFieldsetLegendLabels: Recursively collect legend contents of
* fieldset ancestors, starting with the closest (innermost).
* Return collection as a possibly empty array of strings.
*/
function getFieldsetLegendLabels(element) {
var arrayOfStrings = [];
if (typeof element.closest !== 'function') {
return arrayOfStrings;
}
function getLabelsRec(elem, arr) {
var fieldset = elem.closest('fieldset');
if (fieldset) {
var legend = fieldset.querySelector('legend');
if (legend) {
var text = getElementContents(legend);
if (text.length) {
arr.push({ name: text, source: 'fieldset/legend' });
}
}
// process ancestors
getLabelsRec(fieldset.parentNode, arr);
}
}
getLabelsRec(element, arrayOfStrings);
return arrayOfStrings;
}
/*
* getGroupingLabels: Return an array of grouping label objects for
* element, each with two properties: 'name' and 'source'.
*/
function getGroupingLabels(element) {
// We currently only handle labelable elements as defined in HTML 5.1
if (isLabelableElement(element)) {
return getFieldsetLegendLabels(element);
}
return [];
}
/*
* nameFromNativeSemantics: Use method appropriate to the native semantics
* of element to find accessible name. Includes methods for all interactive
* elements. For non-interactive elements, if the element's ARIA role allows
* its acc. name to be derived from its text contents, or if recFlag is set,
* indicating that we are in a recursive aria-labelledby calculation, the
* nameFromContents method is used.
*/
function nameFromNativeSemantics(element, recFlag) {
var tagName = element.tagName.toLowerCase(),
ariaRole = getAriaRole(element),
accName = null;
// TODO: Verify that this applies to all elements
if (ariaRole && (ariaRole === 'presentation' || ariaRole === 'none')) {
return null;
}
switch (tagName) {
// FORM ELEMENTS: INPUT
case 'input':
switch (element.type) {
// HIDDEN
case 'hidden':
if (recFlag) {
accName = nameFromLabelElement(element);
}
break;
// TEXT FIELDS
case 'email':
case 'password':
case 'search':
case 'tel':
case 'text':
case 'url':
accName = nameFromLabelElement(element);
if (accName === null) accName = nameFromAttribute(element, 'placeholder');
break;
// OTHER INPUT TYPES
case 'button':
accName = nameFromAttribute(element, 'value');
break;
case 'reset':
accName = nameFromAttribute(element, 'value');
if (accName === null) accName = nameFromDefault('Reset');
break;
case 'submit':
accName = nameFromAttribute(element, 'value');
if (accName === null) accName = nameFromDefault('Submit');
break;
case 'image':
accName = nameFromAltAttribute(element);
if (accName === null) accName = nameFromAttribute(element, 'value');
break;
default:
accName = nameFromLabelElement(element);
break;
}
break;
// FORM ELEMENTS: OTHER
case 'button':
accName = nameFromContents(element);
break;
case 'label':
accName = nameFromContents(element);
break;
case 'keygen':
case 'meter':
case 'output':
case 'progress':
case 'select':
accName = nameFromLabelElement(element);
break;
case 'textarea':
accName = nameFromLabelElement(element);
if (accName === null) accName = nameFromAttribute(element, 'placeholder');
break;
// EMBEDDED ELEMENTS
case 'audio':
// if 'controls' attribute is present
accName = { name: 'NOT YET IMPLEMENTED', source: '' };
break;
case 'embed':
accName = { name: 'NOT YET IMPLEMENTED', source: '' };
break;
case 'iframe':
accName = nameFromAttribute(element, 'title');
break;
case 'img':
case 'area':
// added
accName = nameFromAltAttribute(element);
break;
case 'object':
accName = { name: 'NOT YET IMPLEMENTED', source: '' };
break;
case 'svg':
// added
accName = nameFromDescendant(element, 'title');
break;
case 'video':
// if 'controls' attribute is present
accName = { name: 'NOT YET IMPLEMENTED', source: '' };
break;
// OTHER ELEMENTS
case 'a':
accName = nameFromContents(element);
break;
case 'details':
accName = nameFromDetailsOrSummary(element);
break;
case 'figure':
accName = nameFromDescendant(element, 'figcaption');
break;
case 'table':
accName = nameFromDescendant(element, 'caption');
break;
// ELEMENTS NOT SPECIFIED ABOVE
default:
if (nameFromIncludesContents(element) || recFlag) accName = nameFromContents(element);
break;
}
// LAST RESORT USE TITLE
if (accName === null) accName = nameFromAttribute(element, 'title');
return accName;
}
/*
* nameFromAttributeIdRefs: 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 concatenation
* of those results if any, otherwise return null.
*/
function nameFromAttributeIdRefs(element, attribute) {
var value = getAttributeValue(element, attribute);
var idRefs,
refElement,
accName,
arr = [];
if (value.length) {
idRefs = value.split(' ');
for (var i = 0; i < idRefs.length; i++) {
refElement = document.getElementById(idRefs[i]);
if (refElement) {
accName = getAccessibleName(refElement, true);
if (accName && accName.name.length) arr.push(accName.name);
}
}
}
if (arr.length) return { name: arr.join(' '), source: attribute };
return null;
}
/*
* getAccessibleName: Use the ARIA Roles Model specification for accessible
* name calculation based on its precedence order:
* (1) Use aria-labelledby, unless a traversal is already underway;
* (2) Use aria-label attribute value;
* (3) Use whatever method is specified by the native semantics of the
* element, which includes, as last resort, use of the title attribute.
*/
function getAccessibleName(element, recFlag) {
var accName = null;
if (!recFlag) accName = nameFromAttributeIdRefs(element, 'aria-labelledby');
if (accName === null) accName = nameFromAttribute(element, 'aria-label');
if (accName === null) accName = nameFromNativeSemantics(element, recFlag);
return accName;
}
/*
* getAccessibleDesc: Use the ARIA Roles Model specification for accessible
* description calculation based on its precedence order:
* (1) Use aria-describedby, unless a traversal is already underway;
* (2) As last resort, use the title attribute.
*/
function getAccessibleDesc(element, recFlag) {
var accDesc = null;
if (!recFlag) accDesc = nameFromAttributeIdRefs(element, 'aria-describedby');
if (accDesc === null) accDesc = nameFromAttribute(element, 'title');
return accDesc;
}
/*
* info.js: Function for displaying information on highlighted elements
*/
/*
* getElementInfo: Extract tagName and other attribute information
* based on tagName and return as formatted string.
*/
function getElementInfo(element) {
var tagName = element.tagName.toLowerCase(),
elementInfo = tagName;
if (tagName === 'input') {
var type = element.type;
if (type && type.length) elementInfo += ' [type="' + type + '"]';
}
if (tagName === 'label') {
var forVal = getAttributeValue(element, 'for');
if (forVal.length) elementInfo += ' [for="' + forVal + '"]';
}
if (isLabelableElement(element)) {
var id = element.id;
if (id && id.length) elementInfo += ' [id="' + id + '"]';
}
if (element.hasAttribute('role')) {
var role = getAttributeValue(element, 'role');
if (role.length) elementInfo += ' [role="' + role + '"]';
}
return elementInfo;
}
/*
* formatInfo: Convert info properties into a string with line breaks.
*/
function formatInfo(info) {
var value = '';
var title = info.title,
element = info.element,
grpLabels = info.grpLabels,
accName = info.accName,
accDesc = info.accDesc,
role = info.role,
props = info.props;
value += '=== ' + title + ' ===';
if (element) value += '\nELEMENT: ' + element;
if (grpLabels && grpLabels.length) {
// array starts with innermost label, so process from the end
for (var i = grpLabels.length - 1; i >= 0; i--) {
value += '\nGRP. LABEL: ' + grpLabels[i].name + '\nFROM: ' + grpLabels[i].source;
}
}
if (accName) {
value += '\nACC. NAME: ' + accName.name + '\nFROM: ' + accName.source;
}
if (accDesc) {
value += '\nACC. DESC: ' + accDesc.name + '\nFROM: ' + accDesc.source;
}
if (role) value += '\nROLE: ' + role;
if (props) value += '\nPROPERTIES: ' + props;
return value;
}
/*
* namefrom.js
*/
// LOW-LEVEL FUNCTIONS
/*
* normalize: 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.
*/
function normalize(s) {
var rtrim = /^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g;
return s.replace(rtrim, '').replace(/\s+/g, ' ');
}
/*
* getAttributeValue: Return attribute value if present on element,
* otherwise return empty string.
*/
function getAttributeValue(element, attribute) {
var value = element.getAttribute(attribute);
return value === null ? '' : normalize(value);
}
/*
* couldHaveAltText: Based on HTML5 specification, determine whether
* element could have an 'alt' attribute.
*/
function couldHaveAltText(element) {
var tagName = element.tagName.toLowerCase();
switch (tagName) {
case 'img':
case 'area':
return true;
case 'input':
return element.type && element.type === 'image';
}
return false;
}
/*
* hasEmptyAltText: Determine whether the alt attribute is present
* and its value is the empty string.
*/
function hasEmptyAltText(element) {
var value = element.getAttribute('alt');
// Attribute is present
if (value !== null) return normalize(value).length === 0;
return false;
}
/*
* isLabelableElement: Based on HTML5 specification, determine whether
* element can be associated with a label.
*/
function isLabelableElement(element) {
var tagName = element.tagName.toLowerCase(),
type = element.type;
switch (tagName) {
case 'input':
return type !== 'hidden';
case 'button':
case 'keygen':
case 'meter':
case 'output':
case 'progress':
case 'select':
case 'textarea':
return true;
default:
return false;
}
}
/*
* addCssGeneratedContent: Add 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'.
*/
function addCssGeneratedContent(element, contents) {
var result = contents,
prefix = getComputedStyle(element, ':before').content,
suffix = getComputedStyle(element, ':after').content;
if (prefix !== 'none') result = prefix + result;
if (suffix !== 'none') result = result + suffix;
return result;
}
/*
* getNodeContents: Recursively process element and text nodes by aggregating
* their text values for an ARIA text equivalent calculation.
* 1. This includes special handling of elements with 'alt' text and embedded
* controls.
* 2. The forElem parameter is needed for label processing to avoid inclusion
* of an embedded control's value when the label is for the control itself.
*/
function getNodeContents(node, forElem) {
var contents = '';
if (node === forElem) return '';
switch (node.nodeType) {
case Node.ELEMENT_NODE:
if (couldHaveAltText(node)) {
contents = getAttributeValue(node, 'alt');
} else if (isEmbeddedControl(node)) {
contents = getEmbeddedControlValue(node);
} else {
if (node.hasChildNodes()) {
var children = node.childNodes,
arr = [];
for (var i = 0; i < children.length; i++) {
var nc = getNodeContents(children[i], forElem);
if (nc.length) arr.push(nc);
}
contents = arr.length ? arr.join(' ') : '';
}
}
// For all branches of the ELEMENT_NODE case...
contents = addCssGeneratedContent(node, contents);
break;
case Node.TEXT_NODE:
contents = normalize(node.textContent);
}
return contents;
}
/*
* getElementContents: Construct the ARIA text alternative for element by
* processing its element and text node descendants and then adding any CSS-
* generated content if present.
*/
function getElementContents(element, forElement) {
var result = '';
if (element.hasChildNodes()) {
var children = element.childNodes,
arrayOfStrings = [];
for (var i = 0; i < children.length; i++) {
var contents = getNodeContents(children[i], forElement);
if (contents.length) arrayOfStrings.push(contents);
}
result = arrayOfStrings.length ? arrayOfStrings.join(' ') : '';
}
return addCssGeneratedContent(element, result);
}
/*
* getContentsOfChildNodes: Using predicate function for filtering element
* nodes, collect text content from all child nodes of element.
*/
function getContentsOfChildNodes(element, predicate) {
var arr = [],
content;
Array.prototype.forEach.call(element.childNodes, function (node) {
switch (node.nodeType) {
case Node.ELEMENT_NODE:
if (predicate(node)) {
content = getElementContents(node);
if (content.length) arr.push(content);
}
break;
case Node.TEXT_NODE:
content = normalize(node.textContent);
if (content.length) arr.push(content);
break;
}
});
if (arr.length) return arr.join(' ');
return '';
}
// HIGHER-LEVEL FUNCTIONS THAT RETURN AN OBJECT WITH SOURCE PROPERTY
/*
* nameFromAttribute
*/
function nameFromAttribute(element, attribute) {
var name;
name = getAttributeValue(element, attribute);
if (name.length) return { name: name, source: attribute };
return null;
}
/*
* nameFromAltAttribute
*/
function nameFromAltAttribute(element) {
var name = element.getAttribute('alt');
// Attribute is present
if (name !== null) {
name = normalize(name);
return name.length ? { name: name, source: 'alt' } : { name: '', source: 'alt' };
}
// Attribute not present
return null;
}
/*
* nameFromContents
*/
function nameFromContents(element) {
var name;
name = getElementContents(element);
if (name.length) return { name: name, source: 'contents' };
return null;
}
/*
* nameFromDefault
*/
function nameFromDefault(name) {
return name.length ? { name: name, source: 'default' } : null;
}
/*
* nameFromDescendant
*/
function nameFromDescendant(element, tagName) {
var descendant = element.querySelector(tagName);
if (descendant) {
var name = getElementContents(descendant);
if (name.length) return { name: name, source: tagName + ' element' };
}
return null;
}
/*
* nameFromLabelElement
*/
function nameFromLabelElement(element) {
var name, label;
// label [for=id]
if (element.id) {
label = document.querySelector('[for="' + element.id + '"]');
if (label) {
name = getElementContents(label, element);
if (name.length) return { name: name, source: 'label reference' };
}
}
// label encapsulation
if (typeof element.closest === 'function') {
label = element.closest('label');
if (label) {
name = getElementContents(label, element);
if (name.length) return { name: name, source: 'label encapsulation' };
}
}
return null;
}
/*
* nameFromDetailsOrSummary: If element is expanded (has open attribute),
* return the contents of the summary element followed by the text contents
* of element and all of its non-summary child elements. Otherwise, return
* only the contents of the first summary element descendant.
*/
function nameFromDetailsOrSummary(element) {
var name, summary;
function isExpanded(elem) {
return elem.hasAttribute('open');
}
// At minimum, always use summary contents
summary = element.querySelector('summary');
if (summary) name = getElementContents(summary);
// Return either summary + details (non-summary) or summary only
if (isExpanded(element)) {
name += getContentsOfChildNodes(element, function (elem) {
return elem.tagName.toLowerCase() !== 'summary';
});
if (name.length) return { name: name, source: 'contents' };
} else {
if (name.length) return { name: name, source: 'summary element' };
}
return null;
}
/*
* overlay.js: functions for creating and modifying DOM overlay elements
*/
var zIndex = 100000;
/*
* createOverlay: Create overlay div with size and position based on the
* boundingRect properties of its corresponding target element.
*/
function createOverlay(tgt, rect, cname) {
var scrollOffsets = getScrollOffsets();
var MINWIDTH = 68;
var MINHEIGHT = 27;
var node = document.createElement("div");
node.setAttribute("class", [cname, 'oaa-element-overlay'].join(' '));
node.startLeft = rect.left + scrollOffsets.x + "px";
node.startTop = rect.top + scrollOffsets.y + "px";
node.style.left = node.startLeft;
node.style.top = node.startTop;
node.style.width = Math.max(rect.width, MINWIDTH) + "px";
node.style.height = Math.max(rect.height, MINHEIGHT) + "px";
node.style.borderColor = tgt.color;
node.style.zIndex = zIndex;
var label = document.createElement("div");
label.setAttribute("class", 'oaa-overlay-label');
label.style.backgroundColor = tgt.color;
label.innerHTML = tgt.label;
node.appendChild(label);
return node;
}
/*
* addDragAndDrop: Add drag-and-drop and reposition functionality to an
* overlay div element created by the createOverlay function.
*/
function addDragAndDrop(node) {
function hoistZIndex(el) {
var incr = 100;
el.style.zIndex = zIndex += incr;
}
function repositionOverlay(el) {
el.style.left = el.startLeft;
el.style.top = el.startTop;
}
var labelDiv = node.firstChild;
labelDiv.onmousedown = function (event) {
drag(this.parentNode, hoistZIndex, event);
};
labelDiv.ondblclick = function (event) {
repositionOverlay(this.parentNode);
};
}
/*
* roles.js
*
* Note: The information in this module is based on the following documents:
* 1. ARIA in HTML (https://specs.webplatform.org/html-aria/webspecs/master/)
* 2. WAI-ARIA 1.1 (http://www.w3.org/TR/wai-aria-1.1/)
* 3. WAI-ARIA 1.0 (http://www.w3.org/TR/wai-aria/)
*/
/*
* inListOfOptions: Determine whether element is a child of
* 1. a select element
* 2. an optgroup element that is a child of a select element
* 3. a datalist element
*/
function inListOfOptions(element) {
var parent = element.parentElement,
parentName = parent.tagName.toLowerCase(),
parentOfParentName = parent.parentElement.tagName.toLowerCase();
if (parentName === 'select') return true;
if (parentName === 'optgroup' && parentOfParentName === 'select') return true;
if (parentName === 'datalist') return true;
return false;
}
/*
* validRoles: Reference list of all concrete ARIA roles as specified in
* WAI-ARIA 1.1 Working Draft of 14 July 2015
*/
var validRoles = [
// LANDMARK
'application', 'banner', 'complementary', 'contentinfo', 'form', 'main', 'navigation', 'search',
// WIDGET
'alert', 'alertdialog', 'button', 'checkbox', 'dialog', 'gridcell', 'link', 'log', 'marquee', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'option', 'progressbar', 'radio', 'scrollbar', 'searchbox', // ARIA 1.1
'slider', 'spinbutton', 'status', 'switch', // ARIA 1.1
'tab', 'tabpanel', 'textbox', 'timer', 'tooltip', 'treeitem',
// COMPOSITE WIDGET
'combobox', 'grid', 'listbox', 'menu', 'menubar', 'radiogroup', 'tablist', 'tree', 'treegrid',
// DOCUMENT STRUCTURE
'article', 'cell', // ARIA 1.1
'columnheader', 'definition', 'directory', 'document', 'group', 'heading', 'img', 'list', 'listitem', 'math', 'none', // ARIA 1.1
'note', 'presentation', 'region', 'row', 'rowgroup', 'rowheader', 'separator', 'table', // ARIA 1.1
'text', // ARIA 1.1
'toolbar'];
/*
* getValidRole: Examine each value in space-separated list by attempting
* to find its match in the validRoles array. If a match is found, return
* it. Otherwise, return null.
*/
function getValidRole(spaceSepList) {
var arr = spaceSepList.split(' ');
for (var i = 0; i < arr.length; i++) {
var value = arr[i].toLowerCase();
var validRole = validRoles.find(function (role) {
return role === value;
});
if (validRole) return validRole;
}
return null;
}
/*
* getAriaRole: Get the value of the role attribute, if it is present. If
* not specified, get the default role of element if it has one. Based on
* ARIA in HTML as of 21 October 2015.
*/
function getAriaRole(element) {
var tagName = element.tagName.toLowerCase(),
type = element.type;
if (element.hasAttribute('role')) {
return getValidRole(getAttributeValue(element, 'role'));
}
switch (tagName) {
case 'a':
if (element.hasAttribute('href')) return 'link';
break;
case 'area':
if (element.hasAttribute('href')) return 'link';
break;
case 'article':
return 'article';
case 'aside':
return 'complementary';
case 'body':
return 'document';
case 'button':
return 'button';
case 'datalist':
return 'listbox';
case 'details':
return 'group';
case 'dialog':
return 'dialog';
case 'dl':
return 'list';
case 'fieldset':
return 'group';
case 'footer':
if (!isDescendantOf(element, ['article', 'section'])) return 'contentinfo';
break;
case 'form':
return 'form';
case 'h1':
return 'heading';
case 'h2':
return 'heading';
case 'h3':
return 'heading';
case 'h4':
return 'heading';
case 'h5':
return 'heading';
case 'h6':
return 'heading';
case 'header':
if (!isDescendantOf(element, ['article', 'section'])) return 'banner';
break;
case 'hr':
return 'separator';
case 'img':
if (!hasEmptyAltText(element)) return 'img';
break;
case 'input':
if (type === 'button') return 'button';
if (type === 'checkbox') return 'checkbox';
if (type === 'email') return element.hasAttribute('list') ? 'combobox' : 'textbox';
if (type === 'image') return 'button';
if (type === 'number') return 'spinbutton';
if (type === 'password') return 'textbox';
if (type === 'radio') return 'radio';
if (type === 'range') return 'slider';
if (type === 'reset') return 'button';
if (type === 'search') return element.hasAttribute('list') ? 'combobox' : 'textbox';
if (type === 'submit') return 'button';
if (type === 'tel') return element.hasAttribute('list') ? 'combobox' : 'textbox';
if (type === 'text') return element.hasAttribute('list') ? 'combobox' : 'textbox';
if (type === 'url') return element.hasAttribute('list') ? 'combobox' : 'textbox';
break;
case 'li':
if (hasParentWithName(element, ['ol', 'ul'])) return 'listitem';
break;
case 'link':
if (element.hasAttribute('href')) return 'link';
break;
case 'main':
return 'main';
case 'menu':
if (type === 'toolbar') return 'toolbar';
break;
case 'menuitem':
if (type === 'command') return 'menuitem';
if (type === 'checkbox') return 'menuitemcheckbox';
if (type === 'radio') return 'menuitemradio';
break;
case 'meter':
return 'progressbar';
case 'nav':
return 'navigation';
case 'ol':
return 'list';
case 'option':
if (inListOfOptions(element)) return 'option';
break;
case 'output':
return 'status';
case 'progress':
return 'progressbar';
case 'section':
return 'region';
case 'select':
return 'listbox';
case 'summary':
return 'button';
case 'tbody':
return 'rowgroup';
case 'tfoot':
return 'rowgroup';
case 'thead':
return 'rowgroup';
case 'textarea':
return 'textbox';
// TODO: th can have role 'columnheader' or 'rowheader'
case 'th':
return 'columnheader';
case 'ul':
return 'list';
}
return null;
}
/*
* nameFromIncludesContents: Determine whether the ARIA role of element
* specifies that its 'name from' includes 'contents'.
*/
function nameFromIncludesContents(element) {
var elementRole = getAriaRole(element);
if (elementRole === null) return false;
var contentsRoles = ['button', 'cell', // ARIA 1.1
'checkbox', 'columnheader', 'directory', 'gridcell', 'heading', 'link', 'listitem', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'option', 'radio', 'row', 'rowgroup', 'rowheader', 'switch', // ARIA 1.1
'tab', 'text', // ARIA 1.1
'tooltip', 'treeitem'];
var contentsRole = contentsRoles.find(function (role) {
return role === elementRole;
});
return typeof contentsRole !== 'undefined';
}
/*
* utils.js: utility functions
*/
/*
* getScrollOffsets: Use x and y scroll offsets to calculate positioning
* coordinates that take into account whether the page has been scrolled.
* From Mozilla Developer Network: Element.getBoundingClientRect()
*/
function getScrollOffsets() {
var t;
var xOffset = typeof window.pageXOffset === "undefined" ? (((t = document.documentElement) || (t = document.body.parentNode)) && typeof t.ScrollLeft === 'number' ? t : document.body).ScrollLeft : window.pageXOffset;
var yOffset = typeof window.pageYOffset === "undefined" ? (((t = document.documentElement) || (t = document.body.parentNode)) && typeof t.ScrollTop === 'number' ? t : document.body).ScrollTop : window.pageYOffset;
return { x: xOffset, y: yOffset };
}
/*
* drag: Add drag and drop functionality to an element by setting this
* as its mousedown handler. Depends upon getScrollOffsets function.
* From JavaScript: The Definitive Guide, 6th Edition (slightly modified)
*/
function drag(elementToDrag, dragCallback, event) {
var scroll = getScrollOffsets();
var startX = event.clientX + scroll.x;
var startY = event.clientY + scroll.y;
var origX = elementToDrag.offsetLeft;
var origY = elementToDrag.offsetTop;
var deltaX = startX - origX;
var deltaY = startY - origY;
if (dragCallback) dragCallback(elementToDrag);
if (document.addEventListener) {
document.addEventListener("mousemove", moveHandler, true);
document.addEventListener("mouseup", upHandler, true);
} else if (document.attachEvent) {
elementToDrag.setCapture();
elementToDrag.attachEvent("onmousemove", moveHandler);
elementToDrag.attachEvent("onmouseup", upHandler);
elementToDrag.attachEvent("onlosecapture", upHandler);
}
if (event.stopPropagation) event.stopPropagation();else event.cancelBubble = true;
if (event.preventDefault) event.preventDefault();else event.returnValue = false;
function moveHandler(e) {
if (!e) e = window.event;
var scroll = getScrollOffsets();
elementToDrag.style.left = e.clientX + scroll.x - deltaX + "px";
elementToDrag.style.top = e.clientY + scroll.y - deltaY + "px";
elementToDrag.style.cursor = "move";
if (e.stopPropagation) e.stopPropagation();else e.cancelBubble = true;
}
function upHandler(e) {
if (!e) e = window.event;
elementToDrag.style.cursor = "grab";
elementToDrag.style.cursor = "-moz-grab";
elementToDrag.style.cursor = "-webkit-grab";
if (document.removeEventListener) {
document.removeEventListener("mouseup", upHandler, true);
document.removeEventListener("mousemove", moveHandler, true);
} else if (document.detachEvent) {
elementToDrag.detachEvent("onlosecapture", upHandler);
elementToDrag.detachEvent("onmouseup", upHandler);
elementToDrag.detachEvent("onmousemove", moveHandler);
elementToDrag.releaseCapture();
}
if (e.stopPropagation) e.stopPropagation();else e.cancelBubble = true;
}
}
/*
* addPolyfills: Add polyfill implementations for JavaScript object methods
* defined in ES6 and used by bookmarklets:
* 1. Array.prototype.find
* 2. String.prototype.includes
*/
function addPolyfills() {
// From https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/find
if (!Array.prototype.find) {
Array.prototype.find = function (predicate) {
if (this === null) {
throw new TypeError('Array.prototype.find called on null or undefined');
}
if (typeof predicate !== 'function') {
throw new TypeError('predicate must be a function');
}
var list = Object(this);
var length = list.length >>> 0;
var thisArg = arguments[1];
var value;
for (var i = 0; i < length; i++) {
value = list[i];
if (predicate.call(thisArg, value, i, list)) {
return value;
}
}
return undefined;
};
}
// From https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/includes
if (!String.prototype.includes) {
String.prototype.includes = function (search, start) {
if (typeof start !== 'number') {
start = 0;
}
if (start + search.length > this.length) {
return false;
} else {
return this.indexOf(search, start) !== -1;
}
};
}
}