chromium/components/autofill/ios/browser/resources/suggestion_controller.ts

// Copyright 2013 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import {gCrWeb} from '//ios/web/public/js_messaging/resources/gcrweb.js';

/**
 * @fileoverview Installs suggestion management functions on the
 * __gCrWeb object.
 */

/**
 * Returns the element with the specified name that is a child of the
 * specified parent element. This function searches through the whole subtree
 * and, in the event that multiple elements match, the first element in NLR
 * order is returned.
 * @param parent The parent of the desired element.
 * @param name The name of the desired element.
 * @return The element if found, otherwise null;
 */
function getElementByNameWithParent(parent: Element, name: string): Element|
    null {
  if ('name' in parent && parent.name === name) {
    return parent;
  }

  for (const child of parent.children) {
    const element = getElementByNameWithParent(child, name);
    if (element) {
      return element;
    }
  }
  return null;
}

/**
 * Returns the first element in `elements` that is later than `elementToCompare`
 * in tab order.
 *
 * @param elementToCompare The element to start searching forward in tab order
 *     from.
 * @param elementList Elements in which the first element that is later than
 *     `elementToCompare` in tab order is to be returned if there is one;
 *     `elements` should be sorted in DOM NLR tree order and should contain
 *     `elementToCompare`.
 * @return the first element in `elements` that is later than `elementToCompare`
 *     in tab order if there is one; null otherwise.
 */
function getNextElementInTabOrder(
    elementToCompare: Element, elementList: NodeListOf<Element>): Element|null {
  // There is no defined behavior if the element is not reachable. Here the
  // next reachable element in DOM tree order is returned. (This is what is
  // observed in Mobile Safari and Chrome Desktop, if `elementToCompare` is not
  // the last element in DOM tree order).
  // TODO(chenyu): investigate and simulate Mobile Safari's behavior when
  // `elementToCompare` is the last one in DOM tree order.
  const elements = Array.from(elementList);
  if (!isSequentiallyReachable(elementToCompare)) {
    const indexToCompare = elements.indexOf(elementToCompare);
    if (indexToCompare === elements.length - 1 || indexToCompare === -1) {
      return null;
    }
    for (let index = indexToCompare + 1; index < elements.length; ++index) {
      const element = elements[index]!;
      if (element instanceof HTMLElement && isSequentiallyReachable(element)) {
        return element;
      }
    }
    return null;
  }
  // Returns true iff `element1` that has DOM tree position `index1` is after
  // `element2` that has DOM tree position `index2` in tab order. It is assumed
  // `index1 !== index2`.
  const comparator = function(
      element1: Element, index1: number, element2: Element, index2: number) {
    const tabOrder1 = getTabOrder(element1);
    const tabOrder2 = getTabOrder(element2);
    return tabOrder1 > tabOrder2 ||
        (tabOrder1 === tabOrder2 && index1 > index2);
  };
  return getFormElementAfter(elementToCompare, elements, comparator);
}

/**
 * Returns the last element in `elements` that is earlier than
 * `elementToCompare` in tab order.
 *
 * @param elementToCompare The element to start searching backward in tab order
 *     from.
 * @param elementList Elements in which the last element that is earlier than
 *     `elementToCompare` in tab order is to be returned if there is one;
 *     `elements` should be sorted in DOM tree order and it should contain
 *     `elementToCompare`.
 * @return the last element in `elements` that is earlier than
 *     `elementToCompare` in tab order if there is one; null otherwise.
 */
function getPreviousElementInTabOrder(
    elementToCompare: Element, elementList: NodeListOf<Element>): Element|null {
  const elements = Array.from(elementList);
  // There is no defined behavior if the element is not reachable. Here the
  // previous reachable element in DOM tree order is returned.
  if (!isSequentiallyReachable(elementToCompare)) {
    const indexToCompare = elements.indexOf(elementToCompare);
    if (indexToCompare <= 0) {  // Ignore if first or no element is found.
      return null;
    }
    for (let index = indexToCompare - 1; index >= 0; --index) {
      const element = elements[index];
      if (element && element instanceof HTMLElement &&
          isSequentiallyReachable(element)) {
        return element;
      }
    }
    return null;
  }
  // Returns true iff `element1` that has DOM tree position `index1` is before
  // `element2` that has DOM tree position `index2` in tab order. It is assumed
  // `index1 !== index2`.
  const comparator = function(
      element1: Element, index1: number, element2: Element,
      index2: number): boolean {
    const tabOrder1 = getTabOrder(element1);
    const tabOrder2 = getTabOrder(element2);
    return tabOrder1 < tabOrder2 ||
        (tabOrder1 === tabOrder2 && index1 < index2);
  };
  return getFormElementAfter(elementToCompare, elements, comparator);
}

/**
 * Given an element `elementToCompare`, such as
 * `__gCrWeb.suggestion.isSequentiallyReachable(elementToCompare)`, and a list
 * of `elements` which are sorted in DOM tree order and contains
 * `elementToCompare`, this method returns the next element in `elements` after
 * `elementToCompare` in the order defined by `comparator`, where an element is
 * said to be 'after' anotherElement if and only if
 * comparator(element, indexOfElement, anotherElement, anotherIndex) is true.
 *
 * @param elementToCompare The element to be compared.
 * @param elementList Elements to compare; `elements` should be
 *     sorted in DOM tree order and it should contain `elementToCompare`.
 * @param comparator A function that returns a boolean, given an Element
 *     `element1`, an integer that represents `element1`'s position in DOM tree
 *     order, an Element `element2` and an integer that represents `element2`'s
 *     position in DOM tree order.
 * @return The element that satisfies the conditions given above.
 */
function getFormElementAfter(
    elementToCompare: Element, elementList: Element[],
    comparator: (
        element1: Element, index1: number, element2: Element, index2: number) =>
        boolean): Element|null {
  // Computes the index `indexToCompare` of `elementToCompare` in `element`.
  const indexToCompare = elementList.indexOf(elementToCompare);
  if (indexToCompare === -1) {
    return null;
  }
  let result: Element|null = null;
  let resultIndex = -1;
  for (let index = 0; index < elementList.length; ++index) {
    if (index === indexToCompare) {
      continue;
    }
    const element = elementList[index];
    if (!element || !isSequentiallyReachable(element)) {
      continue;
    }
    if (comparator(element, index, elementToCompare, indexToCompare)) {
      if (!result) {
        result = element;
        resultIndex = index;
      } else {
        if (comparator(result, resultIndex, element, index)) {
          result = element;
          resultIndex = index;
        }
      }
    }
  }
  return result;
}

/**
 * Tests an element's visibility. This test is expensive so should be used
 * sparingly.
 *
 * @param element A DOM element.
 * @return true if the `element` is currently part of the visible
 * DOM.
 */
function isElementVisible(element: Element): boolean {
  let node: Node|null = element as Node;
  while (node && node !== document) {
    if (node.nodeType === Node.ELEMENT_NODE) {
      const style = window.getComputedStyle(node as Element);
      if (style.display === 'none' || style.visibility === 'hidden') {
        return false;
      }
    }
    // Move up the tree and test again.
    node = node.parentNode;
  }
  // Test reached the top of the DOM without finding a concealed
  // ancestor.
  return true;
}

/**
 * Returns if an element is reachable in sequential navigation.
 *
 * @param element The element that is to be examined.
 * @return Whether an element is reachable in sequential navigation.
 */
function isSequentiallyReachable(element: Element): boolean {
  // It is proposed in W3C that if tabIndex is omitted or parsing the
  // value returns an error, the user agent should follow platform
  // conventions to determine whether the element can be reached using
  // sequential focus navigation, and if so, what its relative order
  // should be. No document is found on the platform conventions in this
  // case on iOS.
  //
  // There is a list of elements for which the tabIndex focus flags are
  // suggested to be set in this case in W3C proposal. It is observed
  // that in WKWebView parsing the tabIndex of an element in this list
  // returns 0 if it is omitted or it is set to be an invalid value,
  // undefined, null or NaN. So here it is assumed that all the elements
  // that have invalid tabIndex are not reachable in sequential focus
  // navigation.
  //
  // It is proposed in W3C that if tabIndex is a negative integer, the
  // user agent should not allow the element to be reached using
  // sequential focus navigation.
  const tabIndex: number|null =
      'tabIndex' in element ? element.tabIndex as number : null;
  if ((!tabIndex && tabIndex !== 0) || tabIndex < 0) {
    return false;
  }
  const elementType: string|null =
      'type' in element ? element.type as string : null;
  if (elementType === 'hidden' || element.hasAttribute('disabled')) {
    return false;
  }
  // false is returned if `element` is neither an input nor a select.
  // Note based on this condition, false is returned for an iframe (as
  // Mobile Safari does not navigate to elements in an iframe, there is
  // no need to recursively check if there is a reachable element in an
  // iframe).
  if (element.tagName !== 'INPUT' && element.tagName !== 'SELECT' &&
      element.tagName !== 'TEXTAREA') {
    return false;
  }
  // The following elements are skipped when navigating using 'Prev' and
  // "Next' buttons in Mobile Safari.
  if (element.tagName === 'INPUT' &&
      (elementType === 'submit' || elementType === 'reset' ||
       elementType === 'image' || elementType === 'button' ||
       elementType === 'range' || elementType === 'radio' ||
       elementType === 'checkbox')) {
    return false;
  }
  // Expensive, final check that the element is not concealed.
  return isElementVisible(element);
}

/**
 * It is proposed in W3C an element that has a tabIndex greater than zero
 * should be placed before any focusable element whose tabIndex is equal to
 * zero in sequential focus navigation order. Here a value adjusted from
 * tabIndex that reflects this order is returned. That is, given `element1`
 * and `element2`, if `__gCrWeb.suggestion.getTabOrder(element1) >
 * __gCrWeb.suggestion.getTabOrder(element2)`, then `element1` is after
 * `element2` in sequential navigation.
 *
 * @param element The element of which the sequential navigation order
 *     information is returned.
 * @return An adjusted value that reflect `element`'s position in the sequential
 *     navigation.
 */
function getTabOrder(element: Element): number {
  const tabIndex: number|null =
      'tabIndex' in element ? element.tabIndex as number : null;
  if (tabIndex === 0) {
    return Number.MAX_VALUE;
  }
  return tabIndex as number;
}

/**
 * Returns the element named `fieldName` in the form specified by
 * `formName`, if it exists.
 *
 * @param formName The name of the form containing the element.
 * @param fieldName The name of the field containing the element.
 * @return The element if found, otherwise null.
 */
function getFormElement(formName: string, fieldName: string): Element|null {
  const form = gCrWeb.form.getFormElementFromIdentifier(formName);
  if (!form) {
    return null;
  }
  return getElementByNameWithParent(form, fieldName);
}

/**
 * Focuses the next element in the sequential focus navigation. No operation
 * if there is no such element.
 */
function selectNextElement(formName: string, fieldName: string) {
  const currentElement =
      formName ? getFormElement(formName, fieldName) : document.activeElement;
  if (!currentElement) {
    return;
  }
  const nextElement =
      getNextElementInTabOrder(currentElement, document.querySelectorAll('*'));
  if (nextElement && nextElement instanceof HTMLElement) {
    nextElement.focus();
  }
}

/**
 * Focuses the previous element in the sequential focus navigation. No
 * operation if there is no such element.
 */
function selectPreviousElement(formName: string, fieldName: string) {
  const currentElement =
      formName ? getFormElement(formName, fieldName) : document.activeElement;
  if (!currentElement) {
    return;
  }
  const prevElement = getPreviousElementInTabOrder(
      currentElement, document.querySelectorAll('*'));
  if (prevElement && prevElement instanceof HTMLElement) {
    prevElement.focus();
  }
}

/**
 * @param formName The name of the form containing the element.
 * @param fieldName The name of the field containing the element.
 * @return Whether there is an element in the sequential navigation after the
 *     currently active element.
 */
function hasNextElement(formName: string, fieldName: string): boolean {
  const currentElement =
      formName ? getFormElement(formName, fieldName) : document.activeElement;
  if (!currentElement) {
    return false;
  }
  return getNextElementInTabOrder(
             currentElement, document.querySelectorAll('*')) !== null;
}

/**
 * @param formName The name of the form containing the element.
 * @param fieldName The name of the field containing the element.
 * @return Whether there is an element in the sequential navigation before the
 *     currently active element.
 */
function hasPreviousElement(formName: string, fieldName: string): boolean {
  const currentElement =
      formName ? getFormElement(formName, fieldName) : document.activeElement;
  if (!currentElement) {
    return false;
  }
  return getPreviousElementInTabOrder(
             currentElement, document.querySelectorAll('*')) !== null;
}

declare interface PreviousNextElements {
  previous: boolean;
  next: boolean;
}

/**
 * @param formName The name of the form containing the element.
 * @param fieldName The name of the field containing the element.
 * @return Whether there is an element in the sequential navigation before and
 *     after currently active element. The result is returned as an object with
 *     a boolean value for the keys `previous` and `next`.
 */
function hasPreviousNextElements(
    formName: string, fieldName: string): PreviousNextElements {
  return {
    previous: hasPreviousElement(formName, fieldName),
    next: hasNextElement(formName, fieldName),
  };
}

gCrWeb.suggestion = {
  getNextElementInTabOrder,
  getPreviousElementInTabOrder,
  selectNextElement,
  selectPreviousElement,
  hasNextElement,
  hasPreviousElement,
  hasPreviousNextElements,
};