Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 34 additions & 2 deletions shepherd.js/src/step.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -607,7 +631,15 @@ export class Step extends Evented {
* @param {StepOptions} options The options for the step
*/
updateStepOptions(options: StepOptions) {
Object.assign(this.options, options);
const updatedOptions = options.autoUpdateOptions
? {
...options,
autoUpdateOptions: mergeTooltipConfig(this.options, options)
.autoUpdateOptions
}
: options;

Object.assign(this.options, updatedOptions);

if (this.shepherdElementComponent) {
// Recreate the element with updated options
Expand Down
53 changes: 38 additions & 15 deletions shepherd.js/src/utils/floating-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
autoPlacement,
limitShift,
shift,
type AutoUpdateOptions,
type ComputePositionConfig,
type Middleware,
type MiddlewareData,
Expand Down Expand Up @@ -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;

Expand All @@ -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 || {}
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

return config;
}

/**
Expand Down
104 changes: 102 additions & 2 deletions shepherd.js/test/unit/utils/floating-ui.spec.js
Original file line number Diff line number Diff line change
@@ -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: []
}));
Expand All @@ -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';

Expand Down Expand Up @@ -204,6 +205,105 @@ 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('keeps tour `autoUpdateOptions` when a mounted step is updated', function () {
const tour = {
modal: { setupForStep() {} },
options: {
defaultStepOptions: {
autoUpdateOptions: { layoutShift: false }
}
}
};
const step = new Step(tour, {
arrow: true,
attachTo: { element: '.floating-ui-test', on: 'bottom' },
text: 'body'
});

step.show();
autoUpdate.mockClear();

step.updateStepOptions({
autoUpdateOptions: { elementResize: true }
});

expect(step.options.autoUpdateOptions).toEqual({
layoutShift: false,
elementResize: true
});
expect(autoUpdate).toHaveBeenCalledWith(
targetElement,
expect.any(HTMLElement),
expect.any(Function),
{ layoutShift: false, elementResize: true }
);

step.destroy();
});

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();
Expand Down