From fb1b726e7e691510fc14214ef9607f40d81eba69 Mon Sep 17 00:00:00 2001 From: Serhii Kulykov Date: Mon, 20 May 2024 11:22:56 +0300 Subject: [PATCH] feat: observe target move on the page and update position (#7427) --- .../src/vaadin-overlay-position-mixin.js | 12 +++ .../overlay/src/vaadin-overlay-utils.d.ts | 13 +++ packages/overlay/src/vaadin-overlay-utils.js | 89 +++++++++++++++++++ .../test/position-mixin-listeners.test.js | 26 ++++++ 4 files changed, 140 insertions(+) create mode 100644 packages/overlay/src/vaadin-overlay-utils.d.ts create mode 100644 packages/overlay/src/vaadin-overlay-utils.js diff --git a/packages/overlay/src/vaadin-overlay-position-mixin.js b/packages/overlay/src/vaadin-overlay-position-mixin.js index 194eafa67d..ac754d145e 100644 --- a/packages/overlay/src/vaadin-overlay-position-mixin.js +++ b/packages/overlay/src/vaadin-overlay-position-mixin.js @@ -4,6 +4,7 @@ * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/ */ import { getAncestorRootNodes } from '@vaadin/component-base/src/dom-utils.js'; +import { observeMove } from './vaadin-overlay-utils.js'; const PROP_NAMES_VERTICAL = { start: 'top', @@ -153,6 +154,12 @@ export const PositionMixin = (superClass) => this.__positionTargetAncestorRootNodes.forEach((node) => { node.addEventListener('scroll', this.__onScroll, true); }); + + if (this.positionTarget) { + this.__observePositionTargetMove = observeMove(this.positionTarget, () => { + this._updatePosition(); + }); + } } /** @private */ @@ -166,6 +173,11 @@ export const PositionMixin = (superClass) => }); this.__positionTargetAncestorRootNodes = null; } + + if (this.__observePositionTargetMove) { + this.__observePositionTargetMove(); + this.__observePositionTargetMove = null; + } } /** @private */ diff --git a/packages/overlay/src/vaadin-overlay-utils.d.ts b/packages/overlay/src/vaadin-overlay-utils.d.ts new file mode 100644 index 0000000000..13ab5a1cc8 --- /dev/null +++ b/packages/overlay/src/vaadin-overlay-utils.d.ts @@ -0,0 +1,13 @@ +/** + * @license + * Copyright (c) 2024 Vaadin Ltd. + * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/ + */ + +/** + * Observe moving an element around on a page. + * + * Based on the idea from https://samthor.au/2021/observing-dom/ as implemented in Floating UI + * https://github.com/floating-ui/floating-ui/blob/58ed169/packages/dom/src/autoUpdate.ts#L45 + */ +export function observeMove(element: HTMLElement, callback: () => void): () => void; diff --git a/packages/overlay/src/vaadin-overlay-utils.js b/packages/overlay/src/vaadin-overlay-utils.js new file mode 100644 index 0000000000..4086bf4851 --- /dev/null +++ b/packages/overlay/src/vaadin-overlay-utils.js @@ -0,0 +1,89 @@ +/** + * @license + * Copyright (c) 2024 Vaadin Ltd. + * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/ + */ + +/** + * Observe moving an element around on a page. + * + * Based on the idea from https://samthor.au/2021/observing-dom/ as implemented in Floating UI + * https://github.com/floating-ui/floating-ui/blob/58ed169/packages/dom/src/autoUpdate.ts#L45 + * + * @param {HTMLElement} element + * @param {Function} callback + * @return {Function} + */ +export function observeMove(element, callback) { + let io = null; + + const root = document.documentElement; + + function cleanup() { + io && io.disconnect(); + io = null; + } + + function refresh(skip = false, threshold = 1) { + cleanup(); + + const { left, top, width, height } = element.getBoundingClientRect(); + + if (!skip) { + callback(); + } + + if (!width || !height) { + return; + } + + const insetTop = Math.floor(top); + const insetRight = Math.floor(root.clientWidth - (left + width)); + const insetBottom = Math.floor(root.clientHeight - (top + height)); + const insetLeft = Math.floor(left); + + const rootMargin = `${-insetTop}px ${-insetRight}px ${-insetBottom}px ${-insetLeft}px`; + + const options = { + rootMargin, + threshold: Math.max(0, Math.min(1, threshold)) || 1, + }; + + let isFirstUpdate = true; + + function handleObserve(entries) { + let ratio = entries[0].intersectionRatio; + + if (ratio !== threshold) { + if (!isFirstUpdate) { + return refresh(); + } + + // It's possible for the watched element to not be at perfect 1.0 visibility when we create + // the IntersectionObserver. This has a couple of causes: + // - elements being on partial pixels + // - elements being hidden offscreen (e.g., has `overflow: hidden`) + // - delays: if your DOM change occurs due to e.g., page resize, you can see elements + // behind their actual position + // + // In all of these cases, refresh but with this lower ratio of threshold. When the element + // moves beneath _that_ new value, the user will get notified. + if (ratio === 0.0) { + ratio = 0.0000001; // Just needs to be non-zero + } + + refresh(false, ratio); + } + + isFirstUpdate = false; + } + + io = new IntersectionObserver(handleObserve, options); + + io.observe(element); + } + + refresh(true); + + return cleanup; +} diff --git a/packages/overlay/test/position-mixin-listeners.test.js b/packages/overlay/test/position-mixin-listeners.test.js index 3837058af0..ea07b8709e 100644 --- a/packages/overlay/test/position-mixin-listeners.test.js +++ b/packages/overlay/test/position-mixin-listeners.test.js @@ -189,6 +189,32 @@ describe('position mixin listeners', () => { expect(updatePositionSpy.called).to.be.false; }); + it('should update position on target move by changing style', async () => { + target.style.position = 'static'; + await nextFrame(); + updatePositionSpy.resetHistory(); + + target.marginTop = '20px'; + // Wait for intersection observer + await nextFrame(); + await nextFrame(); + + expect(updatePositionSpy.called).to.be.true; + }); + + it('should not update position on target move when closed', async () => { + target.style.position = 'static'; + await nextFrame(); + updatePositionSpy.resetHistory(); + + overlay.opened = false; + + target.marginTop = '20px'; + await nextFrame(); + await nextFrame(); + expect(updatePositionSpy.called).to.be.false; + }); + ['document', 'visual viewport', 'ancestor'].forEach((name) => { describe(name, () => { let scrollableNode;