chromium/ios/chrome/browser/autofill/model/bottom_sheet/bottom_sheet.ts

// Copyright 2023 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';
import {sendWebKitMessage} from '//ios/web/public/js_messaging/resources/utils.js';

/**
 * @fileoverview Adds listeners on the focus event, specifically for elements
 * provided through a list of renderer IDs, in order for to allow showing a
 * bottom sheet in that context.
 */

/**
 * The last HTML element that was blurred.
 */
let lastBlurredElement_: HTMLElement|null = null;

/**
 * The list of observed elements.
 */
let observedElements_: Element[] = [];

/*
 * Returns whether an element is of a type we wish to observe.
 * Must be in sync with what is supported in showBottomSheet_.
 * @private
 */
function isObservable_(element: HTMLElement): boolean {
  // Ignore passkey fields, which contain the 'webauthn' autofill tag.
  const autocomplete_attribute = element.getAttribute('autocomplete');
  const isPasskeyField = autocomplete_attribute?.includes('webauthn');
  return ((element instanceof HTMLInputElement) ||
          (element instanceof HTMLFormElement)) &&
      !isPasskeyField;
}

/*
 * Prepare and send message to show bottom sheet.
 * @private
 */
function showBottomSheet_(hasUserGesture: boolean): void {
  // Verify that the window's layout viewport has a height and a width and also
  // that the element is visible.
  if (window.innerHeight == 0 || window.innerWidth == 0 ||
      !gCrWeb.fill.isVisibleNode(lastBlurredElement_)) {
    return;
  }

  let field = null;
  let fieldType = '';
  let fieldValue = '';
  let form = null;

  if (lastBlurredElement_ instanceof HTMLInputElement) {
    field = lastBlurredElement_;
    fieldType = lastBlurredElement_.type;
    fieldValue = lastBlurredElement_.value;
    form = lastBlurredElement_.form;
  } else if (lastBlurredElement_ instanceof HTMLFormElement) {
    form = lastBlurredElement_;
  }

  // TODO(crbug.com/40261693): convert these "gCrWeb.fill" and "gCrWeb.form"
  // calls to import and call the functions directly once the conversion to
  // TypeScript is done.

  const msg = {
    'frameID': gCrWeb.message.getFrameId(),
    'formName': gCrWeb.form.getFormIdentifier(form),
    'formRendererID': gCrWeb.fill.getUniqueID(form),
    'fieldIdentifier': gCrWeb.form.getFieldIdentifier(field),
    'fieldRendererID': gCrWeb.fill.getUniqueID(field),
    'fieldType': fieldType,
    'type': 'focus',
    'value': fieldValue,
    'hasUserGesture': hasUserGesture,
  };
  sendWebKitMessage('BottomSheetMessage', msg);
}

/**
 * Focus events for observed input elements are messaged to the main
 * application for broadcast to WebStateObservers.
 * @private
 */
function focusEventHandler_(event: Event): void {
  if (!event.target || !(event.target instanceof HTMLElement) ||
      (event.target !== document.activeElement)) {
    return;
  }

  // Field must be empty (ignoring white spaces).
  if ((event.target instanceof HTMLInputElement) && event.target.value.trim()) {
    return;
  }

  // Prevent the keyboard from showing up.
  event.target.blur();
  lastBlurredElement_ = event.target;

  showBottomSheet_(event.isTrusted);
}

/**
 * Removes listeners on the elements associated with each provided renderer ID
 * and removes those same elements from list of observed elements.
 * @private
 */
function detachListeners_(renderer_ids: number[]): void {
  for (const renderer_id of renderer_ids) {
    const element = gCrWeb.fill.getElementByUniqueID(renderer_id);
    let index = observedElements_.indexOf(element);
    if (index > -1) {
      element.removeEventListener('focus', focusEventHandler_, true);
      observedElements_.splice(index, 1);
    }
  }
}

/**
 * Finds the element associated with each provided renderer ID and
 * attaches a listener to each of these elements for the focus event.
 * "allow_autofocus" specifies whether the bottom sheet can be triggered by an
 * already focused field.
 */
function attachListeners(
    renderer_ids: number[], allow_autofocus: boolean): void {
  // Build list of elements
  let blurredElement: HTMLElement|null = null;
  let elementsToObserve: Element[] = [];
  for (const renderer_id of renderer_ids) {
    const element = gCrWeb.fill.getElementByUniqueID(renderer_id);
    // Only add element to list of observed elements if we aren't already
    // observing it.
    if (element && isObservable_(element) &&
        !observedElements_.find(elem => elem === element)) {
      elementsToObserve.push(element);
      if (document.activeElement === element) {
        // Check if the field is empty (ignoring white spaces).
        if (element.value.trim() != '') {
          // The user has already started filling the active field, so bail out
          // without attaching listeners.
          return;
        }
        if (allow_autofocus) {
          // Remove the focus on an element if it already has focus and we want
          // to show the related bottom sheet immediately.
          element.blur();
          blurredElement = element;
        }
      }
    }
  }

  // Attach the listeners once the IDs are set.
  for (const element of elementsToObserve) {
    element.addEventListener('focus', focusEventHandler_, true);
    observedElements_.push(element);
  }

  // Restore focus if it was removed.
  if (blurredElement) {
    lastBlurredElement_ = blurredElement;
    showBottomSheet_(/*hasUserGesture=*/ false);
  }
}

/**
 * Removes all previously attached listeners before re-triggering
 * a focus event on the previously blurred element.
 */
function detachListeners(renderer_ids: number[], refocus: boolean): void {
  // If the bottom sheet was dismissed, we don't need to show it anymore on this
  // page, so remove the event listeners.
  detachListeners_(renderer_ids);

  if (refocus && lastBlurredElement_) {
    // Re-focus the previously blurred element.
    lastBlurredElement_.focus();
  }
}

gCrWeb.bottomSheet = {
  attachListeners,
  detachListeners
};