accessible-name-and-description.mjs 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594
  1. /**
  2. * implements https://w3c.github.io/accname/
  3. */
  4. import ArrayFrom from "./polyfills/array.from.mjs";
  5. import SetLike from "./polyfills/SetLike.mjs";
  6. import { hasAnyConcreteRoles, isElement, isHTMLTableCaptionElement, isHTMLInputElement, isHTMLSelectElement, isHTMLTextAreaElement, safeWindow, isHTMLFieldSetElement, isHTMLLegendElement, isHTMLOptGroupElement, isHTMLTableElement, isHTMLSlotElement, isSVGSVGElement, isSVGTitleElement, queryIdRefs, getLocalName } from "./util.mjs";
  7. /**
  8. * 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.
  9. */
  10. /**
  11. *
  12. * @param {string} string -
  13. * @returns {FlatString} -
  14. */
  15. function asFlatString(s) {
  16. return s.trim().replace(/\s\s+/g, " ");
  17. }
  18. /**
  19. *
  20. * @param node -
  21. * @param options - These are not optional to prevent accidentally calling it without options in `computeAccessibleName`
  22. * @returns {boolean} -
  23. */
  24. function isHidden(node, getComputedStyleImplementation) {
  25. if (!isElement(node)) {
  26. return false;
  27. }
  28. if (node.hasAttribute("hidden") || node.getAttribute("aria-hidden") === "true") {
  29. return true;
  30. }
  31. var style = getComputedStyleImplementation(node);
  32. return style.getPropertyValue("display") === "none" || style.getPropertyValue("visibility") === "hidden";
  33. }
  34. /**
  35. * @param {Node} node -
  36. * @returns {boolean} - As defined in step 2E of https://w3c.github.io/accname/#mapping_additional_nd_te
  37. */
  38. function isControl(node) {
  39. return hasAnyConcreteRoles(node, ["button", "combobox", "listbox", "textbox"]) || hasAbstractRole(node, "range");
  40. }
  41. function hasAbstractRole(node, role) {
  42. if (!isElement(node)) {
  43. return false;
  44. }
  45. switch (role) {
  46. case "range":
  47. return hasAnyConcreteRoles(node, ["meter", "progressbar", "scrollbar", "slider", "spinbutton"]);
  48. default:
  49. throw new TypeError("No knowledge about abstract role '".concat(role, "'. This is likely a bug :("));
  50. }
  51. }
  52. /**
  53. * element.querySelectorAll but also considers owned tree
  54. * @param element
  55. * @param selectors
  56. */
  57. function querySelectorAllSubtree(element, selectors) {
  58. var elements = ArrayFrom(element.querySelectorAll(selectors));
  59. queryIdRefs(element, "aria-owns").forEach(function (root) {
  60. // babel transpiles this assuming an iterator
  61. elements.push.apply(elements, ArrayFrom(root.querySelectorAll(selectors)));
  62. });
  63. return elements;
  64. }
  65. function querySelectedOptions(listbox) {
  66. if (isHTMLSelectElement(listbox)) {
  67. // IE11 polyfill
  68. return listbox.selectedOptions || querySelectorAllSubtree(listbox, "[selected]");
  69. }
  70. return querySelectorAllSubtree(listbox, '[aria-selected="true"]');
  71. }
  72. function isMarkedPresentational(node) {
  73. return hasAnyConcreteRoles(node, ["none", "presentation"]);
  74. }
  75. /**
  76. * Elements specifically listed in html-aam
  77. *
  78. * We don't need this for `label` or `legend` elements.
  79. * Their implicit roles already allow "naming from content".
  80. *
  81. * sources:
  82. *
  83. * - https://w3c.github.io/html-aam/#table-element
  84. */
  85. function isNativeHostLanguageTextAlternativeElement(node) {
  86. return isHTMLTableCaptionElement(node);
  87. }
  88. /**
  89. * https://w3c.github.io/aria/#namefromcontent
  90. */
  91. function allowsNameFromContent(node) {
  92. return hasAnyConcreteRoles(node, ["button", "cell", "checkbox", "columnheader", "gridcell", "heading", "label", "legend", "link", "menuitem", "menuitemcheckbox", "menuitemradio", "option", "radio", "row", "rowheader", "switch", "tab", "tooltip", "treeitem"]);
  93. }
  94. /**
  95. * TODO https://github.com/eps1lon/dom-accessibility-api/issues/100
  96. */
  97. function isDescendantOfNativeHostLanguageTextAlternativeElement( // eslint-disable-next-line @typescript-eslint/no-unused-vars -- not implemented yet
  98. node) {
  99. return false;
  100. }
  101. /**
  102. * TODO https://github.com/eps1lon/dom-accessibility-api/issues/101
  103. */
  104. // eslint-disable-next-line @typescript-eslint/no-unused-vars -- not implemented yet
  105. function computeTooltipAttributeValue(node) {
  106. return null;
  107. }
  108. function getValueOfTextbox(element) {
  109. if (isHTMLInputElement(element) || isHTMLTextAreaElement(element)) {
  110. return element.value;
  111. } // https://github.com/eps1lon/dom-accessibility-api/issues/4
  112. return element.textContent || "";
  113. }
  114. function getTextualContent(declaration) {
  115. var content = declaration.getPropertyValue("content");
  116. if (/^["'].*["']$/.test(content)) {
  117. return content.slice(1, -1);
  118. }
  119. return "";
  120. }
  121. /**
  122. * https://html.spec.whatwg.org/multipage/forms.html#category-label
  123. * TODO: form-associated custom elements
  124. * @param element
  125. */
  126. function isLabelableElement(element) {
  127. var localName = getLocalName(element);
  128. return localName === "button" || localName === "input" && element.getAttribute("type") !== "hidden" || localName === "meter" || localName === "output" || localName === "progress" || localName === "select" || localName === "textarea";
  129. }
  130. /**
  131. * > [...], then the first such descendant in tree order is the label element's labeled control.
  132. * -- https://html.spec.whatwg.org/multipage/forms.html#labeled-control
  133. * @param element
  134. */
  135. function findLabelableElement(element) {
  136. if (isLabelableElement(element)) {
  137. return element;
  138. }
  139. var labelableElement = null;
  140. element.childNodes.forEach(function (childNode) {
  141. if (labelableElement === null && isElement(childNode)) {
  142. var descendantLabelableElement = findLabelableElement(childNode);
  143. if (descendantLabelableElement !== null) {
  144. labelableElement = descendantLabelableElement;
  145. }
  146. }
  147. });
  148. return labelableElement;
  149. }
  150. /**
  151. * Polyfill of HTMLLabelElement.control
  152. * https://html.spec.whatwg.org/multipage/forms.html#labeled-control
  153. * @param label
  154. */
  155. function getControlOfLabel(label) {
  156. if (label.control !== undefined) {
  157. return label.control;
  158. }
  159. var htmlFor = label.getAttribute("for");
  160. if (htmlFor !== null) {
  161. return label.ownerDocument.getElementById(htmlFor);
  162. }
  163. return findLabelableElement(label);
  164. }
  165. /**
  166. * Polyfill of HTMLInputElement.labels
  167. * https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/labels
  168. * @param element
  169. */
  170. function getLabels(element) {
  171. var labelsProperty = element.labels;
  172. if (labelsProperty === null) {
  173. return labelsProperty;
  174. }
  175. if (labelsProperty !== undefined) {
  176. return ArrayFrom(labelsProperty);
  177. } // polyfill
  178. if (!isLabelableElement(element)) {
  179. return null;
  180. }
  181. var document = element.ownerDocument;
  182. return ArrayFrom(document.querySelectorAll("label")).filter(function (label) {
  183. return getControlOfLabel(label) === element;
  184. });
  185. }
  186. /**
  187. * Gets the contents of a slot used for computing the accname
  188. * @param slot
  189. */
  190. function getSlotContents(slot) {
  191. // Computing the accessible name for elements containing slots is not
  192. // currently defined in the spec. This implementation reflects the
  193. // behavior of NVDA 2020.2/Firefox 81 and iOS VoiceOver/Safari 13.6.
  194. var assignedNodes = slot.assignedNodes();
  195. if (assignedNodes.length === 0) {
  196. // if no nodes are assigned to the slot, it displays the default content
  197. return ArrayFrom(slot.childNodes);
  198. }
  199. return assignedNodes;
  200. }
  201. /**
  202. * implements https://w3c.github.io/accname/#mapping_additional_nd_te
  203. * @param root
  204. * @param options
  205. * @returns
  206. */
  207. export function computeTextAlternative(root) {
  208. var options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
  209. var consultedNodes = new SetLike();
  210. var window = safeWindow(root);
  211. var _options$compute = options.compute,
  212. compute = _options$compute === void 0 ? "name" : _options$compute,
  213. _options$computedStyl = options.computedStyleSupportsPseudoElements,
  214. computedStyleSupportsPseudoElements = _options$computedStyl === void 0 ? options.getComputedStyle !== undefined : _options$computedStyl,
  215. _options$getComputedS = options.getComputedStyle,
  216. getComputedStyle = _options$getComputedS === void 0 ? window.getComputedStyle.bind(window) : _options$getComputedS,
  217. _options$hidden = options.hidden,
  218. hidden = _options$hidden === void 0 ? false : _options$hidden; // 2F.i
  219. function computeMiscTextAlternative(node, context) {
  220. var accumulatedText = "";
  221. if (isElement(node) && computedStyleSupportsPseudoElements) {
  222. var pseudoBefore = getComputedStyle(node, "::before");
  223. var beforeContent = getTextualContent(pseudoBefore);
  224. accumulatedText = "".concat(beforeContent, " ").concat(accumulatedText);
  225. } // FIXME: Including aria-owns is not defined in the spec
  226. // But it is required in the web-platform-test
  227. var childNodes = isHTMLSlotElement(node) ? getSlotContents(node) : ArrayFrom(node.childNodes).concat(queryIdRefs(node, "aria-owns"));
  228. childNodes.forEach(function (child) {
  229. var result = computeTextAlternative(child, {
  230. isEmbeddedInLabel: context.isEmbeddedInLabel,
  231. isReferenced: false,
  232. recursion: true
  233. }); // TODO: Unclear why display affects delimiter
  234. // see https://github.com/w3c/accname/issues/3
  235. var display = isElement(child) ? getComputedStyle(child).getPropertyValue("display") : "inline";
  236. var separator = display !== "inline" ? " " : ""; // trailing separator for wpt tests
  237. accumulatedText += "".concat(separator).concat(result).concat(separator);
  238. });
  239. if (isElement(node) && computedStyleSupportsPseudoElements) {
  240. var pseudoAfter = getComputedStyle(node, "::after");
  241. var afterContent = getTextualContent(pseudoAfter);
  242. accumulatedText = "".concat(accumulatedText, " ").concat(afterContent);
  243. }
  244. return accumulatedText;
  245. }
  246. function computeElementTextAlternative(node) {
  247. if (!isElement(node)) {
  248. return null;
  249. }
  250. /**
  251. *
  252. * @param element
  253. * @param attributeName
  254. * @returns A string non-empty string or `null`
  255. */
  256. function useAttribute(element, attributeName) {
  257. var attribute = element.getAttributeNode(attributeName);
  258. if (attribute !== null && !consultedNodes.has(attribute) && attribute.value.trim() !== "") {
  259. consultedNodes.add(attribute);
  260. return attribute.value;
  261. }
  262. return null;
  263. } // https://w3c.github.io/html-aam/#fieldset-and-legend-elements
  264. if (isHTMLFieldSetElement(node)) {
  265. consultedNodes.add(node);
  266. var children = ArrayFrom(node.childNodes);
  267. for (var i = 0; i < children.length; i += 1) {
  268. var child = children[i];
  269. if (isHTMLLegendElement(child)) {
  270. return computeTextAlternative(child, {
  271. isEmbeddedInLabel: false,
  272. isReferenced: false,
  273. recursion: false
  274. });
  275. }
  276. }
  277. } else if (isHTMLTableElement(node)) {
  278. // https://w3c.github.io/html-aam/#table-element
  279. consultedNodes.add(node);
  280. var _children = ArrayFrom(node.childNodes);
  281. for (var _i = 0; _i < _children.length; _i += 1) {
  282. var _child = _children[_i];
  283. if (isHTMLTableCaptionElement(_child)) {
  284. return computeTextAlternative(_child, {
  285. isEmbeddedInLabel: false,
  286. isReferenced: false,
  287. recursion: false
  288. });
  289. }
  290. }
  291. } else if (isSVGSVGElement(node)) {
  292. // https://www.w3.org/TR/svg-aam-1.0/
  293. consultedNodes.add(node);
  294. var _children2 = ArrayFrom(node.childNodes);
  295. for (var _i2 = 0; _i2 < _children2.length; _i2 += 1) {
  296. var _child2 = _children2[_i2];
  297. if (isSVGTitleElement(_child2)) {
  298. return _child2.textContent;
  299. }
  300. }
  301. return null;
  302. } else if (getLocalName(node) === "img" || getLocalName(node) === "area") {
  303. // https://w3c.github.io/html-aam/#area-element
  304. // https://w3c.github.io/html-aam/#img-element
  305. var nameFromAlt = useAttribute(node, "alt");
  306. if (nameFromAlt !== null) {
  307. return nameFromAlt;
  308. }
  309. } else if (isHTMLOptGroupElement(node)) {
  310. var nameFromLabel = useAttribute(node, "label");
  311. if (nameFromLabel !== null) {
  312. return nameFromLabel;
  313. }
  314. }
  315. if (isHTMLInputElement(node) && (node.type === "button" || node.type === "submit" || node.type === "reset")) {
  316. // 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
  317. var nameFromValue = useAttribute(node, "value");
  318. if (nameFromValue !== null) {
  319. return nameFromValue;
  320. } // TODO: l10n
  321. if (node.type === "submit") {
  322. return "Submit";
  323. } // TODO: l10n
  324. if (node.type === "reset") {
  325. return "Reset";
  326. }
  327. }
  328. var labels = getLabels(node);
  329. if (labels !== null && labels.length !== 0) {
  330. consultedNodes.add(node);
  331. return ArrayFrom(labels).map(function (element) {
  332. return computeTextAlternative(element, {
  333. isEmbeddedInLabel: true,
  334. isReferenced: false,
  335. recursion: true
  336. });
  337. }).filter(function (label) {
  338. return label.length > 0;
  339. }).join(" ");
  340. } // https://w3c.github.io/html-aam/#input-type-image-accessible-name-computation
  341. // TODO: wpt test consider label elements but html-aam does not mention them
  342. // We follow existing implementations over spec
  343. if (isHTMLInputElement(node) && node.type === "image") {
  344. var _nameFromAlt = useAttribute(node, "alt");
  345. if (_nameFromAlt !== null) {
  346. return _nameFromAlt;
  347. }
  348. var nameFromTitle = useAttribute(node, "title");
  349. if (nameFromTitle !== null) {
  350. return nameFromTitle;
  351. } // TODO: l10n
  352. return "Submit Query";
  353. }
  354. return useAttribute(node, "title");
  355. }
  356. function computeTextAlternative(current, context) {
  357. if (consultedNodes.has(current)) {
  358. return "";
  359. } // 2A
  360. if (!hidden && isHidden(current, getComputedStyle) && !context.isReferenced) {
  361. consultedNodes.add(current);
  362. return "";
  363. } // 2B
  364. var labelElements = queryIdRefs(current, "aria-labelledby");
  365. if (compute === "name" && !context.isReferenced && labelElements.length > 0) {
  366. return labelElements.map(function (element) {
  367. return computeTextAlternative(element, {
  368. isEmbeddedInLabel: context.isEmbeddedInLabel,
  369. isReferenced: true,
  370. // thais isn't recursion as specified, otherwise we would skip
  371. // `aria-label` in
  372. // <input id="myself" aria-label="foo" aria-labelledby="myself"
  373. recursion: false
  374. });
  375. }).join(" ");
  376. } // 2C
  377. // Changed from the spec in anticipation of https://github.com/w3c/accname/issues/64
  378. // spec says we should only consider skipping if we have a non-empty label
  379. var skipToStep2E = context.recursion && isControl(current) && compute === "name";
  380. if (!skipToStep2E) {
  381. var ariaLabel = (isElement(current) && current.getAttribute("aria-label") || "").trim();
  382. if (ariaLabel !== "" && compute === "name") {
  383. consultedNodes.add(current);
  384. return ariaLabel;
  385. } // 2D
  386. if (!isMarkedPresentational(current)) {
  387. var elementTextAlternative = computeElementTextAlternative(current);
  388. if (elementTextAlternative !== null) {
  389. consultedNodes.add(current);
  390. return elementTextAlternative;
  391. }
  392. }
  393. } // special casing, cheating to make tests pass
  394. // https://github.com/w3c/accname/issues/67
  395. if (hasAnyConcreteRoles(current, ["menu"])) {
  396. consultedNodes.add(current);
  397. return "";
  398. } // 2E
  399. if (skipToStep2E || context.isEmbeddedInLabel || context.isReferenced) {
  400. if (hasAnyConcreteRoles(current, ["combobox", "listbox"])) {
  401. consultedNodes.add(current);
  402. var selectedOptions = querySelectedOptions(current);
  403. if (selectedOptions.length === 0) {
  404. // defined per test `name_heading_combobox`
  405. return isHTMLInputElement(current) ? current.value : "";
  406. }
  407. return ArrayFrom(selectedOptions).map(function (selectedOption) {
  408. return computeTextAlternative(selectedOption, {
  409. isEmbeddedInLabel: context.isEmbeddedInLabel,
  410. isReferenced: false,
  411. recursion: true
  412. });
  413. }).join(" ");
  414. }
  415. if (hasAbstractRole(current, "range")) {
  416. consultedNodes.add(current);
  417. if (current.hasAttribute("aria-valuetext")) {
  418. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- safe due to hasAttribute guard
  419. return current.getAttribute("aria-valuetext");
  420. }
  421. if (current.hasAttribute("aria-valuenow")) {
  422. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- safe due to hasAttribute guard
  423. return current.getAttribute("aria-valuenow");
  424. } // Otherwise, use the value as specified by a host language attribute.
  425. return current.getAttribute("value") || "";
  426. }
  427. if (hasAnyConcreteRoles(current, ["textbox"])) {
  428. consultedNodes.add(current);
  429. return getValueOfTextbox(current);
  430. }
  431. } // 2F: https://w3c.github.io/accname/#step2F
  432. if (allowsNameFromContent(current) || isElement(current) && context.isReferenced || isNativeHostLanguageTextAlternativeElement(current) || isDescendantOfNativeHostLanguageTextAlternativeElement(current)) {
  433. consultedNodes.add(current);
  434. return computeMiscTextAlternative(current, {
  435. isEmbeddedInLabel: context.isEmbeddedInLabel,
  436. isReferenced: false
  437. });
  438. }
  439. if (current.nodeType === current.TEXT_NODE) {
  440. consultedNodes.add(current);
  441. return current.textContent || "";
  442. }
  443. if (context.recursion) {
  444. consultedNodes.add(current);
  445. return computeMiscTextAlternative(current, {
  446. isEmbeddedInLabel: context.isEmbeddedInLabel,
  447. isReferenced: false
  448. });
  449. }
  450. var tooltipAttributeValue = computeTooltipAttributeValue(current);
  451. if (tooltipAttributeValue !== null) {
  452. consultedNodes.add(current);
  453. return tooltipAttributeValue;
  454. } // TODO should this be reachable?
  455. consultedNodes.add(current);
  456. return "";
  457. }
  458. return asFlatString(computeTextAlternative(root, {
  459. isEmbeddedInLabel: false,
  460. // by spec computeAccessibleDescription starts with the referenced elements as roots
  461. isReferenced: compute === "description",
  462. recursion: false
  463. }));
  464. }
  465. //# sourceMappingURL=accessible-name-and-description.mjs.map