implicit-html-roles.js 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186
  1. // Source: https://www.w3.org/TR/html-aam-1.0/#element-mapping-table
  2. // Source: https://www.w3.org/TR/html-aria/
  3. import getElementsByContentType from './get-elements-by-content-type';
  4. import getGlobalAriaAttrs from './get-global-aria-attrs';
  5. import arialabelledbyText from '../aria/arialabelledby-text';
  6. import arialabelText from '../aria/arialabel-text';
  7. import idrefs from '../dom/idrefs';
  8. import isColumnHeader from '../table/is-column-header';
  9. import isRowHeader from '../table/is-row-header';
  10. import sanitize from '../text/sanitize';
  11. import isFocusable from '../dom/is-focusable';
  12. import { closest } from '../../core/utils';
  13. import getExplicitRole from '../aria/get-explicit-role';
  14. const sectioningElementSelector =
  15. getElementsByContentType('sectioning')
  16. .map(nodeName => `${nodeName}:not([role])`)
  17. .join(', ') +
  18. ' , main:not([role]), [role=article], [role=complementary], [role=main], [role=navigation], [role=region]';
  19. // sectioning elements only have an accessible name if the
  20. // aria-label, aria-labelledby, or title attribute has valid
  21. // content.
  22. // can't go through the normal accessible name computation
  23. // as it leads into an infinite loop of asking for the role
  24. // of the element while the implicit role needs the name.
  25. // Source: https://www.w3.org/TR/html-aam-1.0/#section-and-grouping-element-accessible-name-computation
  26. //
  27. // form elements also follow this same pattern although not
  28. // specifically called out in the spec like section elements
  29. // (per Scott O'Hara)
  30. // Source: https://web-a11y.slack.com/archives/C042TSFGN/p1590607895241100?thread_ts=1590602189.217800&cid=C042TSFGN
  31. function hasAccessibleName(vNode) {
  32. // testing for when browsers give a <section> a region role:
  33. // chrome - always a region role
  34. // firefox - if non-empty aria-labelledby, aria-label, or title
  35. // safari - if non-empty aria-lablledby or aria-label
  36. //
  37. // we will go with safaris implantation as it is the least common
  38. // denominator
  39. const ariaLabelledby = sanitize(arialabelledbyText(vNode));
  40. const ariaLabel = sanitize(arialabelText(vNode));
  41. return !!(ariaLabelledby || ariaLabel);
  42. }
  43. const implicitHtmlRoles = {
  44. a: vNode => {
  45. return vNode.hasAttr('href') ? 'link' : null;
  46. },
  47. area: vNode => {
  48. return vNode.hasAttr('href') ? 'link' : null;
  49. },
  50. article: 'article',
  51. aside: 'complementary',
  52. body: 'document',
  53. button: 'button',
  54. datalist: 'listbox',
  55. dd: 'definition',
  56. dfn: 'term',
  57. details: 'group',
  58. dialog: 'dialog',
  59. dt: 'term',
  60. fieldset: 'group',
  61. figure: 'figure',
  62. footer: vNode => {
  63. const sectioningElement = closest(vNode, sectioningElementSelector);
  64. return !sectioningElement ? 'contentinfo' : null;
  65. },
  66. form: vNode => {
  67. return hasAccessibleName(vNode) ? 'form' : null;
  68. },
  69. h1: 'heading',
  70. h2: 'heading',
  71. h3: 'heading',
  72. h4: 'heading',
  73. h5: 'heading',
  74. h6: 'heading',
  75. header: vNode => {
  76. const sectioningElement = closest(vNode, sectioningElementSelector);
  77. return !sectioningElement ? 'banner' : null;
  78. },
  79. hr: 'separator',
  80. img: vNode => {
  81. // an images role is considered implicitly presentation if the
  82. // alt attribute is empty. But that shouldn't be the case if it
  83. // has global aria attributes or is focusable, so we need to
  84. // override the role back to `img`
  85. // e.g. <img alt="" aria-label="foo"></img>
  86. const emptyAlt = vNode.hasAttr('alt') && !vNode.attr('alt');
  87. const hasGlobalAria = getGlobalAriaAttrs().find(attr =>
  88. vNode.hasAttr(attr)
  89. );
  90. return emptyAlt && !hasGlobalAria && !isFocusable(vNode)
  91. ? 'presentation'
  92. : 'img';
  93. },
  94. input: vNode => {
  95. // Source: https://www.w3.org/TR/html52/sec-forms.html#suggestions-source-element
  96. let suggestionsSourceElement;
  97. if (vNode.hasAttr('list')) {
  98. const listElement = idrefs(vNode.actualNode, 'list').filter(
  99. node => !!node
  100. )[0];
  101. suggestionsSourceElement =
  102. listElement && listElement.nodeName.toLowerCase() === 'datalist';
  103. }
  104. switch (vNode.props.type) {
  105. case 'checkbox':
  106. return 'checkbox';
  107. case 'number':
  108. return 'spinbutton';
  109. case 'radio':
  110. return 'radio';
  111. case 'range':
  112. return 'slider';
  113. case 'search':
  114. return !suggestionsSourceElement ? 'searchbox' : 'combobox';
  115. case 'button':
  116. case 'image':
  117. case 'reset':
  118. case 'submit':
  119. return 'button';
  120. case 'text':
  121. case 'tel':
  122. case 'url':
  123. case 'email':
  124. case '':
  125. return !suggestionsSourceElement ? 'textbox' : 'combobox';
  126. default:
  127. return 'textbox';
  128. }
  129. },
  130. // Note: if an li (or some other elms) do not have a required
  131. // parent, Firefox ignores the implicit semantic role and treats
  132. // it as a generic text.
  133. li: 'listitem',
  134. main: 'main',
  135. math: 'math',
  136. menu: 'list',
  137. nav: 'navigation',
  138. ol: 'list',
  139. optgroup: 'group',
  140. option: 'option',
  141. output: 'status',
  142. progress: 'progressbar',
  143. section: vNode => {
  144. return hasAccessibleName(vNode) ? 'region' : null;
  145. },
  146. select: vNode => {
  147. return vNode.hasAttr('multiple') || parseInt(vNode.attr('size')) > 1
  148. ? 'listbox'
  149. : 'combobox';
  150. },
  151. summary: 'button',
  152. table: 'table',
  153. tbody: 'rowgroup',
  154. td: vNode => {
  155. const table = closest(vNode, 'table');
  156. const role = getExplicitRole(table);
  157. return ['grid', 'treegrid'].includes(role) ? 'gridcell' : 'cell';
  158. },
  159. textarea: 'textbox',
  160. tfoot: 'rowgroup',
  161. th: vNode => {
  162. if (isColumnHeader(vNode.actualNode)) {
  163. return 'columnheader';
  164. }
  165. if (isRowHeader(vNode.actualNode)) {
  166. return 'rowheader';
  167. }
  168. },
  169. thead: 'rowgroup',
  170. tr: 'row',
  171. ul: 'list'
  172. };
  173. export default implicitHtmlRoles;