is-visible.js 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191
  1. import getRootNode from './get-root-node';
  2. import isOffscreen from './is-offscreen';
  3. import findUp from './find-up';
  4. import {
  5. getScroll,
  6. getNodeFromTree,
  7. querySelectorAll,
  8. escapeSelector
  9. } from '../../core/utils';
  10. const clipRegex = /rect\s*\(([0-9]+)px,?\s*([0-9]+)px,?\s*([0-9]+)px,?\s*([0-9]+)px\s*\)/;
  11. const clipPathRegex = /(\w+)\((\d+)/;
  12. /**
  13. * Determines if an element is hidden with a clip or clip-path technique
  14. * @method isClipped
  15. * @memberof axe.commons.dom
  16. * @private
  17. * @param {CSSStyleDeclaration} style Computed style
  18. * @return {Boolean}
  19. */
  20. function isClipped(style) {
  21. const matchesClip = style.getPropertyValue('clip').match(clipRegex);
  22. const matchesClipPath = style
  23. .getPropertyValue('clip-path')
  24. .match(clipPathRegex);
  25. if (matchesClip && matchesClip.length === 5) {
  26. return (
  27. matchesClip[3] - matchesClip[1] <= 0 &&
  28. matchesClip[2] - matchesClip[4] <= 0
  29. );
  30. }
  31. if (matchesClipPath) {
  32. const type = matchesClipPath[1];
  33. const value = parseInt(matchesClipPath[2], 10);
  34. switch (type) {
  35. case 'inset':
  36. return value >= 50;
  37. case 'circle':
  38. return value === 0;
  39. default:
  40. }
  41. }
  42. return false;
  43. }
  44. /**
  45. * Check `AREA` element is visible
  46. * - validate if it is a child of `map`
  47. * - ensure `map` is referred by `img` using the `usemap` attribute
  48. * @param {Element} areaEl `AREA` element
  49. * @retruns {Boolean}
  50. */
  51. function isAreaVisible(el, screenReader, recursed) {
  52. /**
  53. * Note:
  54. * - Verified that `map` element cannot refer to `area` elements across different document trees
  55. * - Verified that `map` element does not get affected by altering `display` property
  56. */
  57. const mapEl = findUp(el, 'map');
  58. if (!mapEl) {
  59. return false;
  60. }
  61. const mapElName = mapEl.getAttribute('name');
  62. if (!mapElName) {
  63. return false;
  64. }
  65. /**
  66. * `map` element has to be in light DOM
  67. */
  68. const mapElRootNode = getRootNode(el);
  69. if (!mapElRootNode || mapElRootNode.nodeType !== 9) {
  70. return false;
  71. }
  72. const refs = querySelectorAll(
  73. // TODO: es-module-_tree
  74. axe._tree,
  75. `img[usemap="#${escapeSelector(mapElName)}"]`
  76. );
  77. if (!refs || !refs.length) {
  78. return false;
  79. }
  80. return refs.some(({ actualNode }) =>
  81. isVisible(actualNode, screenReader, recursed)
  82. );
  83. }
  84. /**
  85. * Determine whether an element is visible
  86. * @method isVisible
  87. * @memberof axe.commons.dom
  88. * @instance
  89. * @param {HTMLElement} el The HTMLElement
  90. * @param {Boolean} screenReader When provided, will evaluate visibility from the perspective of a screen reader
  91. * @param {Boolean} recursed
  92. * @return {Boolean} The element's visibilty status
  93. */
  94. function isVisible(el, screenReader, recursed) {
  95. if (!el) {
  96. throw new TypeError(
  97. 'Cannot determine if element is visible for non-DOM nodes'
  98. );
  99. }
  100. const vNode = getNodeFromTree(el);
  101. const cacheName = '_isVisible' + (screenReader ? 'ScreenReader' : '');
  102. // 9 === Node.DOCUMENT
  103. if (el.nodeType === 9) {
  104. return true;
  105. }
  106. // 11 === Node.DOCUMENT_FRAGMENT_NODE
  107. if (el.nodeType === 11) {
  108. el = el.host; // grab the host Node
  109. }
  110. if (vNode && typeof vNode[cacheName] !== 'undefined') {
  111. return vNode[cacheName];
  112. }
  113. const style = window.getComputedStyle(el, null);
  114. if (style === null) {
  115. return false;
  116. }
  117. const nodeName = el.nodeName.toUpperCase();
  118. /**
  119. * check visibility of `AREA`
  120. * Note:
  121. * Firefox's user-agent always sets `AREA` element to `display:none`
  122. * hence excluding the edge case, for visibility computation
  123. */
  124. if (nodeName === 'AREA') {
  125. return isAreaVisible(el, screenReader, recursed);
  126. }
  127. // always hidden
  128. if (
  129. style.getPropertyValue('display') === 'none' ||
  130. ['STYLE', 'SCRIPT', 'NOSCRIPT', 'TEMPLATE'].includes(nodeName)
  131. ) {
  132. return false;
  133. }
  134. // hidden from screen readers
  135. if (screenReader && el.getAttribute('aria-hidden') === 'true') {
  136. return false;
  137. }
  138. // hidden from visual users
  139. if (
  140. !screenReader &&
  141. (isClipped(style) ||
  142. style.getPropertyValue('opacity') === '0' ||
  143. (getScroll(el) && parseInt(style.getPropertyValue('height')) === 0))
  144. ) {
  145. return false;
  146. }
  147. // visibility is only accurate on the first element and
  148. // position does not matter if it was already calculated
  149. if (
  150. !recursed &&
  151. (style.getPropertyValue('visibility') === 'hidden' ||
  152. (!screenReader && isOffscreen(el)))
  153. ) {
  154. return false;
  155. }
  156. const parent = el.assignedSlot ? el.assignedSlot : el.parentNode;
  157. let visible = false;
  158. if (parent) {
  159. visible = isVisible(parent, screenReader, true);
  160. }
  161. if (vNode) {
  162. vNode[cacheName] = visible;
  163. }
  164. return visible;
  165. }
  166. export default isVisible;