blob: 41201eb2d20f9e95cf5c17f57e725e4e4d08d7e8 [file] [log] [blame]
// Copyright 2017 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
let _id = 0;
/**
* @param {string} prefix
* @return {string}
*/
export function nextId(prefix) {
return (prefix || '') + ++_id;
}
/**
* @param {!Element} label
* @param {!Element} control
*/
export function bindLabelToControl(label, control) {
const controlId = nextId('labelledControl');
control.id = controlId;
label.setAttribute('for', controlId);
}
/**
* @param {!Element} element
*/
export function markAsAlert(element) {
element.setAttribute('role', 'alert');
element.setAttribute('aria-live', 'polite');
}
/**
* @param {!Element} element
*/
export function markAsButton(element) {
element.setAttribute('role', 'button');
}
/**
* @param {!Element} element
*/
export function markAsCheckbox(element) {
element.setAttribute('role', 'checkbox');
}
/**
* @param {!Element} element
*/
export function markAsCombobox(element) {
element.setAttribute('role', 'combobox');
}
/**
* @param {!Element} element
*/
export function markAsModalDialog(element) {
element.setAttribute('role', 'dialog');
element.setAttribute('aria-modal', 'true');
}
/**
* @param {!Element} element
*/
export function markAsGroup(element) {
element.setAttribute('role', 'group');
}
/**
* @param {!Element} element
*/
export function markAsLink(element) {
element.setAttribute('role', 'link');
}
/**
* @param {!Element} element
*/
export function markAsMenuButton(element) {
markAsButton(element);
element.setAttribute('aria-haspopup', true);
}
/**
* @param {!Element} element
* @param {number=} min
* @param {number=} max
*/
export function markAsProgressBar(element, min = 0, max = 100) {
element.setAttribute('role', 'progressbar');
element.setAttribute('aria-valuemin', min);
element.setAttribute('aria-valuemax', max);
}
/**
* @param {!Element} element
*/
export function markAsTab(element) {
element.setAttribute('role', 'tab');
}
/**
* @param {!Element} element
*/
export function markAsTabpanel(element) {
element.setAttribute('role', 'tabpanel');
}
/**
* @param {!Element} element
*/
export function markAsTree(element) {
element.setAttribute('role', 'tree');
}
/**
* @param {!Element} element
*/
export function markAsTreeitem(element) {
element.setAttribute('role', 'treeitem');
}
/**
* @param {!Element} element
*/
export function markAsTextBox(element) {
element.setAttribute('role', 'textbox');
}
/**
* @param {!Element} element
*/
export function markAsMenu(element) {
element.setAttribute('role', 'menu');
}
/**
* @param {!Element} element
*/
export function markAsMenuItem(element) {
element.setAttribute('role', 'menuitem');
}
/**
* @param {!Element} element
*/
export function markAsMenuItemSubMenu(element) {
markAsMenuItem(element);
element.setAttribute('aria-haspopup', true);
}
/**
* @param {!Element} element
*/
export function markAsList(element) {
element.setAttribute('role', 'list');
}
/**
* @param {!Element} element
*/
export function markAsListitem(element) {
element.setAttribute('role', 'listitem');
}
/**
* Must contain children whose role is option.
* @param {!Element} element
*/
export function markAsListBox(element) {
element.setAttribute('role', 'listbox');
}
/**
* @param {!Element} element
*/
export function markAsMultiSelectable(element) {
element.setAttribute('aria-multiselectable', 'true');
}
/**
* Must be contained in, or owned by, an element with the role listbox.
* @param {!Element} element
*/
export function markAsOption(element) {
element.setAttribute('role', 'option');
}
/**
* @param {!Element} element
*/
export function markAsRadioGroup(element) {
element.setAttribute('role', 'radiogroup');
}
/**
* @param {!Element} element
*/
export function markAsHidden(element) {
element.setAttribute('aria-hidden', 'true');
}
/**
* @param {!Element} element
* @param {number=} min
* @param {number=} max
*/
export function markAsSlider(element, min = 0, max = 100) {
element.setAttribute('role', 'slider');
element.setAttribute('aria-valuemin', String(min));
element.setAttribute('aria-valuemax', String(max));
}
/**
* @param {!Element} element
* @param {number} level
*/
export function markAsHeading(element, level) {
element.setAttribute('role', 'heading');
element.setAttribute('aria-level', level);
}
/**
* @param {!Element} element
*/
export function markAsPoliteLiveRegion(element) {
element.setAttribute('aria-live', 'polite');
}
/**
* @param {!Element} element
* @return {boolean}
*/
export function hasRole(element) {
return element.hasAttribute('role');
}
/**
* @param {!Element} element
* @param {?string} placeholder
*/
export function setPlaceholder(element, placeholder) {
if (placeholder) {
element.setAttribute('aria-placeholder', placeholder);
} else {
element.removeAttribute('aria-placeholder');
}
}
/**
* @param {!Element} element
*/
export function markAsPresentation(element) {
element.setAttribute('role', 'presentation');
}
/**
* @param {!Element} element
*/
export function markAsStatus(element) {
element.setAttribute('role', 'status');
}
/**
* @param {!Element} element
*/
export function ensureId(element) {
if (!element.id) {
element.id = nextId('ariaElement');
}
}
/**
* @param {!Element} element
* @param {string} valueText
*/
export function setAriaValueText(element, valueText) {
element.setAttribute('aria-valuetext', valueText);
}
/**
* @param {!Element} element
* @param {string} value
*/
export function setAriaValueNow(element, value) {
element.setAttribute('aria-valuenow', value);
}
/**
* @param {!Element} element
* @param {string} min
* @param {string} max
*/
export function setAriaValueMinMax(element, min, max) {
element.setAttribute('aria-valuemin', min);
element.setAttribute('aria-valuemax', max);
}
/**
* @param {!Element} element
* @param {?Element} controlledElement
*/
export function setControls(element, controlledElement) {
if (!controlledElement) {
element.removeAttribute('aria-controls');
return;
}
ensureId(controlledElement);
element.setAttribute('aria-controls', controlledElement.id);
}
/**
* @param {!Element} element
* @param {boolean} value
*/
export function setChecked(element, value) {
element.setAttribute('aria-checked', !!value);
}
/**
* @param {!Element} element
*/
export function setCheckboxAsIndeterminate(element) {
element.setAttribute('aria-checked', 'mixed');
}
/**
* @param {!Element} element
* @param {boolean} value
*/
export function setExpanded(element, value) {
element.setAttribute('aria-expanded', !!value);
}
/**
* @param {!Element} element
*/
export function unsetExpandable(element) {
element.removeAttribute('aria-expanded');
}
/**
* @param {!Element} element
* @param {boolean} value
*/
export function setHidden(element, value) {
element.setAttribute('aria-hidden', !!value);
}
/**
* @enum {string}
*/
export const AutocompleteInteractionModel = {
inline: 'inline',
list: 'list',
both: 'both',
none: 'none',
};
/**
* @param {!Element} element
* @param {!AutocompleteInteractionModel=} interactionModel
*/
export function setAutocomplete(element, interactionModel = AutocompleteInteractionModel.none) {
element.setAttribute('aria-autocomplete', interactionModel);
}
/**
* @param {!Element} element
* @param {boolean} value
*/
export function setSelected(element, value) {
// aria-selected behaves differently for false and undefined.
// Often times undefined values are unintentionally typed as booleans.
// Use !! to make sure this is true or false.
element.setAttribute('aria-selected', !!value);
}
/**
* @param {!Element} element
* @param {boolean} value
*/
export function setInvalid(element, value) {
if (value) {
element.setAttribute('aria-invalid', value);
} else {
element.removeAttribute('aria-invalid');
}
}
/**
* @param {!Element} element
* @param {boolean} value
*/
export function setPressed(element, value) {
// aria-pressed behaves differently for false and undefined.
// Often times undefined values are unintentionally typed as booleans.
// Use !! to make sure this is true or false.
element.setAttribute('aria-pressed', !!value);
}
/**
* @param {!Element} element
* @param {number} value
*/
export function setValueNow(element, value) {
element.setAttribute('aria-valuenow', value);
}
export function setValueText(element, value) {
element.setAttribute('aria-valuetext', value);
}
/**
* @param {!Element} element
* @param {number} valueNow
* @param {string=} valueText
*/
export function setProgressBarValue(element, valueNow, valueText) {
element.setAttribute('aria-valuenow', valueNow);
if (valueText) {
element.setAttribute('aria-valuetext', valueText);
}
}
/**
* @param {!Element} element
* @param {string} name
*/
export function setAccessibleName(element, name) {
element.setAttribute('aria-label', name);
}
/** @type {!WeakMap<!Element, !Element>} */
const _descriptionMap = new WeakMap();
/**
* @param {!Element} element
* @param {string} description
*/
export function setDescription(element, description) {
// Nodes in the accesesibility tree are made up of a core
// triplet of "name", "value", "description"
// The "description" field is taken from either
// 1. The title html attribute
// 2. The value of the aria-description attribute.
// 3. The textContent of an element specified by aria-describedby
//
// The title attribute has the side effect of causing tooltips
// to appear with the description when the element is hovered.
// This is usually fine, except that DevTools has its own styled
// tooltips which would interfere with the browser tooltips.
//
// In future, the aria-description attribute may be used once it
// is unflagged.
//
// aria-describedby requires that an extra element exist in DOM
// that this element can point to. Both elements also have to
// be in the same shadow root. This is not trivial to manage.
// The rest of DevTools shouldn't have to worry about this,
// so there is some unfortunate code below.
if (_descriptionMap.has(element)) {
_descriptionMap.get(element).remove();
}
element.removeAttribute('data-aria-utils-animation-hack');
if (!description) {
_descriptionMap.delete(element);
element.removeAttribute('aria-describedby');
return;
}
// We make a hidden element that contains the decsription
// and will be pointed to by aria-describedby.
const descriptionElement = createElement('span');
descriptionElement.textContent = description;
descriptionElement.style.display = 'none';
ensureId(descriptionElement);
element.setAttribute('aria-describedby', descriptionElement.id);
_descriptionMap.set(element, descriptionElement);
// Now we have to actually put this description element
// somewhere in the DOM so that we can point to it.
// It would be nice to just put it in the body, but that
// wouldn't work if the main element is in a shadow root.
// So the cleanest approach is to add the description element
// as a child of the main element. But wait! Some HTML elements
// aren't supposed to have children. Blink won't search inside
// these elements, and won't find our description element.
const contentfulVoidTags = new Set(['INPUT', 'IMG']);
if (!contentfulVoidTags.has(element.tagName)) {
element.appendChild(descriptionElement);
// If we made it here, someone setting .textContent
// or removeChildren on the element will blow away
// our description. At least we tried our best!
return;
}
// We have some special element, like an <input>, where putting the
// description element inside it doesn't work.
// Lets try the next best thing, and just put the description element
// next to it in the DOM.
const inserted = element.insertAdjacentElement('afterend', descriptionElement);
if (inserted) {
return;
}
// Uh oh, the insertion didn't work! That means we aren't currently in the DOM.
// How can we find out when the element enters the DOM?
// See inspectorCommon.css
element.setAttribute('data-aria-utils-animation-hack', 'sorry');
element.addEventListener('animationend', () => {
// Someone might have made a new description in the meantime.
if (_descriptionMap.get(element) !== descriptionElement) {
return;
}
element.removeAttribute('data-aria-utils-animation-hack');
// Try it again. This time we are in the DOM, so it *should* work.
element.insertAdjacentElement('afterend', descriptionElement);
}, {once: true});
}
/**
* @param {!Element} element
* @param {?Element} activedescendant
*/
export function setActiveDescendant(element, activedescendant) {
if (!activedescendant) {
element.removeAttribute('aria-activedescendant');
return;
}
console.assert(element.hasSameShadowRoot(activedescendant), 'elements are not in the same shadow dom');
ensureId(activedescendant);
element.setAttribute('aria-activedescendant', activedescendant.id);
}
const AlertElementSymbol = Symbol('AlertElementSybmol');
/**
* @param {string} message
* @param {!Element} element
*/
export function alert(message, element) {
const document = element.ownerDocument;
if (!document[AlertElementSymbol]) {
const alertElement = document.body.createChild('div');
alertElement.style.position = 'absolute';
alertElement.style.left = '-999em';
alertElement.style.width = '100em';
alertElement.style.overflow = 'hidden';
alertElement.setAttribute('role', 'alert');
alertElement.setAttribute('aria-atomic', 'true');
document[AlertElementSymbol] = alertElement;
}
document[AlertElementSymbol].textContent = message.trimEndWithMaxLength(10000);
}
/** Legacy exported object */
self.UI = self.UI || {};
/* Legacy exported object*/
UI = UI || {};
self.UI.ARIAUtils = {
nextId,
bindLabelToControl,
markAsAlert,
markAsButton,
markAsCheckbox,
markAsCombobox,
markAsModalDialog,
markAsGroup,
markAsLink,
markAsMenuButton,
markAsProgressBar,
markAsTab,
markAsTabpanel,
markAsTree,
markAsTreeitem,
markAsTextBox,
markAsMenu,
markAsMenuItem,
markAsMenuItemSubMenu,
markAsList,
markAsListitem,
markAsListBox,
markAsMultiSelectable,
markAsOption,
markAsRadioGroup,
markAsHidden,
markAsSlider,
markAsHeading,
markAsPoliteLiveRegion,
setPlaceholder,
markAsPresentation,
markAsStatus,
ensureId,
setAriaValueText,
setAriaValueNow,
setAriaValueMinMax,
setControls,
setChecked,
setCheckboxAsIndeterminate,
setExpanded,
unsetExpandable,
setHidden,
AutocompleteInteractionModel,
setAutocomplete,
setSelected,
setInvalid,
setPressed,
setProgressBarValue,
setValueNow,
setValueText,
setAccessibleName,
setDescription,
setActiveDescendant,
alert,
hasRole,
};