diff --git a/shepherd.js/src/step.ts b/shepherd.js/src/step.ts index 88e5596a6..5b9f993bc 100644 --- a/shepherd.js/src/step.ts +++ b/shepherd.js/src/step.ts @@ -19,7 +19,10 @@ import { type ShepherdElementResult } from './components/shepherd-element.ts'; import { type Tour } from './tour.ts'; -import type { ComputePositionConfig } from '@floating-ui/dom'; +import type { + AutoUpdateOptions, + ComputePositionConfig +} from '@floating-ui/dom'; export type StepText = | string @@ -69,6 +72,27 @@ export interface StepOptions { */ arrow?: boolean | StepOptionsArrow; + /** + * Extra [options to pass to `autoUpdate`]{@link https://floating-ui.com/docs/autoUpdate}, + * which keeps the step attached to its target while the step is open. + * + * A notable use case is `{ layoutShift: false }`, which disables the + * `IntersectionObserver`-based tracking of targets that move for reasons + * other than scrolling or resizing. That machinery re-creates its observer + * every time the target moves, and when the observed intersection ratio + * never settles at the expected threshold (fractional bounding rects at + * non-integer browser zoom, pinch-zoom, or a target animating while + * observed) it can loop unboundedly -- up to + * `RangeError: Maximum call stack size exceeded` in browsers that deliver + * the initial observation synchronously. Scroll and resize tracking are + * unaffected, as they are covered by `ancestorScroll`, `ancestorResize` and + * `elementResize`. + * + * Can be set on `defaultStepOptions` to apply to every step, and is + * deep-merged with the step-level value. + */ + autoUpdateOptions?: AutoUpdateOptions; + /** * A function that returns a promise. * When the promise resolves, the rest of the `show` code for the step will execute. diff --git a/shepherd.js/src/utils/floating-ui.ts b/shepherd.js/src/utils/floating-ui.ts index 0a4e5c298..96c7ca47f 100644 --- a/shepherd.js/src/utils/floating-ui.ts +++ b/shepherd.js/src/utils/floating-ui.ts @@ -8,6 +8,7 @@ import { autoPlacement, limitShift, shift, + type AutoUpdateOptions, type ComputePositionConfig, type Middleware, type MiddlewareData, @@ -42,18 +43,22 @@ export function setupTooltip(step: Step): ComputePositionConfig { content?.classList.add('shepherd-centered'); } - step.cleanup = autoUpdate(target, step.el as HTMLElement, () => { - // The element might have already been removed by the end of the tour. - if (!step.el) { - step.cleanup?.(); - return; - } - - setPosition(target, step, floatingUIOptions, shouldCenter, { - shouldFocusAfterRender - }); - shouldFocusAfterRender = false; - }); + step.cleanup = autoUpdate( + target, + step.el as HTMLElement, + () => { + // The element might have already been removed by the end of the tour. + if (!step.el) { + step.cleanup?.(); + return; + } + setPosition(target, step, floatingUIOptions, shouldCenter, { + shouldFocusAfterRender + }); + shouldFocusAfterRender = false; + }, + step.options.autoUpdateOptions + ); step.target = attachToOptions.element as HTMLElement; @@ -66,18 +71,36 @@ export function setupTooltip(step: Step): ComputePositionConfig { * @param tourOptions - The default tour options. * @param options - Step specific options. * - * @return {floatingUIOptions: FloatingUIOptions} + * @return {floatingUIOptions: FloatingUIOptions, autoUpdateOptions?: AutoUpdateOptions} */ export function mergeTooltipConfig( tourOptions: StepOptions, options: StepOptions -): { floatingUIOptions: ComputePositionConfig } { - return { +): { + floatingUIOptions: ComputePositionConfig; + autoUpdateOptions?: AutoUpdateOptions; +} { + const config: { + floatingUIOptions: ComputePositionConfig; + autoUpdateOptions?: AutoUpdateOptions; + } = { floatingUIOptions: deepmerge( tourOptions.floatingUIOptions || {}, options.floatingUIOptions || {} ) }; + + // Omit the key when neither side set it. `_setOptions` copies this object + // onto `step.options`, and an empty `autoUpdateOptions` would show up on + // every step that never opted in. + if (tourOptions.autoUpdateOptions || options.autoUpdateOptions) { + config.autoUpdateOptions = deepmerge( + tourOptions.autoUpdateOptions || {}, + options.autoUpdateOptions || {} + ); + } + + return config; } /** diff --git a/shepherd.js/test/unit/utils/floating-ui.spec.js b/shepherd.js/test/unit/utils/floating-ui.spec.js index bb7ab6897..a512a1088 100644 --- a/shepherd.js/test/unit/utils/floating-ui.spec.js +++ b/shepherd.js/test/unit/utils/floating-ui.spec.js @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const floatingUIMock = vi.hoisted(() => ({ - autoUpdate: vi.fn(), + autoUpdate: vi.fn(() => vi.fn()), computePosition: vi.fn(), updateCallbacks: [] })); @@ -16,10 +16,11 @@ vi.mock('@floating-ui/dom', async (importOriginal) => { }; }); -import { arrow, offset, shift } from '@floating-ui/dom'; +import { arrow, autoUpdate, offset, shift } from '@floating-ui/dom'; import { Step } from '../../../src/step'; import { getFloatingUIOptions, + mergeTooltipConfig, setupTooltip } from '../../../src/utils/floating-ui'; @@ -204,6 +205,69 @@ describe('Floating UI Utils', function () { }); }); + describe('autoUpdateOptions', function () { + beforeEach(() => { + autoUpdate.mockReset(); + autoUpdate.mockImplementation(() => vi.fn()); + }); + + it('forwards `autoUpdateOptions` to `autoUpdate`', function () { + const step = createStep({ + attachTo: { element: '.floating-ui-test', on: 'right' }, + autoUpdateOptions: { layoutShift: false } + }); + + setupTooltip(step); + + expect(autoUpdate).toHaveBeenCalledTimes(1); + expect(autoUpdate).toHaveBeenCalledWith( + targetElement, + stepElement, + expect.any(Function), + { layoutShift: false } + ); + }); + + it('applies `autoUpdateOptions` from `defaultStepOptions`, overridable per step', function () { + const tour = { + options: { + defaultStepOptions: { + autoUpdateOptions: { layoutShift: false, elementResize: false } + } + } + }; + const step = new Step(tour, { + arrow: true, + attachTo: { element: '.floating-ui-test', on: 'right' }, + autoUpdateOptions: { elementResize: true } + }); + step.el = stepElement; + + setupTooltip(step); + + expect(autoUpdate).toHaveBeenCalledWith( + targetElement, + stepElement, + expect.any(Function), + { layoutShift: false, elementResize: true } + ); + }); + }); + + describe('mergeTooltipConfig()', function () { + it('deep merges `autoUpdateOptions` from tour and step options', function () { + const { autoUpdateOptions } = mergeTooltipConfig( + { autoUpdateOptions: { layoutShift: false, ancestorScroll: false } }, + { autoUpdateOptions: { ancestorScroll: true } } + ); + + expect(autoUpdateOptions).toEqual({ + layoutShift: false, + ancestorScroll: true + }); + }); + }); + describe('setupTooltip()', function () { beforeEach(() => { vi.useFakeTimers();