role-helpers.js 8.7 KB

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