Home Reference Source

lib/accessibility/AXNode.js

const util = require('util')

class AXNode {
  /**
   * @param {Array<Object>} payloads
   * @return {!AXNode}
   */
  static createTree (payloads) {
    /** @type {!Map<string, !AXNode>} */
    const nodeById = new Map()
    for (const payload of payloads) {
      nodeById.set(payload.nodeId, new AXNode(payload))
    }
    for (const node of nodeById.values()) {
      for (const childId of node._payload.childIds || []) {
        node._children.push(nodeById.get(childId))
      }
    }
    return nodeById.values().next().value
  }

  /**
   * @param {!Object} payload
   */
  constructor (payload) {
    this._payload = payload

    /** @type {Array<AXNode>} */
    this._children = []

    this._richlyEditable = false
    this._editable = false
    this._focusable = false
    this._expanded = false
    this._name = this._payload.name ? this._payload.name.value : ''
    this._role = this._payload.role ? this._payload.role.value : 'Unknown'

    for (const property of this._payload.properties || []) {
      if (property.name === 'editable') {
        this._richlyEditable = property.value.value === 'richtext'
        this._editable = true
      }
      if (property.name === 'focusable') this._focusable = property.value.value
      if (property.name === 'expanded') this._expanded = property.value.value
    }
  }

  /**
   * @return {boolean}
   */
  isLeafNode () {
    if (!this._children.length) return true

    // These types of objects may have children that we use as internal
    // implementation details, but we want to expose them as leaves to platform
    // accessibility APIs because screen readers might be confused if they find
    // any children.
    if (this._isPlainTextField() || this._isTextOnlyObject()) return true

    // Roles whose children are only presentational according to the ARIA and
    // HTML5 Specs should be hidden from screen readers.
    // (Note that whilst ARIA buttons can have only presentational children, HTML5
    // buttons are allowed to have content.)
    switch (this._role) {
      case 'doc-cover':
      case 'graphics-symbol':
      case 'img':
      case 'Meter':
      case 'scrollbar':
      case 'slider':
      case 'separator':
      case 'progressbar':
        return true
      default:
        break
    }

    // Here and below: Android heuristics
    if (this._hasFocusableChild()) {
      return false
    }
    if (this._focusable && this._name) {
      return true
    }
    if (this._role === 'heading' && this._name) {
      return true
    }
    return false
  }

  /**
   * @return {boolean}
   */
  isControl () {
    switch (this._role) {
      case 'button':
      case 'checkbox':
      case 'ColorWell':
      case 'combobox':
      case 'DisclosureTriangle':
      case 'listbox':
      case 'menu':
      case 'menubar':
      case 'menuitem':
      case 'menuitemcheckbox':
      case 'menuitemradio':
      case 'radio':
      case 'scrollbar':
      case 'searchbox':
      case 'slider':
      case 'spinbutton':
      case 'switch':
      case 'tab':
      case 'textbox':
      case 'tree':
        return true
      default:
        return false
    }
  }

  /**
   * @param {boolean} insideControl
   * @return {boolean}
   */
  isInteresting (insideControl) {
    const role = this._role
    if (role === 'Ignored') return false

    if (this._focusable || this._richlyEditable) return true

    // If it's not focusable but has a control role, then it's interesting.
    if (this.isControl()) return true

    // A non focusable child of a control is not interesting
    if (insideControl) return false

    return this.isLeafNode() && !!this._name
  }

  /**
   * @return {!SerializedAXNode}
   */
  serialize () {
    /** @type {!Map<string, number|string|boolean>} */
    const properties = new Map()
    for (const property of this._payload.properties || []) {
      properties.set(property.name.toLowerCase(), property.value.value)
    }
    if (this._payload.name) properties.set('name', this._payload.name.value)
    if (this._payload.value) properties.set('value', this._payload.value.value)
    if (this._payload.description) {
      properties.set('description', this._payload.description.value)
    }

    /** @type {SerializedAXNode} */
    const node = { role: this._role }

    /** @type {Array<string>} */
    const userStringProperties = [
      'name',
      'value',
      'description',
      'keyshortcuts',
      'roledescription',
      'valuetext'
    ]

    for (const userStringProperty of userStringProperties) {
      if (!properties.has(userStringProperty)) continue
      node[userStringProperty] = properties.get(userStringProperty)
    }

    /** @type {Array<string>} */
    const booleanProperties = [
      'disabled',
      'expanded',
      'focused',
      'modal',
      'multiline',
      'multiselectable',
      'readonly',
      'required',
      'selected'
    ]

    for (const booleanProperty of booleanProperties) {
      // WebArea's treat focus differently than other nodes. They report whether their frame  has focus,
      // not whether focus is specifically on the root node.
      if (booleanProperty === 'focused' && this._role === 'WebArea') continue
      const value = properties.get(booleanProperty)
      if (!value) continue
      node[booleanProperty] = value
    }

    /** @type {Array<string>} */
    const tristateProperties = ['checked', 'pressed']
    for (const tristateProperty of tristateProperties) {
      if (!properties.has(tristateProperty)) continue
      const value = properties.get(tristateProperty)
      node[tristateProperty] = value === 'mixed' ? 'mixed' : value === 'true'
    }

    /** @type {Array<string>} */
    const numericalProperties = ['level', 'valuemax', 'valuemin']
    for (const numericalProperty of numericalProperties) {
      if (!properties.has(numericalProperty)) continue
      node[numericalProperty] = properties.get(numericalProperty)
    }

    /** @type {Array<string>} */
    const tokenProperties = [
      'autocomplete',
      'haspopup',
      'invalid',
      'orientation'
    ]
    for (const tokenProperty of tokenProperties) {
      const value = properties.get(tokenProperty)
      if (!value || value === 'false') continue
      node[tokenProperty] = value
    }
    return node
  }

  /**
   * @return {boolean}
   */
  _isPlainTextField () {
    if (this._richlyEditable) return false
    if (this._editable) return true
    return (
      this._role === 'textbox' ||
      this._role === 'ComboBox' ||
      this._role === 'searchbox'
    )
  }

  /**
   * @return {boolean}
   */
  _isTextOnlyObject () {
    const role = this._role
    return role === 'LineBreak' || role === 'text' || role === 'InlineTextBox'
  }

  /**
   * @return {boolean}
   */
  _hasFocusableChild () {
    if (this._cachedHasFocusableChild === undefined) {
      this._cachedHasFocusableChild = false
      for (const child of this._children) {
        if (child._focusable || child._hasFocusableChild()) {
          this._cachedHasFocusableChild = true
          break
        }
      }
    }
    return this._cachedHasFocusableChild
  }

  toJSON () {
    return this._payload
  }

  /** @ignore */
  // eslint-disable-next-line space-before-function-paren
  [util.inspect.custom](depth, options) {
    if (depth < 0) {
      return options.stylize('[AXNode]', 'special')
    }

    const newOptions = Object.assign({}, options, {
      depth: options.depth == null ? null : options.depth - 1
    })
    const inner = util.inspect(
      {
        richlyEditable: this._richlyEditable,
        editable: this._editable,
        focusable: this._focusable,
        expanded: this._expanded,
        name: this._name,
        role: this._role,
        cachedHasFocusableChild: this._cachedHasFocusableChild,
        children: this._children
      },
      newOptions
    )
    return `${options.stylize('AXNode', 'special')} ${inner}`
  }
}

/**
 * @type {AXNode}
 */
module.exports = AXNode