mirror of
https://github.com/bitwarden/browser.git
synced 2024-06-27 10:46:02 +02:00
28de9439be
* [deps] Autofill: Update prettier to v3 * prettier formatting updates --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Jonathan Prusik <jprusik@classynemesis.com>
202 lines
7.1 KiB
TypeScript
202 lines
7.1 KiB
TypeScript
import { FillableFormFieldElement, FormFieldElement } from "../types";
|
|
|
|
import { DomElementVisibilityService as domElementVisibilityServiceInterface } from "./abstractions/dom-element-visibility.service";
|
|
|
|
class DomElementVisibilityService implements domElementVisibilityServiceInterface {
|
|
private cachedComputedStyle: CSSStyleDeclaration | null = null;
|
|
|
|
/**
|
|
* Checks if a form field is viewable. This is done by checking if the element is within the
|
|
* viewport bounds, not hidden by CSS, and not hidden behind another element.
|
|
* @param {FormFieldElement} element
|
|
* @returns {Promise<boolean>}
|
|
*/
|
|
async isFormFieldViewable(element: FormFieldElement): Promise<boolean> {
|
|
const elementBoundingClientRect = element.getBoundingClientRect();
|
|
if (
|
|
this.isElementOutsideViewportBounds(element, elementBoundingClientRect) ||
|
|
this.isElementHiddenByCss(element)
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
return this.formFieldIsNotHiddenBehindAnotherElement(element, elementBoundingClientRect);
|
|
}
|
|
|
|
/**
|
|
* Check if the target element is hidden using CSS. This is done by checking the opacity, display,
|
|
* visibility, and clip-path CSS properties of the element. We also check the opacity of all
|
|
* parent elements to ensure that the target element is not hidden by a parent element.
|
|
* @param {HTMLElement} element
|
|
* @returns {boolean}
|
|
* @public
|
|
*/
|
|
isElementHiddenByCss(element: HTMLElement): boolean {
|
|
this.cachedComputedStyle = null;
|
|
|
|
if (
|
|
this.isElementInvisible(element) ||
|
|
this.isElementNotDisplayed(element) ||
|
|
this.isElementNotVisible(element) ||
|
|
this.isElementClipped(element)
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
let parentElement = element.parentElement;
|
|
while (parentElement && parentElement !== element.ownerDocument.documentElement) {
|
|
this.cachedComputedStyle = null;
|
|
if (this.isElementInvisible(parentElement)) {
|
|
return true;
|
|
}
|
|
|
|
parentElement = parentElement.parentElement;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Gets the computed style of a given element, will only calculate the computed
|
|
* style if the element's style has not been previously cached.
|
|
* @param {HTMLElement} element
|
|
* @param {string} styleProperty
|
|
* @returns {string}
|
|
* @private
|
|
*/
|
|
private getElementStyle(element: HTMLElement, styleProperty: string): string {
|
|
if (!this.cachedComputedStyle) {
|
|
this.cachedComputedStyle = (element.ownerDocument.defaultView || window).getComputedStyle(
|
|
element,
|
|
);
|
|
}
|
|
|
|
return this.cachedComputedStyle.getPropertyValue(styleProperty);
|
|
}
|
|
|
|
/**
|
|
* Checks if the opacity of the target element is less than 0.1.
|
|
* @param {HTMLElement} element
|
|
* @returns {boolean}
|
|
* @private
|
|
*/
|
|
private isElementInvisible(element: HTMLElement): boolean {
|
|
return parseFloat(this.getElementStyle(element, "opacity")) < 0.1;
|
|
}
|
|
|
|
/**
|
|
* Checks if the target element has a display property of none.
|
|
* @param {HTMLElement} element
|
|
* @returns {boolean}
|
|
* @private
|
|
*/
|
|
private isElementNotDisplayed(element: HTMLElement): boolean {
|
|
return this.getElementStyle(element, "display") === "none";
|
|
}
|
|
|
|
/**
|
|
* Checks if the target element has a visibility property of hidden or collapse.
|
|
* @param {HTMLElement} element
|
|
* @returns {boolean}
|
|
* @private
|
|
*/
|
|
private isElementNotVisible(element: HTMLElement): boolean {
|
|
return new Set(["hidden", "collapse"]).has(this.getElementStyle(element, "visibility"));
|
|
}
|
|
|
|
/**
|
|
* Checks if the target element has a clip-path property that hides the element.
|
|
* @param {HTMLElement} element
|
|
* @returns {boolean}
|
|
* @private
|
|
*/
|
|
private isElementClipped(element: HTMLElement): boolean {
|
|
return new Set([
|
|
"inset(50%)",
|
|
"inset(100%)",
|
|
"circle(0)",
|
|
"circle(0px)",
|
|
"circle(0px at 50% 50%)",
|
|
"polygon(0 0, 0 0, 0 0, 0 0)",
|
|
"polygon(0px 0px, 0px 0px, 0px 0px, 0px 0px)",
|
|
]).has(this.getElementStyle(element, "clipPath"));
|
|
}
|
|
|
|
/**
|
|
* Checks if the target element is outside the viewport bounds. This is done by checking if the
|
|
* element is too small or is overflowing the viewport bounds.
|
|
* @param {HTMLElement} targetElement
|
|
* @param {DOMRectReadOnly | null} targetElementBoundingClientRect
|
|
* @returns {boolean}
|
|
* @private
|
|
*/
|
|
private isElementOutsideViewportBounds(
|
|
targetElement: HTMLElement,
|
|
targetElementBoundingClientRect: DOMRectReadOnly | null = null,
|
|
): boolean {
|
|
const documentElement = targetElement.ownerDocument.documentElement;
|
|
const documentElementWidth = documentElement.scrollWidth;
|
|
const documentElementHeight = documentElement.scrollHeight;
|
|
const elementBoundingClientRect =
|
|
targetElementBoundingClientRect || targetElement.getBoundingClientRect();
|
|
const elementTopOffset = elementBoundingClientRect.top - documentElement.clientTop;
|
|
const elementLeftOffset = elementBoundingClientRect.left - documentElement.clientLeft;
|
|
|
|
const isElementSizeInsufficient =
|
|
elementBoundingClientRect.width < 10 || elementBoundingClientRect.height < 10;
|
|
const isElementOverflowingLeftViewport = elementLeftOffset < 0;
|
|
const isElementOverflowingRightViewport =
|
|
elementLeftOffset + elementBoundingClientRect.width > documentElementWidth;
|
|
const isElementOverflowingTopViewport = elementTopOffset < 0;
|
|
const isElementOverflowingBottomViewport =
|
|
elementTopOffset + elementBoundingClientRect.height > documentElementHeight;
|
|
|
|
return (
|
|
isElementSizeInsufficient ||
|
|
isElementOverflowingLeftViewport ||
|
|
isElementOverflowingRightViewport ||
|
|
isElementOverflowingTopViewport ||
|
|
isElementOverflowingBottomViewport
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Checks if a passed FormField is not hidden behind another element. This is done by
|
|
* checking if the element at the center point of the FormField is the FormField itself
|
|
* or one of its labels.
|
|
* @param {FormFieldElement} targetElement
|
|
* @param {DOMRectReadOnly | null} targetElementBoundingClientRect
|
|
* @returns {boolean}
|
|
* @private
|
|
*/
|
|
private formFieldIsNotHiddenBehindAnotherElement(
|
|
targetElement: FormFieldElement,
|
|
targetElementBoundingClientRect: DOMRectReadOnly | null = null,
|
|
): boolean {
|
|
const elementBoundingClientRect =
|
|
targetElementBoundingClientRect || targetElement.getBoundingClientRect();
|
|
const elementRootNode = targetElement.getRootNode();
|
|
const rootElement =
|
|
elementRootNode instanceof ShadowRoot ? elementRootNode : targetElement.ownerDocument;
|
|
const elementAtCenterPoint = rootElement.elementFromPoint(
|
|
elementBoundingClientRect.left + elementBoundingClientRect.width / 2,
|
|
elementBoundingClientRect.top + elementBoundingClientRect.height / 2,
|
|
);
|
|
|
|
if (elementAtCenterPoint === targetElement) {
|
|
return true;
|
|
}
|
|
|
|
const targetElementLabelsSet = new Set((targetElement as FillableFormFieldElement).labels);
|
|
if (targetElementLabelsSet.has(elementAtCenterPoint as HTMLLabelElement)) {
|
|
return true;
|
|
}
|
|
|
|
const closestParentLabel = elementAtCenterPoint?.parentElement?.closest("label");
|
|
|
|
return targetElementLabelsSet.has(closestParentLabel);
|
|
}
|
|
}
|
|
|
|
export default DomElementVisibilityService;
|