crAccessibility.js 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237
  1. "use strict";
  2. Object.defineProperty(exports, "__esModule", {
  3. value: true
  4. });
  5. exports.getAccessibilityTree = getAccessibilityTree;
  6. /**
  7. * Copyright 2018 Google Inc. All rights reserved.
  8. * Modifications copyright (c) Microsoft Corporation.
  9. *
  10. * Licensed under the Apache License, Version 2.0 (the 'License');
  11. * you may not use this file except in compliance with the License.
  12. * You may obtain a copy of the License at
  13. *
  14. * http://www.apache.org/licenses/LICENSE-2.0
  15. *
  16. * Unless required by applicable law or agreed to in writing, software
  17. * distributed under the License is distributed on an 'AS IS' BASIS,
  18. * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  19. * See the License for the specific language governing permissions and
  20. * limitations under the License.
  21. */
  22. async function getAccessibilityTree(client, needle) {
  23. const {
  24. nodes
  25. } = await client.send('Accessibility.getFullAXTree');
  26. const tree = CRAXNode.createTree(client, nodes);
  27. return {
  28. tree,
  29. needle: needle ? await tree._findElement(needle) : null
  30. };
  31. }
  32. class CRAXNode {
  33. constructor(client, payload) {
  34. this._payload = void 0;
  35. this._children = [];
  36. this._richlyEditable = false;
  37. this._editable = false;
  38. this._focusable = false;
  39. this._expanded = false;
  40. this._hidden = false;
  41. this._name = void 0;
  42. this._role = void 0;
  43. this._cachedHasFocusableChild = void 0;
  44. this._client = void 0;
  45. this._client = client;
  46. this._payload = payload;
  47. this._name = this._payload.name ? this._payload.name.value : '';
  48. this._role = this._payload.role ? this._payload.role.value : 'Unknown';
  49. for (const property of this._payload.properties || []) {
  50. if (property.name === 'editable') {
  51. this._richlyEditable = property.value.value === 'richtext';
  52. this._editable = true;
  53. }
  54. if (property.name === 'focusable') this._focusable = property.value.value;
  55. if (property.name === 'expanded') this._expanded = property.value.value;
  56. if (property.name === 'hidden') this._hidden = property.value.value;
  57. }
  58. }
  59. _isPlainTextField() {
  60. if (this._richlyEditable) return false;
  61. if (this._editable) return true;
  62. return this._role === 'textbox' || this._role === 'ComboBox' || this._role === 'searchbox';
  63. }
  64. _isTextOnlyObject() {
  65. const role = this._role;
  66. return role === 'LineBreak' || role === 'text' || role === 'InlineTextBox' || role === 'StaticText';
  67. }
  68. _hasFocusableChild() {
  69. if (this._cachedHasFocusableChild === undefined) {
  70. this._cachedHasFocusableChild = false;
  71. for (const child of this._children) {
  72. if (child._focusable || child._hasFocusableChild()) {
  73. this._cachedHasFocusableChild = true;
  74. break;
  75. }
  76. }
  77. }
  78. return this._cachedHasFocusableChild;
  79. }
  80. children() {
  81. return this._children;
  82. }
  83. async _findElement(element) {
  84. const objectId = element._objectId;
  85. const {
  86. node: {
  87. backendNodeId
  88. }
  89. } = await this._client.send('DOM.describeNode', {
  90. objectId
  91. });
  92. const needle = this.find(node => node._payload.backendDOMNodeId === backendNodeId);
  93. return needle || null;
  94. }
  95. find(predicate) {
  96. if (predicate(this)) return this;
  97. for (const child of this._children) {
  98. const result = child.find(predicate);
  99. if (result) return result;
  100. }
  101. return null;
  102. }
  103. isLeafNode() {
  104. if (!this._children.length) return true;
  105. // These types of objects may have children that we use as internal
  106. // implementation details, but we want to expose them as leaves to platform
  107. // accessibility APIs because screen readers might be confused if they find
  108. // any children.
  109. if (this._isPlainTextField() || this._isTextOnlyObject()) return true;
  110. // Roles whose children are only presentational according to the ARIA and
  111. // HTML5 Specs should be hidden from screen readers.
  112. // (Note that whilst ARIA buttons can have only presentational children, HTML5
  113. // buttons are allowed to have content.)
  114. switch (this._role) {
  115. case 'doc-cover':
  116. case 'graphics-symbol':
  117. case 'img':
  118. case 'Meter':
  119. case 'scrollbar':
  120. case 'slider':
  121. case 'separator':
  122. case 'progressbar':
  123. return true;
  124. default:
  125. break;
  126. }
  127. // Here and below: Android heuristics
  128. if (this._hasFocusableChild()) return false;
  129. if (this._focusable && this._role !== 'WebArea' && this._role !== 'RootWebArea' && this._name) return true;
  130. if (this._role === 'heading' && this._name) return true;
  131. return false;
  132. }
  133. isControl() {
  134. switch (this._role) {
  135. case 'button':
  136. case 'checkbox':
  137. case 'ColorWell':
  138. case 'combobox':
  139. case 'DisclosureTriangle':
  140. case 'listbox':
  141. case 'menu':
  142. case 'menubar':
  143. case 'menuitem':
  144. case 'menuitemcheckbox':
  145. case 'menuitemradio':
  146. case 'radio':
  147. case 'scrollbar':
  148. case 'searchbox':
  149. case 'slider':
  150. case 'spinbutton':
  151. case 'switch':
  152. case 'tab':
  153. case 'textbox':
  154. case 'tree':
  155. return true;
  156. default:
  157. return false;
  158. }
  159. }
  160. isInteresting(insideControl) {
  161. const role = this._role;
  162. if (role === 'Ignored' || this._hidden) return false;
  163. if (this._focusable || this._richlyEditable) return true;
  164. // If it's not focusable but has a control role, then it's interesting.
  165. if (this.isControl()) return true;
  166. // A non focusable child of a control is not interesting
  167. if (insideControl) return false;
  168. return this.isLeafNode() && !!this._name;
  169. }
  170. normalizedRole() {
  171. switch (this._role) {
  172. case 'RootWebArea':
  173. return 'WebArea';
  174. case 'StaticText':
  175. return 'text';
  176. default:
  177. return this._role;
  178. }
  179. }
  180. serialize() {
  181. const properties = new Map();
  182. for (const property of this._payload.properties || []) properties.set(property.name.toLowerCase(), property.value.value);
  183. if (this._payload.description) properties.set('description', this._payload.description.value);
  184. const node = {
  185. role: this.normalizedRole(),
  186. name: this._payload.name ? this._payload.name.value || '' : ''
  187. };
  188. const userStringProperties = ['description', 'keyshortcuts', 'roledescription', 'valuetext'];
  189. for (const userStringProperty of userStringProperties) {
  190. if (!properties.has(userStringProperty)) continue;
  191. node[userStringProperty] = properties.get(userStringProperty);
  192. }
  193. const booleanProperties = ['disabled', 'expanded', 'focused', 'modal', 'multiline', 'multiselectable', 'readonly', 'required', 'selected'];
  194. for (const booleanProperty of booleanProperties) {
  195. // WebArea's treat focus differently than other nodes. They report whether their frame has focus,
  196. // not whether focus is specifically on the root node.
  197. if (booleanProperty === 'focused' && (this._role === 'WebArea' || this._role === 'RootWebArea')) continue;
  198. const value = properties.get(booleanProperty);
  199. if (!value) continue;
  200. node[booleanProperty] = value;
  201. }
  202. const numericalProperties = ['level', 'valuemax', 'valuemin'];
  203. for (const numericalProperty of numericalProperties) {
  204. if (!properties.has(numericalProperty)) continue;
  205. node[numericalProperty] = properties.get(numericalProperty);
  206. }
  207. const tokenProperties = ['autocomplete', 'haspopup', 'invalid', 'orientation'];
  208. for (const tokenProperty of tokenProperties) {
  209. const value = properties.get(tokenProperty);
  210. if (!value || value === 'false') continue;
  211. node[tokenProperty] = value;
  212. }
  213. const axNode = node;
  214. if (this._payload.value) {
  215. if (typeof this._payload.value.value === 'string') axNode.valueString = this._payload.value.value;
  216. if (typeof this._payload.value.value === 'number') axNode.valueNumber = this._payload.value.value;
  217. }
  218. if (properties.has('checked')) axNode.checked = properties.get('checked') === 'true' ? 'checked' : properties.get('checked') === 'false' ? 'unchecked' : 'mixed';
  219. if (properties.has('pressed')) axNode.pressed = properties.get('pressed') === 'true' ? 'pressed' : properties.get('pressed') === 'false' ? 'released' : 'mixed';
  220. return axNode;
  221. }
  222. static createTree(client, payloads) {
  223. const nodeById = new Map();
  224. for (const payload of payloads) nodeById.set(payload.nodeId, new CRAXNode(client, payload));
  225. for (const node of nodeById.values()) {
  226. for (const childId of node._payload.childIds || []) node._children.push(nodeById.get(childId));
  227. }
  228. return nodeById.values().next().value;
  229. }
  230. }