blob: f2ea76eeeda7f4284d793d36400a05297d4343f5 [file] [log] [blame]
// 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 * as fillUtil from '//components/autofill/ios/form_util/resources/fill_util.js';
import {gCrWeb, gCrWebLegacy} from '//ios/web/public/js_messaging/resources/gcrweb.js';
import {sendWebKitMessage} from '//ios/web/public/js_messaging/resources/utils.js';
/**
* @fileoverview Adds listeners on focus related events, 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.
*/
const 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;
}
/*
* Returns true if the bottom sheet can be triggered.
* @private
*/
function canTriggerBottomSheet(element: Element) {
// Verify that the window's layout viewport has a height and a width and also
// that the element is visible.
return window.innerHeight > 0 && window.innerWidth > 0 &&
fillUtil.isVisibleNode(element);
}
/*
* Prepare and send message to show bottom sheet.
* @private
*/
function showBottomSheet(hasUserGesture: boolean): void {
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 "gCrWebLegacy.fill" and
// "gCrWebLegacy.form" calls to import and call the functions directly once
// the conversion to TypeScript is done.
const msg = {
'frameID': gCrWeb.getFrameId(),
'formName': gCrWebLegacy.form.getFormIdentifier(form),
'formRendererID': fillUtil.getUniqueID(form),
'fieldIdentifier': gCrWebLegacy.form.getFieldIdentifier(field),
'fieldRendererID': fillUtil.getUniqueID(field),
'fieldType': fieldType,
'type': 'focus',
'value': fieldValue,
'hasUserGesture': hasUserGesture,
};
sendWebKitMessage('BottomSheetMessage', msg);
}
/**
* Handles mousedown events for listeners.
* @private
*/
function onMouseDown(event: MouseEvent): void {
if (!event.target || !(event.target instanceof HTMLElement)) {
return;
}
// Field must be empty (ignoring white spaces).
if ((event.target instanceof HTMLInputElement) &&
event.target.value.trim()) {
return;
}
// Show the bottom sheet iff the conditions are right, or bail out otherwise
// and let the user fallback to using keyboard for filling the field.
if (canTriggerBottomSheet(event.target!)) {
// Prevent the keyboard from showing up iff the bottom sheet can be
// triggered by preventing the default action of mousedown (focus).
event.preventDefault();
lastBlurredElement_ = event.target;
showBottomSheet(event.isTrusted);
}
}
/**
* Handles focus events for listeners.
* @private
*/
function onFocus(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;
}
// Show the bottom sheet iff the conditions are right, or bail out otherwise
// and let the user fallback to using keyboard for filling the field.
if (canTriggerBottomSheet(event.target!)) {
// Prevent the keyboard from showing up iff the bottom sheet can be
// triggered.
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 detachListenersInternal(rendererIds: number[]): void {
for (const rendererId of rendererIds) {
const element = gCrWebLegacy.fill.getElementByUniqueID(rendererId);
const index = observedElements_.indexOf(element);
if (index > -1) {
// Detach all possible handlers. If the listener wasn't attached, this
// will be no op, no errors thrown.
element.removeEventListener(
'mousedown', onMouseDown as EventListener, true);
element.removeEventListener('focus', onFocus, true);
observedElements_.splice(index, 1);
}
}
}
/**
* Finds the element associated with each provided renderer ID and attaches a
* listener to trigger the bottom sheet.
* @param rendererIds The IDs of the elements to observe.
* @param allowAutofocus Whether the bottom sheet can be triggered by an
* already focused field.
* @param useMousedownBlur Whether to do virtual blur from the 'mousedown' event
* instead of doing a brute force 'blur' on the element.
*/
function attachListeners(
rendererIds: number[], allowAutofocus: boolean,
useMousedownBlur: boolean): void {
// Build list of elements
let elementToBlur: HTMLElement|null = null;
const elementsToObserve: Element[] = [];
for (const renderer_id of rendererIds) {
const element = gCrWebLegacy.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)) {
const autofocused = document.activeElement === element;
if (allowAutofocus || !autofocused) {
// Observe element if eligible.
elementsToObserve.push(element);
}
if (autofocused) {
// 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 (allowAutofocus) {
elementToBlur = element;
}
}
}
}
// Attach the listeners once the IDs are set.
for (const element of elementsToObserve) {
if (useMousedownBlur) {
element.addEventListener(
'mousedown', onMouseDown as EventListener, true);
} else {
element.addEventListener('focus', onFocus as EventListener, true);
}
observedElements_.push(element);
}
// If (1) the element was already focused (i.e. likely autofocused) at the
// moment of attaching the listeners , (2) taking over autofocus is allowed,
// and (3) the sheet can be triggered, trigger the bottom sheet immediately
// and allow restoring the focus later on once the sheet is dismissed.
if (elementToBlur && canTriggerBottomSheet(elementToBlur!)) {
// Blur elements that are already actively focused which is the only effective
// way to blur in this case.
elementToBlur.blur();
lastBlurredElement_ = elementToBlur;
showBottomSheet(/*hasUserGesture=*/ false);
}
}
/**
* Refocuses on the last element that was blurred by the listeners.
*/
function refocusLastBlurredElement() {
lastBlurredElement_?.focus();
lastBlurredElement_ = null;
}
/**
* Removes all previously attached listeners before re-triggering
* a focus event on the previously blurred element.
*/
function detachListeners(rendererIds: 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.
detachListenersInternal(rendererIds);
if (refocus) {
refocusLastBlurredElement();
}
}
gCrWebLegacy.bottomSheet = {
attachListeners,
detachListeners,
refocusLastBlurredElement,
};