You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
608 lines
18 KiB
608 lines
18 KiB
/** |
|
* implements https://w3c.github.io/accname/ |
|
*/ |
|
import ArrayFrom from "./polyfills/array.from.mjs"; |
|
import SetLike from "./polyfills/SetLike.mjs"; |
|
import { hasAnyConcreteRoles, isElement, isHTMLTableCaptionElement, isHTMLInputElement, isHTMLSelectElement, isHTMLTextAreaElement, safeWindow, isHTMLFieldSetElement, isHTMLLegendElement, isHTMLOptGroupElement, isHTMLTableElement, isHTMLSlotElement, isSVGSVGElement, isSVGTitleElement, queryIdRefs, getLocalName } from "./util.mjs"; |
|
/** |
|
* A string of characters where all carriage returns, newlines, tabs, and form-feeds are replaced with a single space, and multiple spaces are reduced to a single space. The string contains only character data; it does not contain any markup. |
|
*/ |
|
|
|
/** |
|
* |
|
* @param {string} string - |
|
* @returns {FlatString} - |
|
*/ |
|
function asFlatString(s) { |
|
return s.trim().replace(/\s\s+/g, " "); |
|
} |
|
/** |
|
* |
|
* @param node - |
|
* @param options - These are not optional to prevent accidentally calling it without options in `computeAccessibleName` |
|
* @returns {boolean} - |
|
*/ |
|
|
|
|
|
function isHidden(node, getComputedStyleImplementation) { |
|
if (!isElement(node)) { |
|
return false; |
|
} |
|
|
|
if (node.hasAttribute("hidden") || node.getAttribute("aria-hidden") === "true") { |
|
return true; |
|
} |
|
|
|
var style = getComputedStyleImplementation(node); |
|
return style.getPropertyValue("display") === "none" || style.getPropertyValue("visibility") === "hidden"; |
|
} |
|
/** |
|
* @param {Node} node - |
|
* @returns {boolean} - As defined in step 2E of https://w3c.github.io/accname/#mapping_additional_nd_te |
|
*/ |
|
|
|
|
|
function isControl(node) { |
|
return hasAnyConcreteRoles(node, ["button", "combobox", "listbox", "textbox"]) || hasAbstractRole(node, "range"); |
|
} |
|
|
|
function hasAbstractRole(node, role) { |
|
if (!isElement(node)) { |
|
return false; |
|
} |
|
|
|
switch (role) { |
|
case "range": |
|
return hasAnyConcreteRoles(node, ["meter", "progressbar", "scrollbar", "slider", "spinbutton"]); |
|
|
|
default: |
|
throw new TypeError("No knowledge about abstract role '".concat(role, "'. This is likely a bug :(")); |
|
} |
|
} |
|
/** |
|
* element.querySelectorAll but also considers owned tree |
|
* @param element |
|
* @param selectors |
|
*/ |
|
|
|
|
|
function querySelectorAllSubtree(element, selectors) { |
|
var elements = ArrayFrom(element.querySelectorAll(selectors)); |
|
queryIdRefs(element, "aria-owns").forEach(function (root) { |
|
// babel transpiles this assuming an iterator |
|
elements.push.apply(elements, ArrayFrom(root.querySelectorAll(selectors))); |
|
}); |
|
return elements; |
|
} |
|
|
|
function querySelectedOptions(listbox) { |
|
if (isHTMLSelectElement(listbox)) { |
|
// IE11 polyfill |
|
return listbox.selectedOptions || querySelectorAllSubtree(listbox, "[selected]"); |
|
} |
|
|
|
return querySelectorAllSubtree(listbox, '[aria-selected="true"]'); |
|
} |
|
|
|
function isMarkedPresentational(node) { |
|
return hasAnyConcreteRoles(node, ["none", "presentation"]); |
|
} |
|
/** |
|
* Elements specifically listed in html-aam |
|
* |
|
* We don't need this for `label` or `legend` elements. |
|
* Their implicit roles already allow "naming from content". |
|
* |
|
* sources: |
|
* |
|
* - https://w3c.github.io/html-aam/#table-element |
|
*/ |
|
|
|
|
|
function isNativeHostLanguageTextAlternativeElement(node) { |
|
return isHTMLTableCaptionElement(node); |
|
} |
|
/** |
|
* https://w3c.github.io/aria/#namefromcontent |
|
*/ |
|
|
|
|
|
function allowsNameFromContent(node) { |
|
return hasAnyConcreteRoles(node, ["button", "cell", "checkbox", "columnheader", "gridcell", "heading", "label", "legend", "link", "menuitem", "menuitemcheckbox", "menuitemradio", "option", "radio", "row", "rowheader", "switch", "tab", "tooltip", "treeitem"]); |
|
} |
|
/** |
|
* TODO https://github.com/eps1lon/dom-accessibility-api/issues/100 |
|
*/ |
|
|
|
|
|
function isDescendantOfNativeHostLanguageTextAlternativeElement( // eslint-disable-next-line @typescript-eslint/no-unused-vars -- not implemented yet |
|
node) { |
|
return false; |
|
} |
|
/** |
|
* TODO https://github.com/eps1lon/dom-accessibility-api/issues/101 |
|
*/ |
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- not implemented yet |
|
|
|
|
|
function computeTooltipAttributeValue(node) { |
|
return null; |
|
} |
|
|
|
function getValueOfTextbox(element) { |
|
if (isHTMLInputElement(element) || isHTMLTextAreaElement(element)) { |
|
return element.value; |
|
} // https://github.com/eps1lon/dom-accessibility-api/issues/4 |
|
|
|
|
|
return element.textContent || ""; |
|
} |
|
|
|
function getTextualContent(declaration) { |
|
var content = declaration.getPropertyValue("content"); |
|
|
|
if (/^["'].*["']$/.test(content)) { |
|
return content.slice(1, -1); |
|
} |
|
|
|
return ""; |
|
} |
|
/** |
|
* https://html.spec.whatwg.org/multipage/forms.html#category-label |
|
* TODO: form-associated custom elements |
|
* @param element |
|
*/ |
|
|
|
|
|
function isLabelableElement(element) { |
|
var localName = getLocalName(element); |
|
return localName === "button" || localName === "input" && element.getAttribute("type") !== "hidden" || localName === "meter" || localName === "output" || localName === "progress" || localName === "select" || localName === "textarea"; |
|
} |
|
/** |
|
* > [...], then the first such descendant in tree order is the label element's labeled control. |
|
* -- https://html.spec.whatwg.org/multipage/forms.html#labeled-control |
|
* @param element |
|
*/ |
|
|
|
|
|
function findLabelableElement(element) { |
|
if (isLabelableElement(element)) { |
|
return element; |
|
} |
|
|
|
var labelableElement = null; |
|
element.childNodes.forEach(function (childNode) { |
|
if (labelableElement === null && isElement(childNode)) { |
|
var descendantLabelableElement = findLabelableElement(childNode); |
|
|
|
if (descendantLabelableElement !== null) { |
|
labelableElement = descendantLabelableElement; |
|
} |
|
} |
|
}); |
|
return labelableElement; |
|
} |
|
/** |
|
* Polyfill of HTMLLabelElement.control |
|
* https://html.spec.whatwg.org/multipage/forms.html#labeled-control |
|
* @param label |
|
*/ |
|
|
|
|
|
function getControlOfLabel(label) { |
|
if (label.control !== undefined) { |
|
return label.control; |
|
} |
|
|
|
var htmlFor = label.getAttribute("for"); |
|
|
|
if (htmlFor !== null) { |
|
return label.ownerDocument.getElementById(htmlFor); |
|
} |
|
|
|
return findLabelableElement(label); |
|
} |
|
/** |
|
* Polyfill of HTMLInputElement.labels |
|
* https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/labels |
|
* @param element |
|
*/ |
|
|
|
|
|
function getLabels(element) { |
|
var labelsProperty = element.labels; |
|
|
|
if (labelsProperty === null) { |
|
return labelsProperty; |
|
} |
|
|
|
if (labelsProperty !== undefined) { |
|
return ArrayFrom(labelsProperty); |
|
} // polyfill |
|
|
|
|
|
if (!isLabelableElement(element)) { |
|
return null; |
|
} |
|
|
|
var document = element.ownerDocument; |
|
return ArrayFrom(document.querySelectorAll("label")).filter(function (label) { |
|
return getControlOfLabel(label) === element; |
|
}); |
|
} |
|
/** |
|
* Gets the contents of a slot used for computing the accname |
|
* @param slot |
|
*/ |
|
|
|
|
|
function getSlotContents(slot) { |
|
// Computing the accessible name for elements containing slots is not |
|
// currently defined in the spec. This implementation reflects the |
|
// behavior of NVDA 2020.2/Firefox 81 and iOS VoiceOver/Safari 13.6. |
|
var assignedNodes = slot.assignedNodes(); |
|
|
|
if (assignedNodes.length === 0) { |
|
// if no nodes are assigned to the slot, it displays the default content |
|
return ArrayFrom(slot.childNodes); |
|
} |
|
|
|
return assignedNodes; |
|
} |
|
/** |
|
* implements https://w3c.github.io/accname/#mapping_additional_nd_te |
|
* @param root |
|
* @param options |
|
* @returns |
|
*/ |
|
|
|
|
|
export function computeTextAlternative(root) { |
|
var options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; |
|
var consultedNodes = new SetLike(); |
|
var window = safeWindow(root); |
|
var _options$compute = options.compute, |
|
compute = _options$compute === void 0 ? "name" : _options$compute, |
|
_options$computedStyl = options.computedStyleSupportsPseudoElements, |
|
computedStyleSupportsPseudoElements = _options$computedStyl === void 0 ? options.getComputedStyle !== undefined : _options$computedStyl, |
|
_options$getComputedS = options.getComputedStyle, |
|
getComputedStyle = _options$getComputedS === void 0 ? window.getComputedStyle.bind(window) : _options$getComputedS, |
|
_options$hidden = options.hidden, |
|
hidden = _options$hidden === void 0 ? false : _options$hidden; // 2F.i |
|
|
|
function computeMiscTextAlternative(node, context) { |
|
var accumulatedText = ""; |
|
|
|
if (isElement(node) && computedStyleSupportsPseudoElements) { |
|
var pseudoBefore = getComputedStyle(node, "::before"); |
|
var beforeContent = getTextualContent(pseudoBefore); |
|
accumulatedText = "".concat(beforeContent, " ").concat(accumulatedText); |
|
} // FIXME: Including aria-owns is not defined in the spec |
|
// But it is required in the web-platform-test |
|
|
|
|
|
var childNodes = isHTMLSlotElement(node) ? getSlotContents(node) : ArrayFrom(node.childNodes).concat(queryIdRefs(node, "aria-owns")); |
|
childNodes.forEach(function (child) { |
|
var result = computeTextAlternative(child, { |
|
isEmbeddedInLabel: context.isEmbeddedInLabel, |
|
isReferenced: false, |
|
recursion: true |
|
}); // TODO: Unclear why display affects delimiter |
|
// see https://github.com/w3c/accname/issues/3 |
|
|
|
var display = isElement(child) ? getComputedStyle(child).getPropertyValue("display") : "inline"; |
|
var separator = display !== "inline" ? " " : ""; // trailing separator for wpt tests |
|
|
|
accumulatedText += "".concat(separator).concat(result).concat(separator); |
|
}); |
|
|
|
if (isElement(node) && computedStyleSupportsPseudoElements) { |
|
var pseudoAfter = getComputedStyle(node, "::after"); |
|
var afterContent = getTextualContent(pseudoAfter); |
|
accumulatedText = "".concat(accumulatedText, " ").concat(afterContent); |
|
} |
|
|
|
return accumulatedText.trim(); |
|
} |
|
|
|
function computeElementTextAlternative(node) { |
|
if (!isElement(node)) { |
|
return null; |
|
} |
|
/** |
|
* |
|
* @param element |
|
* @param attributeName |
|
* @returns A string non-empty string or `null` |
|
*/ |
|
|
|
|
|
function useAttribute(element, attributeName) { |
|
var attribute = element.getAttributeNode(attributeName); |
|
|
|
if (attribute !== null && !consultedNodes.has(attribute) && attribute.value.trim() !== "") { |
|
consultedNodes.add(attribute); |
|
return attribute.value; |
|
} |
|
|
|
return null; |
|
} // https://w3c.github.io/html-aam/#fieldset-and-legend-elements |
|
|
|
|
|
if (isHTMLFieldSetElement(node)) { |
|
consultedNodes.add(node); |
|
var children = ArrayFrom(node.childNodes); |
|
|
|
for (var i = 0; i < children.length; i += 1) { |
|
var child = children[i]; |
|
|
|
if (isHTMLLegendElement(child)) { |
|
return computeTextAlternative(child, { |
|
isEmbeddedInLabel: false, |
|
isReferenced: false, |
|
recursion: false |
|
}); |
|
} |
|
} |
|
} else if (isHTMLTableElement(node)) { |
|
// https://w3c.github.io/html-aam/#table-element |
|
consultedNodes.add(node); |
|
|
|
var _children = ArrayFrom(node.childNodes); |
|
|
|
for (var _i = 0; _i < _children.length; _i += 1) { |
|
var _child = _children[_i]; |
|
|
|
if (isHTMLTableCaptionElement(_child)) { |
|
return computeTextAlternative(_child, { |
|
isEmbeddedInLabel: false, |
|
isReferenced: false, |
|
recursion: false |
|
}); |
|
} |
|
} |
|
} else if (isSVGSVGElement(node)) { |
|
// https://www.w3.org/TR/svg-aam-1.0/ |
|
consultedNodes.add(node); |
|
|
|
var _children2 = ArrayFrom(node.childNodes); |
|
|
|
for (var _i2 = 0; _i2 < _children2.length; _i2 += 1) { |
|
var _child2 = _children2[_i2]; |
|
|
|
if (isSVGTitleElement(_child2)) { |
|
return _child2.textContent; |
|
} |
|
} |
|
|
|
return null; |
|
} else if (getLocalName(node) === "img" || getLocalName(node) === "area") { |
|
// https://w3c.github.io/html-aam/#area-element |
|
// https://w3c.github.io/html-aam/#img-element |
|
var nameFromAlt = useAttribute(node, "alt"); |
|
|
|
if (nameFromAlt !== null) { |
|
return nameFromAlt; |
|
} |
|
} else if (isHTMLOptGroupElement(node)) { |
|
var nameFromLabel = useAttribute(node, "label"); |
|
|
|
if (nameFromLabel !== null) { |
|
return nameFromLabel; |
|
} |
|
} |
|
|
|
if (isHTMLInputElement(node) && (node.type === "button" || node.type === "submit" || node.type === "reset")) { |
|
// https://w3c.github.io/html-aam/#input-type-text-input-type-password-input-type-search-input-type-tel-input-type-email-input-type-url-and-textarea-element-accessible-description-computation |
|
var nameFromValue = useAttribute(node, "value"); |
|
|
|
if (nameFromValue !== null) { |
|
return nameFromValue; |
|
} // TODO: l10n |
|
|
|
|
|
if (node.type === "submit") { |
|
return "Submit"; |
|
} // TODO: l10n |
|
|
|
|
|
if (node.type === "reset") { |
|
return "Reset"; |
|
} |
|
} |
|
|
|
var labels = getLabels(node); |
|
|
|
if (labels !== null && labels.length !== 0) { |
|
consultedNodes.add(node); |
|
return ArrayFrom(labels).map(function (element) { |
|
return computeTextAlternative(element, { |
|
isEmbeddedInLabel: true, |
|
isReferenced: false, |
|
recursion: true |
|
}); |
|
}).filter(function (label) { |
|
return label.length > 0; |
|
}).join(" "); |
|
} // https://w3c.github.io/html-aam/#input-type-image-accessible-name-computation |
|
// TODO: wpt test consider label elements but html-aam does not mention them |
|
// We follow existing implementations over spec |
|
|
|
|
|
if (isHTMLInputElement(node) && node.type === "image") { |
|
var _nameFromAlt = useAttribute(node, "alt"); |
|
|
|
if (_nameFromAlt !== null) { |
|
return _nameFromAlt; |
|
} |
|
|
|
var nameFromTitle = useAttribute(node, "title"); |
|
|
|
if (nameFromTitle !== null) { |
|
return nameFromTitle; |
|
} // TODO: l10n |
|
|
|
|
|
return "Submit Query"; |
|
} |
|
|
|
if (hasAnyConcreteRoles(node, ["button"])) { |
|
// https://www.w3.org/TR/html-aam-1.0/#button-element |
|
var nameFromSubTree = computeMiscTextAlternative(node, { |
|
isEmbeddedInLabel: false, |
|
isReferenced: false |
|
}); |
|
|
|
if (nameFromSubTree !== "") { |
|
return nameFromSubTree; |
|
} |
|
|
|
return useAttribute(node, "title"); |
|
} |
|
|
|
return useAttribute(node, "title"); |
|
} |
|
|
|
function computeTextAlternative(current, context) { |
|
if (consultedNodes.has(current)) { |
|
return ""; |
|
} // 2A |
|
|
|
|
|
if (!hidden && isHidden(current, getComputedStyle) && !context.isReferenced) { |
|
consultedNodes.add(current); |
|
return ""; |
|
} // 2B |
|
|
|
|
|
var labelElements = queryIdRefs(current, "aria-labelledby"); |
|
|
|
if (compute === "name" && !context.isReferenced && labelElements.length > 0) { |
|
return labelElements.map(function (element) { |
|
return computeTextAlternative(element, { |
|
isEmbeddedInLabel: context.isEmbeddedInLabel, |
|
isReferenced: true, |
|
// thais isn't recursion as specified, otherwise we would skip |
|
// `aria-label` in |
|
// <input id="myself" aria-label="foo" aria-labelledby="myself" |
|
recursion: false |
|
}); |
|
}).join(" "); |
|
} // 2C |
|
// Changed from the spec in anticipation of https://github.com/w3c/accname/issues/64 |
|
// spec says we should only consider skipping if we have a non-empty label |
|
|
|
|
|
var skipToStep2E = context.recursion && isControl(current) && compute === "name"; |
|
|
|
if (!skipToStep2E) { |
|
var ariaLabel = (isElement(current) && current.getAttribute("aria-label") || "").trim(); |
|
|
|
if (ariaLabel !== "" && compute === "name") { |
|
consultedNodes.add(current); |
|
return ariaLabel; |
|
} // 2D |
|
|
|
|
|
if (!isMarkedPresentational(current)) { |
|
var elementTextAlternative = computeElementTextAlternative(current); |
|
|
|
if (elementTextAlternative !== null) { |
|
consultedNodes.add(current); |
|
return elementTextAlternative; |
|
} |
|
} |
|
} // special casing, cheating to make tests pass |
|
// https://github.com/w3c/accname/issues/67 |
|
|
|
|
|
if (hasAnyConcreteRoles(current, ["menu"])) { |
|
consultedNodes.add(current); |
|
return ""; |
|
} // 2E |
|
|
|
|
|
if (skipToStep2E || context.isEmbeddedInLabel || context.isReferenced) { |
|
if (hasAnyConcreteRoles(current, ["combobox", "listbox"])) { |
|
consultedNodes.add(current); |
|
var selectedOptions = querySelectedOptions(current); |
|
|
|
if (selectedOptions.length === 0) { |
|
// defined per test `name_heading_combobox` |
|
return isHTMLInputElement(current) ? current.value : ""; |
|
} |
|
|
|
return ArrayFrom(selectedOptions).map(function (selectedOption) { |
|
return computeTextAlternative(selectedOption, { |
|
isEmbeddedInLabel: context.isEmbeddedInLabel, |
|
isReferenced: false, |
|
recursion: true |
|
}); |
|
}).join(" "); |
|
} |
|
|
|
if (hasAbstractRole(current, "range")) { |
|
consultedNodes.add(current); |
|
|
|
if (current.hasAttribute("aria-valuetext")) { |
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- safe due to hasAttribute guard |
|
return current.getAttribute("aria-valuetext"); |
|
} |
|
|
|
if (current.hasAttribute("aria-valuenow")) { |
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- safe due to hasAttribute guard |
|
return current.getAttribute("aria-valuenow"); |
|
} // Otherwise, use the value as specified by a host language attribute. |
|
|
|
|
|
return current.getAttribute("value") || ""; |
|
} |
|
|
|
if (hasAnyConcreteRoles(current, ["textbox"])) { |
|
consultedNodes.add(current); |
|
return getValueOfTextbox(current); |
|
} |
|
} // 2F: https://w3c.github.io/accname/#step2F |
|
|
|
|
|
if (allowsNameFromContent(current) || isElement(current) && context.isReferenced || isNativeHostLanguageTextAlternativeElement(current) || isDescendantOfNativeHostLanguageTextAlternativeElement(current)) { |
|
consultedNodes.add(current); |
|
return computeMiscTextAlternative(current, { |
|
isEmbeddedInLabel: context.isEmbeddedInLabel, |
|
isReferenced: false |
|
}); |
|
} |
|
|
|
if (current.nodeType === current.TEXT_NODE) { |
|
consultedNodes.add(current); |
|
return current.textContent || ""; |
|
} |
|
|
|
if (context.recursion) { |
|
consultedNodes.add(current); |
|
return computeMiscTextAlternative(current, { |
|
isEmbeddedInLabel: context.isEmbeddedInLabel, |
|
isReferenced: false |
|
}); |
|
} |
|
|
|
var tooltipAttributeValue = computeTooltipAttributeValue(current); |
|
|
|
if (tooltipAttributeValue !== null) { |
|
consultedNodes.add(current); |
|
return tooltipAttributeValue; |
|
} // TODO should this be reachable? |
|
|
|
|
|
consultedNodes.add(current); |
|
return ""; |
|
} |
|
|
|
return asFlatString(computeTextAlternative(root, { |
|
isEmbeddedInLabel: false, |
|
// by spec computeAccessibleDescription starts with the referenced elements as roots |
|
isReferenced: compute === "description", |
|
recursion: false |
|
})); |
|
} |
|
//# sourceMappingURL=accessible-name-and-description.mjs.map
|