diff --git a/src/dom-utils/getBoundingClientRect.js b/src/dom-utils/getBoundingClientRect.js index 1cae0f270d..79c3c6757f 100644 --- a/src/dom-utils/getBoundingClientRect.js +++ b/src/dom-utils/getBoundingClientRect.js @@ -1,38 +1,49 @@ // @flow import type { ClientRectObject, VirtualElement } from '../types'; -import { isHTMLElement } from './instanceOf'; +import { isElement, isHTMLElement} from './instanceOf'; import { round } from '../utils/math'; +import getWindow from './getWindow'; +import isLayoutViewport from './isLayoutViewport'; export default function getBoundingClientRect( element: Element | VirtualElement, - includeScale: boolean = false + includeScale: boolean = false, + isFixedStrategy: boolean = false ): ClientRectObject { - const rect = element.getBoundingClientRect(); + const clientRect = element.getBoundingClientRect(); let scaleX = 1; let scaleY = 1; - if (isHTMLElement(element) && includeScale) { - const offsetHeight = element.offsetHeight; - const offsetWidth = element.offsetWidth; - - // Do not attempt to divide by 0, otherwise we get `Infinity` as scale - // Fallback to 1 in case both values are `0` - if (offsetWidth > 0) { - scaleX = round(rect.width) / offsetWidth || 1; - } - if (offsetHeight > 0) { - scaleY = round(rect.height) / offsetHeight || 1; - } + if (includeScale && isHTMLElement(element)) { + scaleX = + (element: HTMLElement).offsetWidth > 0 + ? round(clientRect.width) / (element: HTMLElement).offsetWidth || 1 + : 1; + scaleY = + (element: HTMLElement).offsetHeight > 0 + ? round(clientRect.height) / (element: HTMLElement).offsetHeight || 1 + : 1; } + const {visualViewport} = isElement(element) ? getWindow(element) : window; + const addVisualOffsets = !isLayoutViewport() && isFixedStrategy; + + const x = + (clientRect.left + (addVisualOffsets && visualViewport ? visualViewport.offsetLeft : 0)) / + scaleX; + const y = + (clientRect.top + (addVisualOffsets && visualViewport ? visualViewport.offsetTop : 0)) / + scaleY; + const width = clientRect.width / scaleX; + const height = clientRect.height / scaleY; return { - width: rect.width / scaleX, - height: rect.height / scaleY, - top: rect.top / scaleY, - right: rect.right / scaleX, - bottom: rect.bottom / scaleY, - left: rect.left / scaleX, - x: rect.left / scaleX, - y: rect.top / scaleY, + width, + height, + top: y, + right: x + width, + bottom: y + height, + left: x, + x, + y }; } diff --git a/src/dom-utils/getClippingRect.js b/src/dom-utils/getClippingRect.js index 2d2fb7b4c0..7f61097426 100644 --- a/src/dom-utils/getClippingRect.js +++ b/src/dom-utils/getClippingRect.js @@ -1,5 +1,5 @@ // @flow -import type { ClientRectObject } from '../types'; +import type { ClientRectObject, PositioningStrategy } from '../types'; import type { Boundary, RootBoundary } from '../enums'; import { viewport } from '../enums'; import getViewportRect from './getViewportRect'; @@ -16,8 +16,11 @@ import getNodeName from './getNodeName'; import rectToClientRect from '../utils/rectToClientRect'; import { max, min } from '../utils/math'; -function getInnerBoundingClientRect(element: Element) { - const rect = getBoundingClientRect(element); +function getInnerBoundingClientRect( + element: Element, + strategy: PositioningStrategy +) { + const rect = getBoundingClientRect(element, false, strategy === 'fixed'); rect.top = rect.top + element.clientTop; rect.left = rect.left + element.clientLeft; @@ -33,12 +36,13 @@ function getInnerBoundingClientRect(element: Element) { function getClientRectFromMixedType( element: Element, - clippingParent: Element | RootBoundary + clippingParent: Element | RootBoundary, + strategy: PositioningStrategy, ): ClientRectObject { return clippingParent === viewport - ? rectToClientRect(getViewportRect(element)) + ? rectToClientRect(getViewportRect(element, strategy)) : isElement(clippingParent) - ? getInnerBoundingClientRect(clippingParent) + ? getInnerBoundingClientRect(clippingParent, strategy) : rectToClientRect(getDocumentRect(getDocumentElement(element))); } @@ -72,7 +76,8 @@ function getClippingParents(element: Element): Array { export default function getClippingRect( element: Element, boundary: Boundary, - rootBoundary: RootBoundary + rootBoundary: RootBoundary, + strategy: PositioningStrategy ): ClientRectObject { const mainClippingParents = boundary === 'clippingParents' @@ -82,7 +87,7 @@ export default function getClippingRect( const firstClippingParent = clippingParents[0]; const clippingRect = clippingParents.reduce((accRect, clippingParent) => { - const rect = getClientRectFromMixedType(element, clippingParent); + const rect = getClientRectFromMixedType(element, clippingParent, strategy); accRect.top = max(rect.top, accRect.top); accRect.right = min(rect.right, accRect.right); @@ -90,7 +95,7 @@ export default function getClippingRect( accRect.left = max(rect.left, accRect.left); return accRect; - }, getClientRectFromMixedType(element, firstClippingParent)); + }, getClientRectFromMixedType(element, firstClippingParent, strategy)); clippingRect.width = clippingRect.right - clippingRect.left; clippingRect.height = clippingRect.bottom - clippingRect.top; diff --git a/src/dom-utils/getCompositeRect.js b/src/dom-utils/getCompositeRect.js index 8092dc8849..a930a5ff9b 100644 --- a/src/dom-utils/getCompositeRect.js +++ b/src/dom-utils/getCompositeRect.js @@ -30,7 +30,8 @@ export default function getCompositeRect( const documentElement = getDocumentElement(offsetParent); const rect = getBoundingClientRect( elementOrVirtualElement, - offsetParentIsScaled + offsetParentIsScaled, + isFixed ); let scroll = { scrollLeft: 0, scrollTop: 0 }; diff --git a/src/dom-utils/getViewportRect.js b/src/dom-utils/getViewportRect.js index 44421f848a..2d85fe6a6b 100644 --- a/src/dom-utils/getViewportRect.js +++ b/src/dom-utils/getViewportRect.js @@ -2,8 +2,13 @@ import getWindow from './getWindow'; import getDocumentElement from './getDocumentElement'; import getWindowScrollBarX from './getWindowScrollBarX'; +import isLayoutViewport from './isLayoutViewport'; +import type { PositioningStrategy } from '../types'; -export default function getViewportRect(element: Element) { +export default function getViewportRect( + element: Element, + strategy: PositioningStrategy +) { const win = getWindow(element); const html = getDocumentElement(element); const visualViewport = win.visualViewport; @@ -13,25 +18,13 @@ export default function getViewportRect(element: Element) { let x = 0; let y = 0; - // NB: This isn't supported on iOS <= 12. If the keyboard is open, the popper - // can be obscured underneath it. - // Also, `html.clientHeight` adds the bottom bar height in Safari iOS, even - // if it isn't open, so if this isn't available, the popper will be detected - // to overflow the bottom of the screen too early. if (visualViewport) { width = visualViewport.width; height = visualViewport.height; - // Uses Layout Viewport (like Chrome; Safari does not currently) - // In Chrome, it returns a value very close to 0 (+/-) but contains rounding - // errors due to floating point numbers, so we need to check precision. - // Safari returns a number <= 0, usually < -1 when pinch-zoomed + const layoutViewport = isLayoutViewport(); - // Feature detection fails in mobile emulation mode in Chrome. - // Math.abs(win.innerWidth / visualViewport.scale - visualViewport.width) < - // 0.001 - // Fallback here: "Not Safari" userAgent - if (!/^((?!chrome|android).)*safari/i.test(navigator.userAgent)) { + if (layoutViewport || (!layoutViewport && strategy === 'fixed')) { x = visualViewport.offsetLeft; y = visualViewport.offsetTop; } diff --git a/src/dom-utils/isLayoutViewport.js b/src/dom-utils/isLayoutViewport.js new file mode 100644 index 0000000000..4483ccb25f --- /dev/null +++ b/src/dom-utils/isLayoutViewport.js @@ -0,0 +1,25 @@ +// @flow +type Navigator = Navigator & { userAgentData?: NavigatorUAData }; + +interface NavigatorUAData { + brands: Array<{brand: string; version: string}>; + mobile: boolean; + platform: string; +} + +function getUAString(): string { + + const uaData = (navigator: Navigator).userAgentData; + + if (uaData?.brands) { + return uaData.brands + .map((item) => `${item.brand}/${item.version}`) + .join(' '); + } + + return navigator.userAgent; +} + +export default function isLayoutViewport() { + return !/^((?!chrome|android).)*safari/i.test(getUAString()); +} diff --git a/src/utils/detectOverflow.js b/src/utils/detectOverflow.js index 29cbabb96b..4cc253c9bc 100644 --- a/src/utils/detectOverflow.js +++ b/src/utils/detectOverflow.js @@ -1,5 +1,5 @@ // @flow -import type { State, SideObject, Padding } from '../types'; +import type { State, SideObject, Padding, PositioningStrategy } from '../types'; import type { Placement, Boundary, RootBoundary, Context } from '../enums'; import getClippingRect from '../dom-utils/getClippingRect'; import getDocumentElement from '../dom-utils/getDocumentElement'; @@ -23,6 +23,7 @@ import expandToHashMap from './expandToHashMap'; // eslint-disable-next-line import/no-unused-modules export type Options = { placement: Placement, + strategy: PositioningStrategy, boundary: Boundary, rootBoundary: RootBoundary, elementContext: Context, @@ -36,6 +37,7 @@ export default function detectOverflow( ): SideObject { const { placement = state.placement, + strategy = state.strategy, boundary = clippingParents, rootBoundary = viewport, elementContext = popper, @@ -59,7 +61,8 @@ export default function detectOverflow( ? element : element.contextElement || getDocumentElement(state.elements.popper), boundary, - rootBoundary + rootBoundary, + strategy, ); const referenceClientRect = getBoundingClientRect(state.elements.reference);