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.

533 lines
19KB

  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(
  98. // eslint-disable-next-line @typescript-eslint/no-unused-vars -- not implemented yet
  99. node) {
  100. return false;
  101. }
  102. function getValueOfTextbox(element) {
  103. if (isHTMLInputElement(element) || isHTMLTextAreaElement(element)) {
  104. return element.value;
  105. }
  106. // https://github.com/eps1lon/dom-accessibility-api/issues/4
  107. return element.textContent || "";
  108. }
  109. function getTextualContent(declaration) {
  110. var content = declaration.getPropertyValue("content");
  111. if (/^["'].*["']$/.test(content)) {
  112. return content.slice(1, -1);
  113. }
  114. return "";
  115. }
  116. /**
  117. * https://html.spec.whatwg.org/multipage/forms.html#category-label
  118. * TODO: form-associated custom elements
  119. * @param element
  120. */
  121. function isLabelableElement(element) {
  122. var localName = getLocalName(element);
  123. return localName === "button" || localName === "input" && element.getAttribute("type") !== "hidden" || localName === "meter" || localName === "output" || localName === "progress" || localName === "select" || localName === "textarea";
  124. }
  125. /**
  126. * > [...], then the first such descendant in tree order is the label element's labeled control.
  127. * -- https://html.spec.whatwg.org/multipage/forms.html#labeled-control
  128. * @param element
  129. */
  130. function findLabelableElement(element) {
  131. if (isLabelableElement(element)) {
  132. return element;
  133. }
  134. var labelableElement = null;
  135. element.childNodes.forEach(function (childNode) {
  136. if (labelableElement === null && isElement(childNode)) {
  137. var descendantLabelableElement = findLabelableElement(childNode);
  138. if (descendantLabelableElement !== null) {
  139. labelableElement = descendantLabelableElement;
  140. }
  141. }
  142. });
  143. return labelableElement;
  144. }
  145. /**
  146. * Polyfill of HTMLLabelElement.control
  147. * https://html.spec.whatwg.org/multipage/forms.html#labeled-control
  148. * @param label
  149. */
  150. function getControlOfLabel(label) {
  151. if (label.control !== undefined) {
  152. return label.control;
  153. }
  154. var htmlFor = label.getAttribute("for");
  155. if (htmlFor !== null) {
  156. return label.ownerDocument.getElementById(htmlFor);
  157. }
  158. return findLabelableElement(label);
  159. }
  160. /**
  161. * Polyfill of HTMLInputElement.labels
  162. * https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/labels
  163. * @param element
  164. */
  165. function getLabels(element) {
  166. var labelsProperty = element.labels;
  167. if (labelsProperty === null) {
  168. return labelsProperty;
  169. }
  170. if (labelsProperty !== undefined) {
  171. return ArrayFrom(labelsProperty);
  172. }
  173. // polyfill
  174. if (!isLabelableElement(element)) {
  175. return null;
  176. }
  177. var document = element.ownerDocument;
  178. return ArrayFrom(document.querySelectorAll("label")).filter(function (label) {
  179. return getControlOfLabel(label) === element;
  180. });
  181. }
  182. /**
  183. * Gets the contents of a slot used for computing the accname
  184. * @param slot
  185. */
  186. function getSlotContents(slot) {
  187. // Computing the accessible name for elements containing slots is not
  188. // currently defined in the spec. This implementation reflects the
  189. // behavior of NVDA 2020.2/Firefox 81 and iOS VoiceOver/Safari 13.6.
  190. var assignedNodes = slot.assignedNodes();
  191. if (assignedNodes.length === 0) {
  192. // if no nodes are assigned to the slot, it displays the default content
  193. return ArrayFrom(slot.childNodes);
  194. }
  195. return assignedNodes;
  196. }
  197. /**
  198. * implements https://w3c.github.io/accname/#mapping_additional_nd_te
  199. * @param root
  200. * @param options
  201. * @returns
  202. */
  203. export function computeTextAlternative(root) {
  204. var options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
  205. var consultedNodes = new SetLike();
  206. var window = safeWindow(root);
  207. var _options$compute = options.compute,
  208. compute = _options$compute === void 0 ? "name" : _options$compute,
  209. _options$computedStyl = options.computedStyleSupportsPseudoElements,
  210. computedStyleSupportsPseudoElements = _options$computedStyl === void 0 ? options.getComputedStyle !== undefined : _options$computedStyl,
  211. _options$getComputedS = options.getComputedStyle,
  212. getComputedStyle = _options$getComputedS === void 0 ? window.getComputedStyle.bind(window) : _options$getComputedS,
  213. _options$hidden = options.hidden,
  214. hidden = _options$hidden === void 0 ? false : _options$hidden;
  215. // 2F.i
  216. function computeMiscTextAlternative(node, context) {
  217. var accumulatedText = "";
  218. if (isElement(node) && computedStyleSupportsPseudoElements) {
  219. var pseudoBefore = getComputedStyle(node, "::before");
  220. var beforeContent = getTextualContent(pseudoBefore);
  221. accumulatedText = "".concat(beforeContent, " ").concat(accumulatedText);
  222. }
  223. // FIXME: Including aria-owns is not defined in the spec
  224. // But it is required in the web-platform-test
  225. var childNodes = isHTMLSlotElement(node) ? getSlotContents(node) : ArrayFrom(node.childNodes).concat(queryIdRefs(node, "aria-owns"));
  226. childNodes.forEach(function (child) {
  227. var result = computeTextAlternative(child, {
  228. isEmbeddedInLabel: context.isEmbeddedInLabel,
  229. isReferenced: false,
  230. recursion: true
  231. });
  232. // TODO: Unclear why display affects delimiter
  233. // see https://github.com/w3c/accname/issues/3
  234. var display = isElement(child) ? getComputedStyle(child).getPropertyValue("display") : "inline";
  235. var separator = display !== "inline" ? " " : "";
  236. // 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.trim();
  245. }
  246. /**
  247. *
  248. * @param element
  249. * @param attributeName
  250. * @returns A string non-empty string or `null`
  251. */
  252. function useAttribute(element, attributeName) {
  253. var attribute = element.getAttributeNode(attributeName);
  254. if (attribute !== null && !consultedNodes.has(attribute) && attribute.value.trim() !== "") {
  255. consultedNodes.add(attribute);
  256. return attribute.value;
  257. }
  258. return null;
  259. }
  260. function computeTooltipAttributeValue(node) {
  261. if (!isElement(node)) {
  262. return null;
  263. }
  264. return useAttribute(node, "title");
  265. }
  266. function computeElementTextAlternative(node) {
  267. if (!isElement(node)) {
  268. return null;
  269. }
  270. // https://w3c.github.io/html-aam/#fieldset-and-legend-elements
  271. if (isHTMLFieldSetElement(node)) {
  272. consultedNodes.add(node);
  273. var children = ArrayFrom(node.childNodes);
  274. for (var i = 0; i < children.length; i += 1) {
  275. var child = children[i];
  276. if (isHTMLLegendElement(child)) {
  277. return computeTextAlternative(child, {
  278. isEmbeddedInLabel: false,
  279. isReferenced: false,
  280. recursion: false
  281. });
  282. }
  283. }
  284. } else if (isHTMLTableElement(node)) {
  285. // https://w3c.github.io/html-aam/#table-element
  286. consultedNodes.add(node);
  287. var _children = ArrayFrom(node.childNodes);
  288. for (var _i = 0; _i < _children.length; _i += 1) {
  289. var _child = _children[_i];
  290. if (isHTMLTableCaptionElement(_child)) {
  291. return computeTextAlternative(_child, {
  292. isEmbeddedInLabel: false,
  293. isReferenced: false,
  294. recursion: false
  295. });
  296. }
  297. }
  298. } else if (isSVGSVGElement(node)) {
  299. // https://www.w3.org/TR/svg-aam-1.0/
  300. consultedNodes.add(node);
  301. var _children2 = ArrayFrom(node.childNodes);
  302. for (var _i2 = 0; _i2 < _children2.length; _i2 += 1) {
  303. var _child2 = _children2[_i2];
  304. if (isSVGTitleElement(_child2)) {
  305. return _child2.textContent;
  306. }
  307. }
  308. return null;
  309. } else if (getLocalName(node) === "img" || getLocalName(node) === "area") {
  310. // https://w3c.github.io/html-aam/#area-element
  311. // https://w3c.github.io/html-aam/#img-element
  312. var nameFromAlt = useAttribute(node, "alt");
  313. if (nameFromAlt !== null) {
  314. return nameFromAlt;
  315. }
  316. } else if (isHTMLOptGroupElement(node)) {
  317. var nameFromLabel = useAttribute(node, "label");
  318. if (nameFromLabel !== null) {
  319. return nameFromLabel;
  320. }
  321. }
  322. if (isHTMLInputElement(node) && (node.type === "button" || node.type === "submit" || node.type === "reset")) {
  323. // 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
  324. var nameFromValue = useAttribute(node, "value");
  325. if (nameFromValue !== null) {
  326. return nameFromValue;
  327. }
  328. // TODO: l10n
  329. if (node.type === "submit") {
  330. return "Submit";
  331. }
  332. // TODO: l10n
  333. if (node.type === "reset") {
  334. return "Reset";
  335. }
  336. }
  337. var labels = getLabels(node);
  338. if (labels !== null && labels.length !== 0) {
  339. consultedNodes.add(node);
  340. return ArrayFrom(labels).map(function (element) {
  341. return computeTextAlternative(element, {
  342. isEmbeddedInLabel: true,
  343. isReferenced: false,
  344. recursion: true
  345. });
  346. }).filter(function (label) {
  347. return label.length > 0;
  348. }).join(" ");
  349. }
  350. // https://w3c.github.io/html-aam/#input-type-image-accessible-name-computation
  351. // TODO: wpt test consider label elements but html-aam does not mention them
  352. // We follow existing implementations over spec
  353. if (isHTMLInputElement(node) && node.type === "image") {
  354. var _nameFromAlt = useAttribute(node, "alt");
  355. if (_nameFromAlt !== null) {
  356. return _nameFromAlt;
  357. }
  358. var nameFromTitle = useAttribute(node, "title");
  359. if (nameFromTitle !== null) {
  360. return nameFromTitle;
  361. }
  362. // TODO: l10n
  363. return "Submit Query";
  364. }
  365. if (hasAnyConcreteRoles(node, ["button"])) {
  366. // https://www.w3.org/TR/html-aam-1.0/#button-element
  367. var nameFromSubTree = computeMiscTextAlternative(node, {
  368. isEmbeddedInLabel: false,
  369. isReferenced: false
  370. });
  371. if (nameFromSubTree !== "") {
  372. return nameFromSubTree;
  373. }
  374. }
  375. return null;
  376. }
  377. function computeTextAlternative(current, context) {
  378. if (consultedNodes.has(current)) {
  379. return "";
  380. }
  381. // 2A
  382. if (!hidden && isHidden(current, getComputedStyle) && !context.isReferenced) {
  383. consultedNodes.add(current);
  384. return "";
  385. }
  386. // 2B
  387. var labelAttributeNode = isElement(current) ? current.getAttributeNode("aria-labelledby") : null;
  388. // TODO: Do we generally need to block query IdRefs of attributes we have already consulted?
  389. var labelElements = labelAttributeNode !== null && !consultedNodes.has(labelAttributeNode) ? queryIdRefs(current, "aria-labelledby") : [];
  390. if (compute === "name" && !context.isReferenced && labelElements.length > 0) {
  391. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- Can't be null here otherwise labelElements would be empty
  392. consultedNodes.add(labelAttributeNode);
  393. return labelElements.map(function (element) {
  394. // TODO: Chrome will consider repeated values i.e. use a node multiple times while we'll bail out in computeTextAlternative.
  395. return computeTextAlternative(element, {
  396. isEmbeddedInLabel: context.isEmbeddedInLabel,
  397. isReferenced: true,
  398. // this isn't recursion as specified, otherwise we would skip
  399. // `aria-label` in
  400. // <input id="myself" aria-label="foo" aria-labelledby="myself"
  401. recursion: false
  402. });
  403. }).join(" ");
  404. }
  405. // 2C
  406. // Changed from the spec in anticipation of https://github.com/w3c/accname/issues/64
  407. // spec says we should only consider skipping if we have a non-empty label
  408. var skipToStep2E = context.recursion && isControl(current) && compute === "name";
  409. if (!skipToStep2E) {
  410. var ariaLabel = (isElement(current) && current.getAttribute("aria-label") || "").trim();
  411. if (ariaLabel !== "" && compute === "name") {
  412. consultedNodes.add(current);
  413. return ariaLabel;
  414. }
  415. // 2D
  416. if (!isMarkedPresentational(current)) {
  417. var elementTextAlternative = computeElementTextAlternative(current);
  418. if (elementTextAlternative !== null) {
  419. consultedNodes.add(current);
  420. return elementTextAlternative;
  421. }
  422. }
  423. }
  424. // special casing, cheating to make tests pass
  425. // https://github.com/w3c/accname/issues/67
  426. if (hasAnyConcreteRoles(current, ["menu"])) {
  427. consultedNodes.add(current);
  428. return "";
  429. }
  430. // 2E
  431. if (skipToStep2E || context.isEmbeddedInLabel || context.isReferenced) {
  432. if (hasAnyConcreteRoles(current, ["combobox", "listbox"])) {
  433. consultedNodes.add(current);
  434. var selectedOptions = querySelectedOptions(current);
  435. if (selectedOptions.length === 0) {
  436. // defined per test `name_heading_combobox`
  437. return isHTMLInputElement(current) ? current.value : "";
  438. }
  439. return ArrayFrom(selectedOptions).map(function (selectedOption) {
  440. return computeTextAlternative(selectedOption, {
  441. isEmbeddedInLabel: context.isEmbeddedInLabel,
  442. isReferenced: false,
  443. recursion: true
  444. });
  445. }).join(" ");
  446. }
  447. if (hasAbstractRole(current, "range")) {
  448. consultedNodes.add(current);
  449. if (current.hasAttribute("aria-valuetext")) {
  450. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- safe due to hasAttribute guard
  451. return current.getAttribute("aria-valuetext");
  452. }
  453. if (current.hasAttribute("aria-valuenow")) {
  454. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- safe due to hasAttribute guard
  455. return current.getAttribute("aria-valuenow");
  456. }
  457. // Otherwise, use the value as specified by a host language attribute.
  458. return current.getAttribute("value") || "";
  459. }
  460. if (hasAnyConcreteRoles(current, ["textbox"])) {
  461. consultedNodes.add(current);
  462. return getValueOfTextbox(current);
  463. }
  464. }
  465. // 2F: https://w3c.github.io/accname/#step2F
  466. if (allowsNameFromContent(current) || isElement(current) && context.isReferenced || isNativeHostLanguageTextAlternativeElement(current) || isDescendantOfNativeHostLanguageTextAlternativeElement(current)) {
  467. var accumulatedText2F = computeMiscTextAlternative(current, {
  468. isEmbeddedInLabel: context.isEmbeddedInLabel,
  469. isReferenced: false
  470. });
  471. if (accumulatedText2F !== "") {
  472. consultedNodes.add(current);
  473. return accumulatedText2F;
  474. }
  475. }
  476. if (current.nodeType === current.TEXT_NODE) {
  477. consultedNodes.add(current);
  478. return current.textContent || "";
  479. }
  480. if (context.recursion) {
  481. consultedNodes.add(current);
  482. return computeMiscTextAlternative(current, {
  483. isEmbeddedInLabel: context.isEmbeddedInLabel,
  484. isReferenced: false
  485. });
  486. }
  487. var tooltipAttributeValue = computeTooltipAttributeValue(current);
  488. if (tooltipAttributeValue !== null) {
  489. consultedNodes.add(current);
  490. return tooltipAttributeValue;
  491. }
  492. // TODO should this be reachable?
  493. consultedNodes.add(current);
  494. return "";
  495. }
  496. return asFlatString(computeTextAlternative(root, {
  497. isEmbeddedInLabel: false,
  498. // by spec computeAccessibleDescription starts with the referenced elements as roots
  499. isReferenced: compute === "description",
  500. recursion: false
  501. }));
  502. }
  503. //# sourceMappingURL=accessible-name-and-description.mjs.map