UNPKG

accessibility-developer-tools

Version:

This is a library of accessibility-related testing and utility code.

1,282 lines (1,170 loc) 41.7 kB
// Copyright 2012 Google Inc. // // 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. goog.require('axs.browserUtils'); goog.require('axs.color'); goog.require('axs.color.Color'); goog.require('axs.constants'); goog.require('axs.dom'); goog.provide('axs.utils'); /** * @const * @type {string} */ axs.utils.FOCUSABLE_ELEMENTS_SELECTOR = 'input:not([type=hidden]):not([disabled]),' + 'select:not([disabled]),' + 'textarea:not([disabled]),' + 'button:not([disabled]),' + 'a[href],' + 'iframe,' + '[tabindex]'; /** * Elements that can have labels: https://html.spec.whatwg.org/multipage/forms.html#category-label * @const * @type {string} */ axs.utils.LABELABLE_ELEMENTS_SELECTOR = 'button,' + 'input:not([type=hidden]),' + 'keygen,' + 'meter,' + 'output,' + 'progress,' + 'select,' + 'textarea'; /** * @param {Element} element * @return {boolean} */ axs.utils.elementIsTransparent = function(element) { return element.style.opacity == '0'; }; /** * @param {Element} element * @return {boolean} */ axs.utils.elementHasZeroArea = function(element) { var rect = element.getBoundingClientRect(); var width = rect.right - rect.left; var height = rect.top - rect.bottom; if (!width || !height) return true; return false; }; /** * @param {Element} element * @return {boolean} */ axs.utils.elementIsOutsideScrollArea = function(element) { var parent = axs.dom.parentElement(element); var defaultView = element.ownerDocument.defaultView; while (parent != defaultView.document.body) { if (axs.utils.isClippedBy(element, parent)) return true; if (axs.utils.canScrollTo(element, parent) && !axs.utils.elementIsOutsideScrollArea(parent)) return false; parent = axs.dom.parentElement(parent); } return !axs.utils.canScrollTo(element, defaultView.document.body); }; /** * Checks whether it's possible to scroll to the given element within the given container. * Assumes that |container| is an ancestor of |element|. * If |container| cannot be scrolled, returns True if the element is within its bounding client * rect. * @param {Element} element * @param {Element} container * @return {boolean} True iff it's possible to scroll to |element| within |container|. */ axs.utils.canScrollTo = function(element, container) { var rect = element.getBoundingClientRect(); var containerRect = container.getBoundingClientRect(); if (container == container.ownerDocument.body) { var absoluteTop = containerRect.top; var absoluteLeft = containerRect.left; } else { var absoluteTop = containerRect.top - container.scrollTop; var absoluteLeft = containerRect.left - container.scrollLeft; } var containerScrollArea = { top: absoluteTop, bottom: absoluteTop + container.scrollHeight, left: absoluteLeft, right: absoluteLeft + container.scrollWidth }; if (rect.right < containerScrollArea.left || rect.bottom < containerScrollArea.top || rect.left > containerScrollArea.right || rect.top > containerScrollArea.bottom) { return false; } var defaultView = element.ownerDocument.defaultView; var style = defaultView.getComputedStyle(container); if (rect.left > containerRect.right || rect.top > containerRect.bottom) { return (style.overflow == 'scroll' || style.overflow == 'auto' || container instanceof defaultView.HTMLBodyElement); } return true; }; /** * Checks whether the given element is clipped by the given container. * Assumes that |container| is an ancestor of |element|. * @param {Element} element * @param {Element} container * @return {boolean} True iff |element| is clipped by |container|. */ axs.utils.isClippedBy = function(element, container) { var rect = element.getBoundingClientRect(); var containerRect = container.getBoundingClientRect(); var containerTop = containerRect.top; var containerLeft = containerRect.left; var containerScrollArea = { top: containerTop - container.scrollTop, bottom: containerTop - container.scrollTop + container.scrollHeight, left: containerLeft - container.scrollLeft, right: containerLeft - container.scrollLeft + container.scrollWidth }; var defaultView = element.ownerDocument.defaultView; var style = defaultView.getComputedStyle(container); if ((rect.right < containerRect.left || rect.bottom < containerRect.top || rect.left > containerRect.right || rect.top > containerRect.bottom) && style.overflow == 'hidden') { return true; } if (rect.right < containerScrollArea.left || rect.bottom < containerScrollArea.top) return (style.overflow != 'visible'); return false; }; /** * @param {Node} ancestor A potential ancestor of |node|. * @param {Node} node * @return {boolean} true if |ancestor| is an ancestor of |node| (including * |ancestor| === |node|). */ axs.utils.isAncestor = function(ancestor, node) { if (node == null) return false; if (node === ancestor) return true; var parentNode = axs.dom.composedParentNode(node); return axs.utils.isAncestor(ancestor, parentNode); }; /** * @param {Element} element * @return {Array.<Element>} An array of any non-transparent elements which * overlap the given element. */ axs.utils.overlappingElements = function(element) { if (axs.utils.elementHasZeroArea(element)) return null; var overlappingElements = []; var clientRects = element.getClientRects(); for (var i = 0; i < clientRects.length; i++) { var rect = clientRects[i]; var center_x = (rect.left + rect.right) / 2; var center_y = (rect.top + rect.bottom) / 2; var elementAtPoint = document.elementFromPoint(center_x, center_y); if (elementAtPoint == null || elementAtPoint == element || axs.utils.isAncestor(elementAtPoint, element) || axs.utils.isAncestor(element, elementAtPoint)) { continue; } var overlappingElementStyle = window.getComputedStyle(elementAtPoint, null); if (!overlappingElementStyle) continue; var overlappingElementBg = axs.utils.getBgColor(overlappingElementStyle, elementAtPoint); if (overlappingElementBg && overlappingElementBg.alpha > 0 && overlappingElements.indexOf(elementAtPoint) < 0) { overlappingElements.push(elementAtPoint); } } return overlappingElements; }; /** * @param {Element} element * @return {boolean} */ axs.utils.elementIsHtmlControl = function(element) { var defaultView = element.ownerDocument.defaultView; // HTML control if (element instanceof defaultView.HTMLButtonElement) return true; if (element instanceof defaultView.HTMLInputElement) return true; if (element instanceof defaultView.HTMLSelectElement) return true; if (element instanceof defaultView.HTMLTextAreaElement) return true; return false; }; /** * @param {Element} element * @return {boolean} */ axs.utils.elementIsAriaWidget = function(element) { if (element.hasAttribute('role')) { var roleValue = element.getAttribute('role'); // TODO is this correct? if (roleValue) { var role = axs.constants.ARIA_ROLES[roleValue]; if (role && 'widget' in role['allParentRolesSet']) return true; } } return false; }; /** * @param {Element} element * @return {boolean} */ axs.utils.elementIsVisible = function(element) { if (axs.utils.elementIsTransparent(element)) return false; if (axs.utils.elementHasZeroArea(element)) return false; if (axs.utils.elementIsOutsideScrollArea(element)) return false; var overlappingElements = axs.utils.overlappingElements(element); if (overlappingElements.length) return false; return true; }; /** * @param {CSSStyleDeclaration} style * @return {boolean} */ axs.utils.isLargeFont = function(style) { var fontSize = style.fontSize; var bold = style.fontWeight == 'bold'; var matches = fontSize.match(/(\d+)px/); if (matches) { var fontSizePx = parseInt(matches[1], 10); var bodyStyle = window.getComputedStyle(document.body, null); var bodyFontSize = bodyStyle.fontSize; matches = bodyFontSize.match(/(\d+)px/); if (matches) { var bodyFontSizePx = parseInt(matches[1], 10); var boldLarge = bodyFontSizePx * 1.2; var large = bodyFontSizePx * 1.5; } else { var boldLarge = 19.2; var large = 24; } return (bold && fontSizePx >= boldLarge || fontSizePx >= large); } matches = fontSize.match(/(\d+)em/); if (matches) { var fontSizeEm = parseInt(matches[1], 10); if (bold && fontSizeEm >= 1.2 || fontSizeEm >= 1.5) return true; return false; } matches = fontSize.match(/(\d+)%/); if (matches) { var fontSizePercent = parseInt(matches[1], 10); if (bold && fontSizePercent >= 120 || fontSizePercent >= 150) return true; return false; } matches = fontSize.match(/(\d+)pt/); if (matches) { var fontSizePt = parseInt(matches[1], 10); if (bold && fontSizePt >= 14 || fontSizePt >= 18) return true; return false; } return false; }; /** * @param {CSSStyleDeclaration} style * @param {Element} element * @return {?axs.color.Color} */ axs.utils.getBgColor = function(style, element) { var bgColorString = style.backgroundColor; var bgColor = axs.color.parseColor(bgColorString); if (!bgColor) return null; if (style.opacity < 1) bgColor.alpha = bgColor.alpha * style.opacity; if (bgColor.alpha < 1) { var parentBg = axs.utils.getParentBgColor(element); if (parentBg == null) return null; bgColor = axs.color.flattenColors(bgColor, parentBg); } return bgColor; }; /** * Gets the effective background color of the parent of |element|. * @param {Element} element * @return {?axs.color.Color} */ axs.utils.getParentBgColor = function(element) { /** @type {Element} */ var parent = element; var bgStack = []; var foundSolidColor = null; while ((parent = axs.dom.parentElement(parent))) { var computedStyle = window.getComputedStyle(parent, null); if (!computedStyle) continue; var parentBg = axs.color.parseColor(computedStyle.backgroundColor); if (!parentBg) continue; if (computedStyle.opacity < 1) parentBg.alpha = parentBg.alpha * computedStyle.opacity; if (parentBg.alpha == 0) continue; bgStack.push(parentBg); if (parentBg.alpha == 1) { foundSolidColor = true; break; } } if (!foundSolidColor) bgStack.push(new axs.color.Color(255, 255, 255, 1)); var bg = bgStack.pop(); while (bgStack.length) { var fg = bgStack.pop(); bg = axs.color.flattenColors(fg, bg); } return bg; }; /** * @param {CSSStyleDeclaration} style * @param {Element} element * @param {axs.color.Color} bgColor The background color, which may come from * another element (such as a parent element), for flattening into the * foreground color. * @return {?axs.color.Color} */ axs.utils.getFgColor = function(style, element, bgColor) { var fgColorString = style.color; var fgColor = axs.color.parseColor(fgColorString); if (!fgColor) return null; if (fgColor.alpha < 1) fgColor = axs.color.flattenColors(fgColor, bgColor); if (style.opacity < 1) { var parentBg = axs.utils.getParentBgColor(element); fgColor.alpha = fgColor.alpha * style.opacity; fgColor = axs.color.flattenColors(fgColor, parentBg); } return fgColor; }; /** * @param {Element} element * @return {?number} */ axs.utils.getContrastRatioForElement = function(element) { var style = window.getComputedStyle(element, null); return axs.utils.getContrastRatioForElementWithComputedStyle(style, element); }; /** * @param {CSSStyleDeclaration} style * @param {Element} element * @return {?number} */ axs.utils.getContrastRatioForElementWithComputedStyle = function(style, element) { if (axs.utils.isElementHidden(element)) return null; var bgColor = axs.utils.getBgColor(style, element); if (!bgColor) return null; var fgColor = axs.utils.getFgColor(style, element, bgColor); if (!fgColor) return null; return axs.color.calculateContrastRatio(fgColor, bgColor); }; /** * @param {Element} element * @return {boolean} */ axs.utils.isNativeTextElement = function(element) { var tagName = element.tagName.toLowerCase(); var type = element.type ? element.type.toLowerCase() : ''; if (tagName == 'textarea') return true; if (tagName != 'input') return false; switch (type) { case 'email': case 'number': case 'password': case 'search': case 'text': case 'tel': case 'url': case '': return true; default: return false; } }; /** * @param {number} contrastRatio * @param {CSSStyleDeclaration} style * @param {boolean=} opt_strict Whether to use AA (false) or AAA (true) level * @return {boolean} */ axs.utils.isLowContrast = function(contrastRatio, style, opt_strict) { // Round to nearest 0.1 var roundedContrastRatio = (Math.round(contrastRatio * 10) / 10); if (!opt_strict) { return roundedContrastRatio < 3.0 || (!axs.utils.isLargeFont(style) && roundedContrastRatio < 4.5); } else { return roundedContrastRatio < 4.5 || (!axs.utils.isLargeFont(style) && roundedContrastRatio < 7.0); } }; /** * @param {Element} element * @return {boolean} */ axs.utils.hasLabel = function(element) { var tagName = element.tagName.toLowerCase(); var type = element.type ? element.type.toLowerCase() : ''; if (element.hasAttribute('aria-label')) return true; if (element.hasAttribute('title')) return true; if (tagName == 'img' && element.hasAttribute('alt')) return true; if (tagName == 'input' && type == 'image' && element.hasAttribute('alt')) return true; if (tagName == 'input' && (type == 'submit' || type == 'reset')) return true; // There's a separate audit that makes sure this points to an actual element or elements. if (element.hasAttribute('aria-labelledby')) return true; if (element.hasAttribute('id')) { var labelsFor = document.querySelectorAll('label[for="' + element.id + '"]'); if (labelsFor.length > 0) return true; } var parent = axs.dom.parentElement(element); while (parent) { if (parent.tagName.toLowerCase() == 'label') { var parentLabel = /** HTMLLabelElement */ parent; if (parentLabel.control == element) return true; } parent = axs.dom.parentElement(parent); } return false; }; /** * Determine if this element natively supports being disabled (i.e. via the `disabled` attribute. * Disabled here means that the element should be considered disabled according to specification. * This element may or may not be effectively disabled in practice as this is dependent on implementation. * * @param {Element} element An element to check. * @return {boolean} true If the element supports being natively disabled. */ axs.utils.isNativelyDisableable = function(element) { var tagName = element.tagName.toUpperCase(); return (tagName in axs.constants.NATIVELY_DISABLEABLE); }; /** * Determine if this element is disabled directly or indirectly by a disabled ancestor. * Disabled here means that the element should be considered disabled according to specification. * This element may or may not be effectively disabled in practice as this is dependent on implementation. * * @param {Element} element An element to check. * @param {boolean=} ignoreAncestors If true do not check for disabled ancestors. * @return {boolean} true if the element or one of its ancestors is disabled. */ axs.utils.isElementDisabled = function(element, ignoreAncestors) { var selector = ignoreAncestors ? '[aria-disabled=true]' : '[aria-disabled=true], [aria-disabled=true] *'; if (axs.browserUtils.matchSelector(element, selector)) { return true; } if (!axs.utils.isNativelyDisableable(element) || axs.browserUtils.matchSelector(element, 'fieldset>legend:first-of-type *')) { return false; } for (var next = element; next !== null; next = axs.dom.parentElement(next)) { if (next.hasAttribute('disabled')) { return true; } if (ignoreAncestors) { return false; } } return false; }; /** * @param {Element} element An element to check. * @return {boolean} True if the element is hidden from accessibility. */ axs.utils.isElementHidden = function(element) { if (!(element instanceof element.ownerDocument.defaultView.HTMLElement)) return false; if (element.hasAttribute('chromevoxignoreariahidden')) var chromevoxignoreariahidden = true; var style = window.getComputedStyle(element, null); if (style.display == 'none' || style.visibility == 'hidden') return true; if (element.hasAttribute('aria-hidden') && element.getAttribute('aria-hidden').toLowerCase() == 'true') { return !chromevoxignoreariahidden; } return false; }; /** * @param {Element} element An element to check. * @return {boolean} True if the element or one of its ancestors is * hidden from accessibility. */ axs.utils.isElementOrAncestorHidden = function(element) { if (axs.utils.isElementHidden(element)) return true; if (axs.dom.parentElement(element)) return axs.utils.isElementOrAncestorHidden(axs.dom.parentElement(element)); else return false; }; /** * @param {Element} element An element to check * @return {boolean} True if the given element is an inline element, false * otherwise. */ axs.utils.isInlineElement = function(element) { var tagName = element.tagName.toUpperCase(); return axs.constants.InlineElements[tagName]; }; /** * * Gets role details from an element. * @param {Element} element The DOM element whose role we want. * @param {boolean=} implicit if true then implicit semantics will be considered if there is no role attribute. * * @return {Object} */ axs.utils.getRoles = function(element, implicit) { if (!element || element.nodeType !== Node.ELEMENT_NODE || (!element.hasAttribute('role') && !implicit)) return null; var roleValue = element.getAttribute('role'); if (!roleValue && implicit) roleValue = axs.properties.getImplicitRole(element); if (!roleValue) // role='' or implicit role came up empty return null; var roleNames = roleValue.split(' '); var result = { roles: [], valid: false }; for (var i = 0; i < roleNames.length; i++) { var role = roleNames[i]; var ariaRole = axs.constants.ARIA_ROLES[role]; var roleObject = { 'name': role }; if (ariaRole && !ariaRole.abstract) { roleObject.details = ariaRole; if (!result.applied) { result.applied = roleObject; } roleObject.valid = result.valid = true; } else { roleObject.valid = false; } result.roles.push(roleObject); } return result; }; /** * @param {!string} propertyName * @param {!string} value * @param {!Element} element * @return {!Object} */ axs.utils.getAriaPropertyValue = function(propertyName, value, element) { var propertyKey = propertyName.replace(/^aria-/, ''); var property = axs.constants.ARIA_PROPERTIES[propertyKey]; var result = { 'name': propertyName, 'rawValue': value }; if (!property) { result.valid = false; result.reason = '"' + propertyName + '" is not a valid ARIA property'; return result; } var propertyType = property.valueType; if (!propertyType) { result.valid = false; result.reason = '"' + propertyName + '" is not a valid ARIA property'; return result; } switch (propertyType) { case "idref": var isValid = axs.utils.isValidIDRefValue(value, element); result.valid = isValid.valid; result.reason = isValid.reason; result.idref = isValid.idref; // falls through case "idref_list": var idrefValues = value.split(/\s+/); result.valid = true; for (var i = 0; i < idrefValues.length; i++) { var refIsValid = axs.utils.isValidIDRefValue(idrefValues[i], element); if (!refIsValid.valid) result.valid = false; if (result.values) result.values.push(refIsValid); else result.values = [refIsValid]; } return result; case "integer": var validNumber = axs.utils.isValidNumber(value); if (!validNumber.valid) { result.valid = false; result.reason = validNumber.reason; return result; } if (Math.floor(validNumber.value) !== validNumber.value) { result.valid = false; result.reason = '' + value + ' is not a whole integer'; } else { result.valid = true; result.value = validNumber.value; } return result; case "decimal": case "number": var validNumber = axs.utils.isValidNumber(value); result.valid = validNumber.valid; if (!validNumber.valid) { result.reason = validNumber.reason; return result; } result.value = validNumber.value; return result; case "string": result.valid = true; result.value = value; return result; case "token": var validTokenValue = axs.utils.isValidTokenValue(propertyName, value.toLowerCase()); if (validTokenValue.valid) { result.valid = true; result.value = validTokenValue.value; return result; } else { result.valid = false; result.value = value; result.reason = validTokenValue.reason; return result; } // falls through case "token_list": var tokenValues = value.split(/\s+/); result.valid = true; for (var i = 0; i < tokenValues.length; i++) { var validTokenValue = axs.utils.isValidTokenValue(propertyName, tokenValues[i].toLowerCase()); if (!validTokenValue.valid) { result.valid = false; if (result.reason) { result.reason = [ result.reason ]; result.reason.push(validTokenValue.reason); } else { result.reason = validTokenValue.reason; result.possibleValues = validTokenValue.possibleValues; } } // TODO (more structured result) if (result.values) result.values.push(validTokenValue.value); else result.values = [validTokenValue.value]; } return result; case "tristate": var validTristate = axs.utils.isPossibleValue(value.toLowerCase(), axs.constants.MIXED_VALUES, propertyName); if (validTristate.valid) { result.valid = true; result.value = validTristate.value; } else { result.valid = false; result.value = value; result.reason = validTristate.reason; } return result; case "boolean": var validBoolean = axs.utils.isValidBoolean(value); if (validBoolean.valid) { result.valid = true; result.value = validBoolean.value; } else { result.valid = false; result.value = value; result.reason = validBoolean.reason; } return result; } result.valid = false; result.reason = 'Not a valid ARIA property'; return result; }; /** * @param {string} propertyName The name of the property. * @param {string} value The value to check. * @return {!Object} */ axs.utils.isValidTokenValue = function(propertyName, value) { var propertyKey = propertyName.replace(/^aria-/, ''); var propertyDetails = axs.constants.ARIA_PROPERTIES[propertyKey]; var possibleValues = propertyDetails.valuesSet; return axs.utils.isPossibleValue(value, possibleValues, propertyName); }; /** * @param {string} value * @param {Object.<string, boolean>} possibleValues * @param {string} propertyName The name of the property. * @return {!Object} */ axs.utils.isPossibleValue = function(value, possibleValues, propertyName) { if (!possibleValues[value]) return { 'valid': false, 'value': value, 'reason': '"' + value + '" is not a valid value for ' + propertyName, 'possibleValues': Object.keys(possibleValues) }; return { 'valid': true, 'value': value }; }; /** * @param {string} value * @return {!Object} */ axs.utils.isValidBoolean = function(value) { try { var parsedValue = JSON.parse(value); } catch (e) { parsedValue = ''; } if (typeof(parsedValue) != 'boolean') return { 'valid': false, 'value': value, 'reason': '"' + value + '" is not a true/false value' }; return { 'valid': true, 'value': parsedValue }; }; /** * @param {string} value * @param {!Element} element * @return {!Object} */ axs.utils.isValidIDRefValue = function(value, element) { if (value.length == 0) return { 'valid': true, 'idref': value }; if (!element.ownerDocument.getElementById(value)) return { 'valid': false, 'idref': value, 'reason': 'No element with ID "' + value + '"' }; return { 'valid': true, 'idref': value }; }; /** * Tests if a number is real number for a11y purposes. * Must be a real, numerical, decimal value; heavily inspired by * http://www.w3.org/TR/wai-aria/states_and_properties#valuetype_number * @param {string} value * @return {!Object} */ axs.utils.isValidNumber = function(value) { var failResult = { 'valid': false, 'value': value, 'reason': '"' + value + '" is not a number' }; if (!value) { return failResult; } if (/^0x/i.test(value)) { failResult.reason = '"' + value + '" is not a decimal number'; // hex is not accepted return failResult; } var parsedValue = value * 1; if (!isFinite(parsedValue)) { return failResult; } return { 'valid': true, 'value': parsedValue }; }; /** * @param {Element} element * @return {boolean} */ axs.utils.isElementImplicitlyFocusable = function(element) { var defaultView = element.ownerDocument.defaultView; if (element instanceof defaultView.HTMLAnchorElement || element instanceof defaultView.HTMLAreaElement) return element.hasAttribute('href'); if (element instanceof defaultView.HTMLInputElement || element instanceof defaultView.HTMLSelectElement || element instanceof defaultView.HTMLTextAreaElement || element instanceof defaultView.HTMLButtonElement || element instanceof defaultView.HTMLIFrameElement) return !element.disabled; return false; }; /** * Returns an array containing the values of the given JSON-compatible object. * (Simply ignores any function values.) * @param {Object} obj * @return {Array} */ axs.utils.values = function(obj) { var values = []; for (var key in obj) { if (obj.hasOwnProperty(key) && typeof obj[key] != 'function') values.push(obj[key]); } return values; }; /** * Returns an object containing the same keys and values as the given * JSON-compatible object. (Simply ignores any function values.) * @param {Object} obj * @return {Object} */ axs.utils.namedValues = function(obj) { var values = {}; for (var key in obj) { if (obj.hasOwnProperty(key) && typeof obj[key] != 'function') values[key] = obj[key]; } return values; }; /** * Escapes a given ID to be used in a CSS selector * * @private * @param {!string} id The ID to be escaped * @return {string} The escaped ID */ function escapeId(id) { return id.replace(/[^a-zA-Z0-9_-]/g,function(match) { return '\\' + match; }); } /** Gets a CSS selector text for a DOM object. * @param {Node} obj The DOM object. * @return {string} CSS selector text for the DOM object. */ axs.utils.getQuerySelectorText = function(obj) { if (obj == null || obj.tagName == 'HTML') { return 'html'; } else if (obj.tagName == 'BODY') { return 'body'; } if (obj.hasAttribute) { if (obj.id) { return '#' + escapeId(obj.id); } if (obj.className) { var selector = ''; for (var i = 0; i < obj.classList.length; i++) selector += '.' + obj.classList[i]; var total = 0; if (obj.parentNode) { for (i = 0; i < obj.parentNode.children.length; i++) { var similar = obj.parentNode.children[i]; if (axs.browserUtils.matchSelector(similar, selector)) total++; if (similar === obj) break; } } else { total = 1; } if (total == 1) { return axs.utils.getQuerySelectorText(obj.parentNode) + ' > ' + selector; } } if (obj.parentNode) { var similarTags = obj.parentNode.children; var total = 1; var i = 0; while (similarTags[i] !== obj) { if (similarTags[i].tagName == obj.tagName) { total++; } i++; } var next = ''; if (obj.parentNode.tagName != 'BODY') { next = axs.utils.getQuerySelectorText(obj.parentNode) + ' > '; } if (total == 1) { return next + obj.tagName; } else { return next + obj.tagName + ':nth-of-type(' + total + ')'; } } } else if (obj.selectorText) { return obj.selectorText; } return ''; }; /** * Gets elements that refer to this element in an ARIA attribute that takes an ID reference list or * single ID reference. * @param {Element} element a potential referent. * @param {string=} opt_attributeName Name of an ARIA attribute to limit the results to, e.g. 'aria-owns'. * @return {NodeList} The elements that refer to this element or null. */ axs.utils.getAriaIdReferrers = function(element, opt_attributeName) { var propertyToSelector = function(propertyKey) { var propertyDetails = axs.constants.ARIA_PROPERTIES[propertyKey]; if (propertyDetails) { if (propertyDetails.valueType === ('idref')) { return '[aria-' + propertyKey + '=\'' + id + '\']'; } else if (propertyDetails.valueType === ('idref_list')) { return '[aria-' + propertyKey + '~=\'' + id + '\']'; } } return ''; }; if (!element) return null; var id = element.id; if (!id) return null; id = id.replace(/'/g, "\\'"); // make it safe to use in a selector if (opt_attributeName) { var propertyKey = opt_attributeName.replace(/^aria-/, ''); var referrerQuery = propertyToSelector(propertyKey); if (referrerQuery) { return element.ownerDocument.querySelectorAll(referrerQuery); } } else { var selectors = []; for (var propertyKey in axs.constants.ARIA_PROPERTIES) { var referrerQuery = propertyToSelector(propertyKey); if (referrerQuery) { selectors.push(referrerQuery); } } return element.ownerDocument.querySelectorAll(selectors.join(',')); } return null; }; /** * Gets elements that refer to this element in an HTML attribute that takes an ID reference list or * single ID reference. * @param {Element} element a potential referent. * @return {NodeList} The elements that refer to this element. */ axs.utils.getHtmlIdReferrers = function(element) { if (!element) return null; var id = element.id; if (!id) return null; id = id.replace(/'/g, "\\'"); // make it safe to use in a selector var selectorTemplates = [ '[contextmenu=\'{id}\']', '[itemref~=\'{id}\']', 'button[form=\'{id}\']', 'button[menu=\'{id}\']', 'fieldset[form=\'{id}\']', 'input[form=\'{id}\']', 'input[list=\'{id}\']', 'keygen[form=\'{id}\']', 'label[for=\'{id}\']', 'label[form=\'{id}\']', 'menuitem[command=\'{id}\']', 'object[form=\'{id}\']', 'output[for~=\'{id}\']', 'output[form=\'{id}\']', 'select[form=\'{id}\']', 'td[headers~=\'{id}\']', 'textarea[form=\'{id}\']', 'tr[headers~=\'{id}\']']; var selectors = selectorTemplates.map(function(selector) { return selector.replace('\{id\}', id); }); return element.ownerDocument.querySelectorAll(selectors.join(',')); }; /** * Gets a list of all IDs this element references in either ARIA or HTML attributes. * * @param {Element} element The element to check for idref attributes. * @returns {Array.<string>} Any IDs this element references. */ axs.utils.getReferencedIds = function(element) { var result = []; var addResult = function(ids) { if (ids) { if (ids.indexOf(' ') > 0) { result = result.concat(attrib.value.split(' ')); } else { result.push(ids); } } }; for (var i = 0; i < element.attributes.length; i++) { var tagName = element.tagName.toLowerCase(); var attrib = element.attributes[i]; if (attrib.specified) { var attribName = attrib.name; var ariaAttr = attribName.match(/aria-(.+)/); if (ariaAttr) { var details = axs.constants.ARIA_PROPERTIES[ariaAttr[1]]; if (details && (details.valueType === ('idref') || details.valueType === ('idref_list'))) { addResult(attrib.value); } continue; } switch (attribName) { case 'contextmenu': case 'itemref': addResult(attrib.value); break; case 'form': if (tagName == 'button' || tagName == 'fieldset' || tagName == 'input' || tagName == 'keygen' || tagName == 'label' || tagName == 'object' || tagName == 'output' || tagName == 'select' || tagName == 'textarea') { addResult(attrib.value); } break; case 'for': if (tagName == 'label' || tagName == 'output') { addResult(attrib.value); } break; case 'menu': if (tagName == 'button') { addResult(attrib.value); } break; case 'list': if (tagName == 'input') { addResult(attrib.value); } break; case 'command': if (tagName == 'menuitem') { addResult(attrib.value); } break; case 'headers': if (tagName == 'td' || tagName == 'tr') { addResult(attrib.value); } break; } } } return result; }; /** * Gets elements that refer to this element in an attribute that takes an ID reference list or * single ID reference. * @param {Element} element a potential referent. * @return {Array<Element>} The elements that refer to this element. */ axs.utils.getIdReferrers = function(element) { var result = []; var referrers = axs.utils.getHtmlIdReferrers(element); if (referrers) { result = result.concat(Array.prototype.slice.call(referrers)); } referrers = axs.utils.getAriaIdReferrers(element); if (referrers) { result = result.concat(Array.prototype.slice.call(referrers)); } return result; }; /** * Gets elements which this element refers to in the given attribute. * @param {!string} attributeName Name of an ARIA attribute, e.g. 'aria-owns'. * @param {Element} element The DOM element which has the ARIA attribute. * @return {!Array.<Element>} An array of elements that are referred to by this element. * @example * var owner = document.body.appendChild(document.createElement("div")); * var owned = document.body.appendChild(document.createElement("div")); * owner.setAttribute("aria-owns", "kungfu"); * owned.setAttribute("id", "kungfu"); * console.log(axs.utils.getIdReferents("aria-owns", owner)[0] === owned); // This will log 'true' */ axs.utils.getIdReferents = function(attributeName, element) { var result = []; var propertyKey = attributeName.replace(/^aria-/, ''); var property = axs.constants.ARIA_PROPERTIES[propertyKey]; if (!property || !element.hasAttribute(attributeName)) return result; var propertyType = property.valueType; if (propertyType === 'idref_list' || propertyType === 'idref') { var ownerDocument = element.ownerDocument; var ids = element.getAttribute(attributeName); ids = ids.split(/\s+/); for (var i = 0, len = ids.length; i < len; i++) { var next = ownerDocument.getElementById(ids[i]); if (next) { result[result.length] = next; } } } return result; }; /** * Gets a subset of 'axs.constants.ARIA_PROPERTIES' filtered by 'valueType'. * @param {!Array.<string>} valueTypes Types to match, e.g. ['idref_list']. * @return {Object.<string, Object>} axs.constants.ARIA_PROPERTIES which match. */ axs.utils.getAriaPropertiesByValueType = function(valueTypes) { var result = {}; for (var propertyName in axs.constants.ARIA_PROPERTIES) { var property = axs.constants.ARIA_PROPERTIES[propertyName]; if (property && valueTypes.indexOf(property.valueType) >= 0) { result[propertyName] = property; } } return result; }; /** * Builds a selector that matches an element with any of these ARIA properties. * @param {Object.<string, Object>} ariaProperties axs.constants.ARIA_PROPERTIES * @return {!string} The selector. */ axs.utils.getSelectorForAriaProperties = function(ariaProperties) { var propertyNames = Object.keys(/** @type {!Object} */(ariaProperties)); var result = propertyNames.map(function(propertyName) { return '[aria-' + propertyName + ']'; }); result.sort(); // facilitates reading long selectors and unit testing return result.join(','); }; /** * Finds descendants of this element which implement the given ARIA role. * Will look for descendants with implicit or explicit role. * @param {Element} element an HTML DOM element. * @param {string} role The role you seek. * @return {!Array.<Element>} An array of matching elements. * @example * var container = document.createElement("div"); * var button = document.createElement("button"); * var span = document.createElement("span"); * span.setAttribute("role", "button"); * container.appendChild(button); * container.appendChild(span); * var result = axs.utils.findDescendantsWithRole(container, "button"); // result is an array containing both 'button' and 'span' */ axs.utils.findDescendantsWithRole = function(element, role) { if (!(element && role)) return []; var selector = axs.properties.getSelectorForRole(role); if (!selector) return []; var result = element.querySelectorAll(selector); if (result) { // Convert NodeList to Array; methinks 80/20 that's what callers want. result = Array.prototype.map.call(result, function(item) { return item; }); } else { return []; } return result; };