diff --git a/src/js/components/shepherd-modal.svelte b/src/js/components/shepherd-modal.svelte index b28daf01d..2d4dcdd96 100644 --- a/src/js/components/shepherd-modal.svelte +++ b/src/js/components/shepherd-modal.svelte @@ -32,16 +32,18 @@ /** * Uses the bounds of the element we want the opening overtop of to set the dimensions of the opening and position it * @param {HTMLElement} targetElement The element the opening will expose + * @param {HTMLElement} scrollParent The scrollable parent of the target element * @param {Number} modalOverlayOpeningPadding An amount of padding to add around the modal overlay opening */ - export function positionModalOpening(targetElement, modalOverlayOpeningPadding = 0) { + export function positionModalOpening(targetElement, scrollParent, modalOverlayOpeningPadding = 0) { if (targetElement.getBoundingClientRect) { - const { x, y, width, height, left, top } = targetElement.getBoundingClientRect(); + const { y, height } = _getVisibleHeight(targetElement, scrollParent); + const { x, width, left } = targetElement.getBoundingClientRect(); // getBoundingClientRect is not consistent. Some browsers use x and y, while others use left and top openingProperties = { x: (x || left) - modalOverlayOpeningPadding, - y: (y || top) - modalOverlayOpeningPadding, + y: y - modalOverlayOpeningPadding, width: (width + (modalOverlayOpeningPadding * 2)), height: (height + (modalOverlayOpeningPadding * 2)) }; @@ -114,10 +116,12 @@ const { modalOverlayOpeningPadding } = step.options; if (step.target) { + const scrollParent = _getScrollParent(step.target); + // Setup recursive function to call requestAnimationFrame to update the modal opening position const rafLoop = () => { rafId = undefined; - positionModalOpening(step.target, modalOverlayOpeningPadding); + positionModalOpening(step.target, scrollParent, modalOverlayOpeningPadding); rafId = requestAnimationFrame(rafLoop); }; @@ -128,6 +132,57 @@ closeModalOpening(); } } + + /** + * Find the closest scrollable parent element + * @param {HTMLElement} element The target element + * @returns {HTMLElement} + * @private + */ + function _getScrollParent(element) { + if (!element) { + return null; + } + + const isHtmlElement = element instanceof HTMLElement; + const overflowY = isHtmlElement && window.getComputedStyle(element).overflowY; + const isScrollable = overflowY !== 'hidden' && overflowY !== 'visible'; + + + if (isScrollable && element.scrollHeight >= element.clientHeight) { + return element; + } + + return _getScrollParent(element.parentElement); + } + + /** + * Get the visible height of the target element relative to its scrollParent. + * If there is no scroll parent, the height of the element is returned. + * + * @param {HTMLElement} element The target element + * @param {HTMLElement} [scrollParent] The scrollable parent element + * @returns {{y: number, height: number}} + * @private + */ + function _getVisibleHeight(element, scrollParent) { + const elementRect = element.getBoundingClientRect(); + let top = elementRect.y || elementRect.top; + let bottom = elementRect.bottom || top + elementRect.height; + + if (scrollParent) { + const scrollRect = scrollParent.getBoundingClientRect(); + const scrollTop = scrollRect.y || scrollRect.top; + const scrollBottom = scrollRect.bottom || scrollTop + scrollRect.height; + + top = Math.max(top, scrollTop); + bottom = Math.min(bottom, scrollBottom); + } + + const height = Math.max(bottom - top, 0); // Default to 0 if height is negative + + return {y: top, height}; + }