role-helpers.js 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303
  1. "use strict";
  2. Object.defineProperty(exports, "__esModule", {
  3. value: true
  4. });
  5. exports.computeAriaChecked = computeAriaChecked;
  6. exports.computeAriaCurrent = computeAriaCurrent;
  7. exports.computeAriaExpanded = computeAriaExpanded;
  8. exports.computeAriaPressed = computeAriaPressed;
  9. exports.computeAriaSelected = computeAriaSelected;
  10. exports.computeHeadingLevel = computeHeadingLevel;
  11. exports.getImplicitAriaRoles = getImplicitAriaRoles;
  12. exports.getRoles = getRoles;
  13. exports.isInaccessible = isInaccessible;
  14. exports.isSubtreeInaccessible = isSubtreeInaccessible;
  15. exports.logRoles = void 0;
  16. exports.prettyRoles = prettyRoles;
  17. var _ariaQuery = require("aria-query");
  18. var _domAccessibilityApi = require("dom-accessibility-api");
  19. var _prettyDom = require("./pretty-dom");
  20. var _config = require("./config");
  21. const elementRoleList = buildElementRoleList(_ariaQuery.elementRoles);
  22. /**
  23. * @param {Element} element -
  24. * @returns {boolean} - `true` if `element` and its subtree are inaccessible
  25. */
  26. function isSubtreeInaccessible(element) {
  27. if (element.hidden === true) {
  28. return true;
  29. }
  30. if (element.getAttribute('aria-hidden') === 'true') {
  31. return true;
  32. }
  33. const window = element.ownerDocument.defaultView;
  34. if (window.getComputedStyle(element).display === 'none') {
  35. return true;
  36. }
  37. return false;
  38. }
  39. /**
  40. * Partial implementation https://www.w3.org/TR/wai-aria-1.2/#tree_exclusion
  41. * which should only be used for elements with a non-presentational role i.e.
  42. * `role="none"` and `role="presentation"` will not be excluded.
  43. *
  44. * Implements aria-hidden semantics (i.e. parent overrides child)
  45. * Ignores "Child Presentational: True" characteristics
  46. *
  47. * @param {Element} element -
  48. * @param {object} [options] -
  49. * @param {function (element: Element): boolean} options.isSubtreeInaccessible -
  50. * can be used to return cached results from previous isSubtreeInaccessible calls
  51. * @returns {boolean} true if excluded, otherwise false
  52. */
  53. function isInaccessible(element, options = {}) {
  54. const {
  55. isSubtreeInaccessible: isSubtreeInaccessibleImpl = isSubtreeInaccessible
  56. } = options;
  57. const window = element.ownerDocument.defaultView;
  58. // since visibility is inherited we can exit early
  59. if (window.getComputedStyle(element).visibility === 'hidden') {
  60. return true;
  61. }
  62. let currentElement = element;
  63. while (currentElement) {
  64. if (isSubtreeInaccessibleImpl(currentElement)) {
  65. return true;
  66. }
  67. currentElement = currentElement.parentElement;
  68. }
  69. return false;
  70. }
  71. function getImplicitAriaRoles(currentNode) {
  72. // eslint bug here:
  73. // eslint-disable-next-line no-unused-vars
  74. for (const {
  75. match,
  76. roles
  77. } of elementRoleList) {
  78. if (match(currentNode)) {
  79. return [...roles];
  80. }
  81. }
  82. return [];
  83. }
  84. function buildElementRoleList(elementRolesMap) {
  85. function makeElementSelector({
  86. name,
  87. attributes
  88. }) {
  89. return `${name}${attributes.map(({
  90. name: attributeName,
  91. value,
  92. constraints = []
  93. }) => {
  94. const shouldNotExist = constraints.indexOf('undefined') !== -1;
  95. if (shouldNotExist) {
  96. return `:not([${attributeName}])`;
  97. } else if (value) {
  98. return `[${attributeName}="${value}"]`;
  99. } else {
  100. return `[${attributeName}]`;
  101. }
  102. }).join('')}`;
  103. }
  104. function getSelectorSpecificity({
  105. attributes = []
  106. }) {
  107. return attributes.length;
  108. }
  109. function bySelectorSpecificity({
  110. specificity: leftSpecificity
  111. }, {
  112. specificity: rightSpecificity
  113. }) {
  114. return rightSpecificity - leftSpecificity;
  115. }
  116. function match(element) {
  117. let {
  118. attributes = []
  119. } = element;
  120. // https://github.com/testing-library/dom-testing-library/issues/814
  121. const typeTextIndex = attributes.findIndex(attribute => attribute.value && attribute.name === 'type' && attribute.value === 'text');
  122. if (typeTextIndex >= 0) {
  123. // not using splice to not mutate the attributes array
  124. attributes = [...attributes.slice(0, typeTextIndex), ...attributes.slice(typeTextIndex + 1)];
  125. }
  126. const selector = makeElementSelector({
  127. ...element,
  128. attributes
  129. });
  130. return node => {
  131. if (typeTextIndex >= 0 && node.type !== 'text') {
  132. return false;
  133. }
  134. return node.matches(selector);
  135. };
  136. }
  137. let result = [];
  138. // eslint bug here:
  139. // eslint-disable-next-line no-unused-vars
  140. for (const [element, roles] of elementRolesMap.entries()) {
  141. result = [...result, {
  142. match: match(element),
  143. roles: Array.from(roles),
  144. specificity: getSelectorSpecificity(element)
  145. }];
  146. }
  147. return result.sort(bySelectorSpecificity);
  148. }
  149. function getRoles(container, {
  150. hidden = false
  151. } = {}) {
  152. function flattenDOM(node) {
  153. return [node, ...Array.from(node.children).reduce((acc, child) => [...acc, ...flattenDOM(child)], [])];
  154. }
  155. return flattenDOM(container).filter(element => {
  156. return hidden === false ? isInaccessible(element) === false : true;
  157. }).reduce((acc, node) => {
  158. let roles = [];
  159. // TODO: This violates html-aria which does not allow any role on every element
  160. if (node.hasAttribute('role')) {
  161. roles = node.getAttribute('role').split(' ').slice(0, 1);
  162. } else {
  163. roles = getImplicitAriaRoles(node);
  164. }
  165. return roles.reduce((rolesAcc, role) => Array.isArray(rolesAcc[role]) ? {
  166. ...rolesAcc,
  167. [role]: [...rolesAcc[role], node]
  168. } : {
  169. ...rolesAcc,
  170. [role]: [node]
  171. }, acc);
  172. }, {});
  173. }
  174. function prettyRoles(dom, {
  175. hidden,
  176. includeDescription
  177. }) {
  178. const roles = getRoles(dom, {
  179. hidden
  180. });
  181. // We prefer to skip generic role, we don't recommend it
  182. return Object.entries(roles).filter(([role]) => role !== 'generic').map(([role, elements]) => {
  183. const delimiterBar = '-'.repeat(50);
  184. const elementsString = elements.map(el => {
  185. const nameString = `Name "${(0, _domAccessibilityApi.computeAccessibleName)(el, {
  186. computedStyleSupportsPseudoElements: (0, _config.getConfig)().computedStyleSupportsPseudoElements
  187. })}":\n`;
  188. const domString = (0, _prettyDom.prettyDOM)(el.cloneNode(false));
  189. if (includeDescription) {
  190. const descriptionString = `Description "${(0, _domAccessibilityApi.computeAccessibleDescription)(el, {
  191. computedStyleSupportsPseudoElements: (0, _config.getConfig)().computedStyleSupportsPseudoElements
  192. })}":\n`;
  193. return `${nameString}${descriptionString}${domString}`;
  194. }
  195. return `${nameString}${domString}`;
  196. }).join('\n\n');
  197. return `${role}:\n\n${elementsString}\n\n${delimiterBar}`;
  198. }).join('\n');
  199. }
  200. const logRoles = (dom, {
  201. hidden = false
  202. } = {}) => console.log(prettyRoles(dom, {
  203. hidden
  204. }));
  205. /**
  206. * @param {Element} element -
  207. * @returns {boolean | undefined} - false/true if (not)selected, undefined if not selectable
  208. */
  209. exports.logRoles = logRoles;
  210. function computeAriaSelected(element) {
  211. // implicit value from html-aam mappings: https://www.w3.org/TR/html-aam-1.0/#html-attribute-state-and-property-mappings
  212. // https://www.w3.org/TR/html-aam-1.0/#details-id-97
  213. if (element.tagName === 'OPTION') {
  214. return element.selected;
  215. }
  216. // explicit value
  217. return checkBooleanAttribute(element, 'aria-selected');
  218. }
  219. /**
  220. * @param {Element} element -
  221. * @returns {boolean | undefined} - false/true if (not)checked, undefined if not checked-able
  222. */
  223. function computeAriaChecked(element) {
  224. // implicit value from html-aam mappings: https://www.w3.org/TR/html-aam-1.0/#html-attribute-state-and-property-mappings
  225. // https://www.w3.org/TR/html-aam-1.0/#details-id-56
  226. // https://www.w3.org/TR/html-aam-1.0/#details-id-67
  227. if ('indeterminate' in element && element.indeterminate) {
  228. return undefined;
  229. }
  230. if ('checked' in element) {
  231. return element.checked;
  232. }
  233. // explicit value
  234. return checkBooleanAttribute(element, 'aria-checked');
  235. }
  236. /**
  237. * @param {Element} element -
  238. * @returns {boolean | undefined} - false/true if (not)pressed, undefined if not press-able
  239. */
  240. function computeAriaPressed(element) {
  241. // https://www.w3.org/TR/wai-aria-1.1/#aria-pressed
  242. return checkBooleanAttribute(element, 'aria-pressed');
  243. }
  244. /**
  245. * @param {Element} element -
  246. * @returns {boolean | string | null} -
  247. */
  248. function computeAriaCurrent(element) {
  249. var _ref, _checkBooleanAttribut;
  250. // https://www.w3.org/TR/wai-aria-1.1/#aria-current
  251. return (_ref = (_checkBooleanAttribut = checkBooleanAttribute(element, 'aria-current')) != null ? _checkBooleanAttribut : element.getAttribute('aria-current')) != null ? _ref : false;
  252. }
  253. /**
  254. * @param {Element} element -
  255. * @returns {boolean | undefined} - false/true if (not)expanded, undefined if not expand-able
  256. */
  257. function computeAriaExpanded(element) {
  258. // https://www.w3.org/TR/wai-aria-1.1/#aria-expanded
  259. return checkBooleanAttribute(element, 'aria-expanded');
  260. }
  261. function checkBooleanAttribute(element, attribute) {
  262. const attributeValue = element.getAttribute(attribute);
  263. if (attributeValue === 'true') {
  264. return true;
  265. }
  266. if (attributeValue === 'false') {
  267. return false;
  268. }
  269. return undefined;
  270. }
  271. /**
  272. * @param {Element} element -
  273. * @returns {number | undefined} - number if implicit heading or aria-level present, otherwise undefined
  274. */
  275. function computeHeadingLevel(element) {
  276. // https://w3c.github.io/html-aam/#el-h1-h6
  277. // https://w3c.github.io/html-aam/#el-h1-h6
  278. const implicitHeadingLevels = {
  279. H1: 1,
  280. H2: 2,
  281. H3: 3,
  282. H4: 4,
  283. H5: 5,
  284. H6: 6
  285. };
  286. // explicit aria-level value
  287. // https://www.w3.org/TR/wai-aria-1.2/#aria-level
  288. const ariaLevelAttribute = element.getAttribute('aria-level') && Number(element.getAttribute('aria-level'));
  289. return ariaLevelAttribute || implicitHeadingLevels[element.tagName];
  290. }