role-helpers.js 11 KB

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