diff --git a/src/js/components/shepherd-modal.svelte b/src/js/components/shepherd-modal.svelte index 2db7cfbfc..a21b4d5a2 100644 --- a/src/js/components/shepherd-modal.svelte +++ b/src/js/components/shepherd-modal.svelte @@ -38,12 +38,16 @@ * Uses the bounds of the element we want the opening overtop of to set the dimensions of the opening and position it * @param {Number} modalOverlayOpeningPadding An amount of padding to add around the modal overlay opening * @param {Number | { topLeft: Number, bottomLeft: Number, bottomRight: Number, topRight: Number }} modalOverlayOpeningRadius An amount of border radius to add around the modal overlay opening + * @param {Number} modalOverlayOpeningXOffset An amount to offset the modal overlay opening in the x-direction + * @param {Number} modalOverlayOpeningYOffset An amount to offset the modal overlay opening in the y-direction * @param {HTMLElement} scrollParent The scrollable parent of the target element * @param {HTMLElement} targetElement The element the opening will expose */ export function positionModal( modalOverlayOpeningPadding = 0, modalOverlayOpeningRadius = 0, + modalOverlayOpeningXOffset = 0, + modalOverlayOpeningYOffset = 0, scrollParent, targetElement ) { @@ -55,8 +59,8 @@ openingProperties = { width: width + modalOverlayOpeningPadding * 2, height: height + modalOverlayOpeningPadding * 2, - x: (x || left) - modalOverlayOpeningPadding, - y: y - modalOverlayOpeningPadding, + x: (x || left) + modalOverlayOpeningXOffset - modalOverlayOpeningPadding, + y: y + modalOverlayOpeningYOffset - modalOverlayOpeningPadding, r: modalOverlayOpeningRadius }; } else { @@ -129,9 +133,12 @@ function _styleForStep(step) { const { modalOverlayOpeningPadding, - modalOverlayOpeningRadius + modalOverlayOpeningRadius, + modalOverlayOpeningXOffset = 0, + modalOverlayOpeningYOffset = 0 } = step.options; + const iframeOffset = _getIframeOffset(step.target); const scrollParent = _getScrollParent(step.target); // Setup recursive function to call requestAnimationFrame to update the modal opening position @@ -140,6 +147,8 @@ positionModal( modalOverlayOpeningPadding, modalOverlayOpeningRadius, + modalOverlayOpeningXOffset + iframeOffset.left, + modalOverlayOpeningYOffset + iframeOffset.top, scrollParent, step.target ); @@ -174,6 +183,42 @@ return _getScrollParent(element.parentElement); } + /** + * Get the top and left offset required to position the modal overlay cutout + * when the target element is within an iframe + * @param {HTMLElement} element The target element + * @private + */ + function _getIframeOffset(element) { + let offset = { + top: 0, + left: 0 + }; + + if (!element) { + return offset; + } + + let targetWindow = element.ownerDocument.defaultView; + + while (targetWindow !== window.top) { + const targetIframe = targetWindow?.frameElement; + + if (targetIframe) { + const targetIframeRect = targetIframe.getBoundingClientRect(); + + offset.top += + targetIframeRect.top + (targetIframeRect.scrollTop ?? 0); + offset.left += + targetIframeRect.left + (targetIframeRect.scrollLeft ?? 0); + } + + targetWindow = targetWindow.parent; + } + + return offset; + } + /** * 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. diff --git a/src/types/step.d.ts b/src/types/step.d.ts index 1e8587525..3bceb6530 100644 --- a/src/types/step.d.ts +++ b/src/types/step.d.ts @@ -12,7 +12,7 @@ declare class Step extends Evented { * @param options The options for the step * @return The newly created Step instance */ - constructor(tour: Tour, options: Step.StepOptions);//TODO superheri Note: Return on constructor is not possible in typescript. Could this be possible to make this the same for the constructor of the Step class? + constructor(tour: Tour, options: Step.StepOptions); //TODO superheri Note: Return on constructor is not possible in typescript. Could this be possible to make this the same for the constructor of the Step class? /** * The string used as the `id` for the step. @@ -41,13 +41,13 @@ declare class Step extends Evented { * Returns the element for the step * @return The element instance. undefined if it has never been shown, null if it has been destroyed */ - getElement(): HTMLElement | null | undefined + getElement(): HTMLElement | null | undefined; /** * Returns the target for the step * @returns The element instance. undefined if it has never been shown, null if query string has not been found */ - getTarget(): HTMLElement | null | undefined + getTarget(): HTMLElement | null | undefined; /** * Returns the tour for the step @@ -130,7 +130,7 @@ declare namespace Step { * A function that returns a promise. * When the promise resolves, the rest of the `show` code for the step will execute. */ - beforeShowPromise?: (() => Promise); + beforeShowPromise?: () => Promise; /** * An array of buttons to add to the step. These will be rendered in a @@ -181,6 +181,16 @@ declare namespace Step { topRight?: number; }; + /** + * An amount to offset the modal overlay opening in the x-direction + */ + modalOverlayOpeningXOffset?: number; + + /** + * An amount to offset the modal overlay opening in the y-direction + */ + modalOverlayOpeningYOffset?: number; + /** * Extra [options to pass to FloatingUI]{@link https://floating-ui.com/docs/tutorial/} */ @@ -195,13 +205,13 @@ declare namespace Step { * A function that lets you override the default scrollTo behavior and * define a custom action to do the scrolling, and possibly other logic. */ - scrollToHandler?: ((element: HTMLElement) => void); + scrollToHandler?: (element: HTMLElement) => void; /** * A function that, when it returns `true`, will show the step. * If it returns `false`, the step will be skipped. */ - showOn?: (() => boolean); + showOn?: () => boolean; /** * The text in the body of the step. It can be one of four types: @@ -212,7 +222,11 @@ declare namespace Step { * - `Function` to be executed when the step is built. It must return one of the three options above. * ``` */ - text?: string | ReadonlyArray | HTMLElement | (() => string | ReadonlyArray | HTMLElement); + text?: + | string + | ReadonlyArray + | HTMLElement + | (() => string | ReadonlyArray | HTMLElement); /** * The step's title. It becomes an `h3` at the top of the step. @@ -236,10 +250,25 @@ declare namespace Step { when?: StepOptionsWhen; } - type PopperPlacement = 'top'|'top-start'|'top-end'|'bottom'|'bottom-start'|'bottom-end'|'right'|'right-start'|'right-end'|'left'|'left-start'|'left-end'; + type PopperPlacement = + | 'top' + | 'top-start' + | 'top-end' + | 'bottom' + | 'bottom-start' + | 'bottom-end' + | 'right' + | 'right-start' + | 'right-end' + | 'left' + | 'left-start' + | 'left-end'; interface StepOptionsAttachTo { - element?: HTMLElement | string | (() => HTMLElement | string | null | undefined); + element?: + | HTMLElement + | string + | (() => HTMLElement | string | null | undefined); on?: PopperPlacement; } @@ -260,7 +289,7 @@ declare namespace Step { * } * ``` */ - action?: ((this: Tour) => void); + action?: (this: Tour) => void; /** * Extra classes to apply to the `` @@ -290,7 +319,7 @@ declare namespace Step { } interface StepOptionsButtonEvent { - [key: string]: (() => void); + [key: string]: () => void; } interface StepOptionsCancelIcon { @@ -299,7 +328,7 @@ declare namespace Step { } interface StepOptionsWhen { - [key: string]: ((this: Step) => void); + [key: string]: (this: Step) => void; } } diff --git a/test/unit/components/shepherd-modal.spec.js b/test/unit/components/shepherd-modal.spec.js index d230f145d..71c8aba3c 100644 --- a/test/unit/components/shepherd-modal.spec.js +++ b/test/unit/components/shepherd-modal.spec.js @@ -4,16 +4,13 @@ import { stub } from 'sinon'; const classPrefix = ''; describe('components/ShepherdModal', () => { - describe('closeModalOpening()', function() { - it('sets values back to 0', async() => { + describe('closeModalOpening()', function () { + it('sets values back to 0', async () => { const modalComponent = new ShepherdModal({ - target: document.body, - props: { - classPrefix - } + target: document.body }); - await modalComponent.positionModal(0, 0, null, { + await modalComponent.positionModal(0, 0, 0, 0, null, { getBoundingClientRect() { return { height: 250, @@ -42,8 +39,8 @@ describe('components/ShepherdModal', () => { }); }); - describe('positionModal()', function() { - it('sets the correct attributes when positioning modal opening', async() => { + describe('positionModal()', function () { + it('sets the correct attributes when positioning modal opening', async () => { const modalComponent = new ShepherdModal({ target: document.body, props: { @@ -65,7 +62,7 @@ describe('components/ShepherdModal', () => { 'M1024,768H0V0H1024V768ZM0,0a0,0,0,0,0-0,0V0a0,0,0,0,0,0,0H0a0,0,0,0,0,0-0V0a0,0,0,0,0-0-0Z' ); - await modalComponent.positionModal(0, 0, null, { + await modalComponent.positionModal(0, 0, 0, 0, null, { getBoundingClientRect() { return { height: 250, @@ -85,7 +82,7 @@ describe('components/ShepherdModal', () => { modalComponent.$destroy(); }); - it('sets the correct attributes with padding', async() => { + it('sets the correct attributes with padding', async () => { const modalComponent = new ShepherdModal({ target: document.body, props: { @@ -99,7 +96,7 @@ describe('components/ShepherdModal', () => { 'M1024,768H0V0H1024V768ZM0,0a0,0,0,0,0-0,0V0a0,0,0,0,0,0,0H0a0,0,0,0,0,0-0V0a0,0,0,0,0-0-0Z' ); - await modalComponent.positionModal(10, 0, null, { + await modalComponent.positionModal(10, 0, 0, 0, null, { getBoundingClientRect() { return { height: 250, @@ -119,7 +116,7 @@ describe('components/ShepherdModal', () => { modalComponent.$destroy(); }); - it('sets the correct attributes when positioning modal opening with border radius as number', async() => { + it('sets the correct attributes when positioning modal opening with border radius as number', async () => { const modalComponent = new ShepherdModal({ target: document.body, props: { @@ -141,7 +138,7 @@ describe('components/ShepherdModal', () => { 'M1024,768H0V0H1024V768ZM0,0a0,0,0,0,0-0,0V0a0,0,0,0,0,0,0H0a0,0,0,0,0,0-0V0a0,0,0,0,0-0-0Z' ); - await modalComponent.positionModal(0, 10, null, { + await modalComponent.positionModal(0, 10, 0, 0, null, { getBoundingClientRect() { return { height: 250, @@ -161,7 +158,7 @@ describe('components/ShepherdModal', () => { modalComponent.$destroy(); }); - it('sets the correct attributes when positioning modal opening with border radius as object', async() => { + it('sets the correct attributes when positioning modal opening with border radius as object', async () => { const modalComponent = new ShepherdModal({ target: document.body, props: { @@ -186,6 +183,8 @@ describe('components/ShepherdModal', () => { await modalComponent.positionModal( 0, { topLeft: 1, bottomLeft: 2, bottomRight: 3 }, + 0, + 0, null, { getBoundingClientRect() { @@ -208,7 +207,7 @@ describe('components/ShepherdModal', () => { modalComponent.$destroy(); }); - it('sets the correct attributes when target is overflowing from scroll parent', async() => { + it('sets the correct attributes when target is overflowing from scroll parent', async () => { const modalComponent = new ShepherdModal({ target: document.body, props: { @@ -217,6 +216,8 @@ describe('components/ShepherdModal', () => { }); await modalComponent.positionModal( + 0, + 0, 0, 0, { @@ -250,7 +251,7 @@ describe('components/ShepherdModal', () => { modalComponent.$destroy(); }); - it('sets the correct attributes when target fits inside scroll parent', async() => { + it('sets the correct attributes when target fits inside scroll parent', async () => { const modalComponent = new ShepherdModal({ target: document.body, props: { @@ -259,6 +260,8 @@ describe('components/ShepherdModal', () => { }); await modalComponent.positionModal( + 0, + 0, 0, 0, { @@ -291,9 +294,103 @@ describe('components/ShepherdModal', () => { modalComponent.$destroy(); }); + + it('allows setting an x-axis offset', async () => { + const modalComponent = new ShepherdModal({ + target: document.body, + props: { + classPrefix + } + }); + + modalComponent.positionModal(0, 0, 50, 0, null, { + getBoundingClientRect() { + return { + height: 250, + x: 10, + y: 10, + width: 500 + }; + } + }); + + let modalPath = await modalComponent.getElement().querySelector('path'); + + expect(modalPath).toHaveAttribute( + 'd', + 'M1024,768H0V0H1024V768ZM60,10a0,0,0,0,0-0,0V260a0,0,0,0,0,0,0H560a0,0,0,0,0,0-0V10a0,0,0,0,0-0-0Z' + ); + + modalComponent.positionModal(0, 0, 100, 0, null, { + getBoundingClientRect() { + return { + height: 250, + x: 10, + y: 10, + width: 500 + }; + } + }); + + modalPath = await modalComponent.getElement().querySelector('path'); + + expect(modalPath).toHaveAttribute( + 'd', + 'M1024,768H0V0H1024V768ZM110,10a0,0,0,0,0-0,0V260a0,0,0,0,0,0,0H610a0,0,0,0,0,0-0V10a0,0,0,0,0-0-0Z' + ); + + modalComponent.$destroy(); + }); + + it('allows setting a y-axis offset', async () => { + const modalComponent = new ShepherdModal({ + target: document.body, + props: { + classPrefix + } + }); + + modalComponent.positionModal(0, 0, 0, 35, null, { + getBoundingClientRect() { + return { + height: 250, + x: 10, + y: 10, + width: 500 + }; + } + }); + + let modalPath = await modalComponent.getElement().querySelector('path'); + + expect(modalPath).toHaveAttribute( + 'd', + 'M1024,768H0V0H1024V768ZM10,45a0,0,0,0,0-0,0V295a0,0,0,0,0,0,0H510a0,0,0,0,0,0-0V45a0,0,0,0,0-0-0Z' + ); + + modalComponent.positionModal(0, 0, 0, 75, null, { + getBoundingClientRect() { + return { + height: 250, + x: 10, + y: 10, + width: 500 + }; + } + }); + + modalPath = await modalComponent.getElement().querySelector('path'); + + expect(modalPath).toHaveAttribute( + 'd', + 'M1024,768H0V0H1024V768ZM10,85a0,0,0,0,0-0,0V335a0,0,0,0,0,0,0H510a0,0,0,0,0,0-0V85a0,0,0,0,0-0-0Z' + ); + + modalComponent.$destroy(); + }); }); - describe('setupForStep()', function() { + describe('setupForStep()', function () { let hideStub, showStub; afterEach(() => { @@ -302,7 +399,7 @@ describe('components/ShepherdModal', () => { }); // eslint-disable-next-line jest/no-disabled-tests - it.skip('useModalOverlay: false, hides modal', async() => { + it.skip('useModalOverlay: false, hides modal', async () => { const modalComponent = new ShepherdModal({ target: document.body, props: { @@ -329,7 +426,7 @@ describe('components/ShepherdModal', () => { }); // eslint-disable-next-line jest/no-disabled-tests - it.skip('useModalOverlay: true, shows modal', async() => { + it.skip('useModalOverlay: true, shows modal', async () => { const modalComponent = new ShepherdModal({ target: document.body, props: { @@ -356,7 +453,7 @@ describe('components/ShepherdModal', () => { }); }); - describe('show/hide', function() { + describe('show/hide', function () { const modalComponent = new ShepherdModal({ target: document.body, props: { @@ -364,7 +461,7 @@ describe('components/ShepherdModal', () => { } }); - it('show adds classes', async() => { + it('show adds classes', async () => { await modalComponent.show(); expect(modalComponent.getElement()).toHaveClass( @@ -372,7 +469,7 @@ describe('components/ShepherdModal', () => { ); }); - it('hide removes classes', async() => { + it('hide removes classes', async () => { await modalComponent.hide(); expect(modalComponent.getElement()).not.toHaveClass(