/** * 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 //