diff --git a/docs-src/src/content/docs/guides/usage.md b/docs-src/src/content/docs/guides/usage.md index 43c18852b..9ea3effb3 100644 --- a/docs-src/src/content/docs/guides/usage.md +++ b/docs-src/src/content/docs/guides/usage.md @@ -310,7 +310,12 @@ function will be called in the `before-show` phase. modal overlay opening. It can be either a number or an object with properties `{ topLeft, bottomLeft, bottomRight, topRight }` - `floatingUIOptions`: Extra options to pass to - [Floating UI](https://floating-ui.com/docs/getting-started) + [Floating UI](https://floating-ui.com/docs/getting-started). This includes + `strategy`, which sets the CSS `position` of the step element and defaults to + `'absolute'`. It can be set per-step or on `defaultStepOptions`. See + [Floating UI's `strategy` documentation](https://floating-ui.com/docs/computePosition#strategy) + for when `'fixed'` is the better choice. Note that steps without an `attachTo` + element are always centered with `position: fixed` and ignore `strategy`. - `showOn`: A function that, when it returns true, will show the step. If it returns false, the step will be skipped. - `skipMissingElement`: A boolean. When true, a step whose `attachTo.element` diff --git a/docs-src/src/content/docs/recipes/cookbook.md b/docs-src/src/content/docs/recipes/cookbook.md index b43c646a4..b4f80bd33 100644 --- a/docs-src/src/content/docs/recipes/cookbook.md +++ b/docs-src/src/content/docs/recipes/cookbook.md @@ -54,6 +54,51 @@ const tour = new Shepherd.Tour({ }); ``` +### Positioning strategy + +Steps are positioned with `position: absolute` by default. Shepherd repositions +them through Floating UI's `autoUpdate`, so the default already keeps a step +locked to its target while the page — or any scrolling ancestor of the target — +scrolls. + +If you need the step element to be `position: fixed` instead, set the Floating +UI `strategy`. Floating UI recommends this when the target itself is +`position: fixed`, or to escape a clipping ancestor; see +[its `strategy` documentation](https://floating-ui.com/docs/computePosition#strategy) +for the trade-offs. + +For example: + +```js +const tour = new Shepherd.Tour({ + steps: [ + { + ... + floatingUIOptions: { + strategy: 'fixed' + } + ... + } + ] +}); +``` + +You can also set this once for every step via `defaultStepOptions`: + +```js +const tour = new Shepherd.Tour({ + defaultStepOptions: { + floatingUIOptions: { + strategy: 'fixed' + } + } +}); +``` + +Centered steps are always positioned in the viewport with `position: fixed`, so +`strategy` has no effect on them. A step is centered when it has no `attachTo` +at all, or when its `attachTo` is missing either `element` or `on`. + ### Progress Indicator Using the already exposed API, you could add a progress indicator of your choosing diff --git a/shepherd.js/src/step.ts b/shepherd.js/src/step.ts index c8804a265..e88fa0ef4 100644 --- a/shepherd.js/src/step.ts +++ b/shepherd.js/src/step.ts @@ -158,6 +158,12 @@ export interface StepOptions { /** * Extra [options to pass to FloatingUI]{@link https://floating-ui.com/docs/tutorial/} + * + * This includes `strategy`, the CSS `position` used for the step element, + * which defaults to `'absolute'`. Centered steps are always `position: fixed` + * and ignore `strategy` -- a step counts as centered when it has no + * `attachTo` at all, or when its `attachTo` is missing either `element` or + * `on`. */ floatingUIOptions?: ComputePositionConfig; diff --git a/shepherd.js/src/utils/floating-ui.ts b/shepherd.js/src/utils/floating-ui.ts index acec7b78f..3776e0814 100644 --- a/shepherd.js/src/utils/floating-ui.ts +++ b/shepherd.js/src/utils/floating-ui.ts @@ -11,7 +11,8 @@ import { type ComputePositionConfig, type MiddlewareData, type Placement, - type Alignment + type Alignment, + type Strategy } from '@floating-ui/dom'; import type { Step, StepOptions, StepOptionsAttachTo } from '../step.ts'; import { isHTMLElement } from './type-check.ts'; @@ -116,11 +117,13 @@ function floatingUIposition(step: Step, shouldCenter: boolean) { return ({ x, y, + strategy, placement, middlewareData }: { x: number; y: number; + strategy: Strategy; placement: Placement; middlewareData: MiddlewareData; }) => { @@ -129,6 +132,15 @@ function floatingUIposition(step: Step, shouldCenter: boolean) { } if (shouldCenter) { + // `position: fixed` is intentional here and must NOT follow `strategy`. + // Centering relies on `left`/`top: 50%` plus a `translate(-50%, -50%)`, + // and those percentages have to resolve against the viewport. Under the + // default `absolute` strategy they would resolve against the document + // instead, placing the step at 50% of the *page* height so it scrolls + // off screen. A step centers when it has no `attachTo`, or when its + // `attachTo` is missing either `element` or `on` (see `shouldCenterStep`); + // all of those are modal dialogs, so viewport centering is the correct + // behavior regardless of `strategy`. Object.assign(step.el.style, { position: 'fixed', left: '50%', @@ -137,7 +149,7 @@ function floatingUIposition(step: Step, shouldCenter: boolean) { }); } else { Object.assign(step.el.style, { - position: 'absolute', + position: strategy, left: `${x}px`, top: `${y}px` }); diff --git a/shepherd.js/test/cypress/examples/positioning-strategy.html b/shepherd.js/test/cypress/examples/positioning-strategy.html new file mode 100644 index 000000000..f86c697a3 --- /dev/null +++ b/shepherd.js/test/cypress/examples/positioning-strategy.html @@ -0,0 +1,57 @@ + + + + + + + + + + +
Page target
+ +
+
+
Overflow target
+
+
+ + diff --git a/shepherd.js/test/cypress/integration/positioning-strategy.cy.js b/shepherd.js/test/cypress/integration/positioning-strategy.cy.js new file mode 100644 index 000000000..94d717ac4 --- /dev/null +++ b/shepherd.js/test/cypress/integration/positioning-strategy.cy.js @@ -0,0 +1,150 @@ +import setupTour from '../utils/setup-tour'; + +// End-to-end guard for #3269. Unit tests run in happy-dom, which has no layout +// engine, so this is the only place where a step that drifts away from its +// target while scrolling actually fails a test. +describe('positioning strategy', () => { + let Shepherd; + + beforeEach(() => { + Shepherd = null; + + cy.visit('/test/cypress/examples/positioning-strategy', { + onLoad(contentWindow) { + if (contentWindow.Shepherd) { + return (Shepherd = contentWindow.Shepherd); + } + } + }); + }); + + /** + * Vector from the target to the step, in viewport coordinates. If the step + * tracks its target, this vector is identical at every scroll offset. + */ + const offsetFromTarget = (targetSelector, stepId) => { + return cy.get(targetSelector).then(($target) => { + return cy.get(`[data-shepherd-step-id="${stepId}"]`).then(($step) => { + const target = $target[0].getBoundingClientRect(); + const step = $step[0].getBoundingClientRect(); + + return { + dx: Math.round(step.left - target.left), + dy: Math.round(step.top - target.top) + }; + }); + }); + }; + + const startTour = (floatingUIOptions) => { + const tour = setupTour(Shepherd, { scrollTo: false }, () => [ + { + attachTo: { element: '.page-target', on: 'bottom' }, + id: 'strategy', + title: 'Strategy step', + text: 'positioned against a target the page scrolls past', + floatingUIOptions + } + ]); + + tour.start(); + cy.wait(250); + + return tour; + }; + + it('keeps a `fixed` strategy step locked to its target while the page scrolls', () => { + startTour({ strategy: 'fixed' }); + + cy.scrollTo(0, 700); + cy.wait(250); + + cy.get('[data-shepherd-step-id="strategy"]').should( + 'have.css', + 'position', + 'fixed' + ); + + let before; + + offsetFromTarget('.page-target', 'strategy') + .then((offset) => { + before = offset; + + cy.scrollTo(0, 800); + cy.wait(250); + + return offsetFromTarget('.page-target', 'strategy'); + }) + .then((after) => { + // Before the fix, the step element was hardcoded to + // `position: absolute` while `computePosition` returned + // viewport-relative coordinates, so `after.dy` was `before.dy - 100` — + // exactly the scroll delta. + expect(after).to.deep.equal(before); + }); + }); + + it('keeps a default strategy step locked to its target while the page scrolls', () => { + startTour(undefined); + + cy.scrollTo(0, 700); + cy.wait(250); + + cy.get('[data-shepherd-step-id="strategy"]').should( + 'have.css', + 'position', + 'absolute' + ); + + let before; + + offsetFromTarget('.page-target', 'strategy') + .then((offset) => { + before = offset; + + cy.scrollTo(0, 800); + cy.wait(250); + + return offsetFromTarget('.page-target', 'strategy'); + }) + .then((after) => { + expect(after).to.deep.equal(before); + }); + }); + + it('tracks a target inside a scrolling container under the default strategy', () => { + const tour = setupTour(Shepherd, { scrollTo: false }, () => [ + { + attachTo: { element: '.overflow-target', on: 'bottom' }, + id: 'overflow', + title: 'Overflow step', + text: 'positioned against a target inside an overflow container' + } + ]); + + tour.start(); + + cy.get('.overflow-container').scrollTo(0, 500); + cy.wait(250); + + let before; + + offsetFromTarget('.overflow-target', 'overflow') + .then((offset) => { + before = offset; + + cy.get('.overflow-container').scrollTo(0, 600); + cy.wait(250); + + return offsetFromTarget('.overflow-target', 'overflow'); + }) + .then((after) => { + // `autoUpdate` recomputes on ancestor scroll, so the default + // `absolute` strategy already follows a target inside an `overflow` + // container. This is why the docs do not recommend `fixed` for that + // case. + expect(after).to.deep.equal(before); + }); + }); +}); diff --git a/shepherd.js/test/unit/tour.spec.js b/shepherd.js/test/unit/tour.spec.js index 4babff03f..94bf75a1d 100644 --- a/shepherd.js/test/unit/tour.spec.js +++ b/shepherd.js/test/unit/tour.spec.js @@ -804,6 +804,91 @@ describe('Tour | Top-Level Class', function () { ); expect(step3PlacementMiddleware.options.alignment).toBe('end'); }); + + describe('strategy', () => { + let target; + + beforeEach(() => { + target = document.createElement('div'); + target.classList.add('strategy-test'); + document.body.appendChild(target); + }); + + afterEach(() => { + document.body.removeChild(target); + }); + + it('writes the default `absolute` strategy to an attached step', async () => { + instance = new Shepherd.Tour(); + + const step = instance.addStep({ + id: 'test', + title: 'This is a test step for our tour', + attachTo: { element: '.strategy-test', on: 'top' } + }); + + instance.start(); + + await vi.waitFor(() => expect(step.el.style.position).toBe('absolute')); + }); + + it('honors `strategy: fixed` from step floatingUIOptions', async () => { + instance = new Shepherd.Tour(); + + const step = instance.addStep({ + id: 'test', + title: 'This is a test step for our tour', + attachTo: { element: '.strategy-test', on: 'top' }, + floatingUIOptions: { strategy: 'fixed' } + }); + + instance.start(); + + await vi.waitFor(() => expect(step.el.style.position).toBe('fixed')); + }); + + it('honors `strategy: fixed` from defaultStepOptions floatingUIOptions', async () => { + instance = new Shepherd.Tour({ + defaultStepOptions: { + floatingUIOptions: { strategy: 'fixed' } + } + }); + + const step = instance.addStep({ + id: 'test', + title: 'This is a test step for our tour', + attachTo: { element: '.strategy-test', on: 'top' } + }); + + instance.start(); + + await vi.waitFor(() => expect(step.el.style.position).toBe('fixed')); + }); + + // Centered steps are modal dialogs positioned with `left`/`top: 50%` and + // a `translate(-50%, -50%)`. Those percentages must resolve against the + // viewport, so the centered branch stays `fixed` no matter what strategy + // is configured. This test locks that in deliberately. + it.each([undefined, 'absolute', 'fixed'])( + 'always centers with `position: fixed` when strategy is %s', + async (strategy) => { + instance = new Shepherd.Tour(); + + const step = instance.addStep({ + id: 'test', + title: 'This is a test step for our tour', + floatingUIOptions: strategy ? { strategy } : {} + }); + + instance.start(); + + await vi.waitFor(() => expect(step.el.style.position).toBe('fixed')); + + expect(step.el.style.left).toBe('50%'); + expect(step.el.style.top).toBe('50%'); + } + ); + }); }); describe('shepherdModalOverlayContainer', function () { diff --git a/shepherd.js/test/unit/utils/floating-ui-position.spec.js b/shepherd.js/test/unit/utils/floating-ui-position.spec.js new file mode 100644 index 000000000..9e785bbbb --- /dev/null +++ b/shepherd.js/test/unit/utils/floating-ui-position.spec.js @@ -0,0 +1,186 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import ResizeObserver from 'resize-observer-polyfill'; + +// happy-dom has no layout engine: every `getBoundingClientRect()` is 0x0 at the +// origin, so a real `computePosition` call always resolves `{ x: 0, y: 0 }` and +// no assertion about coordinates can fail. These tests therefore stub the +// Floating UI boundary and assert the one thing Shepherd actually owns: what it +// writes to `step.el.style` for a given resolved position payload. +const floatingUI = vi.hoisted(() => ({ + // Overwritten per test to control exactly what the positioning callback gets. + position: { + x: 0, + y: 0, + strategy: 'absolute', + placement: 'top', + middlewareData: {} + } +})); + +vi.mock('@floating-ui/dom', async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + computePosition: vi.fn(() => Promise.resolve({ ...floatingUI.position })), + // Run the position callback once, synchronously, rather than wiring up the + // scroll/resize observers that happy-dom cannot drive. + autoUpdate: vi.fn((_target, _element, update) => { + update(); + return () => {}; + }) + }; +}); + +const Shepherd = (await import('../../../src/shepherd')).default; +const { destroyTooltip, setupTooltip } = + await import('../../../src/utils/floating-ui'); + +window.Shepherd = Shepherd; +window.ResizeObserver = ResizeObserver; + +describe('utils/floating-ui | position writing', function () { + let instance, target; + + beforeEach(() => { + target = document.createElement('div'); + target.classList.add('position-test'); + document.body.appendChild(target); + }); + + afterEach(() => { + instance?.complete(); + target.remove(); + window.scrollTo(0, 0); + floatingUI.position = { + x: 0, + y: 0, + strategy: 'absolute', + placement: 'top', + middlewareData: {} + }; + }); + + /** + * Re-runs positioning with a caller-supplied Floating UI payload and waits + * for it to land on the element. The payload is swapped in *after* + * `tour.start()` has already positioned the step once, so an assertion on + * these values can only be satisfied by this `setupTooltip` call. + */ + async function positionWith(step, position) { + floatingUI.position = { ...floatingUI.position, ...position }; + + setupTooltip(step); + + await vi.waitFor(() => + expect(step.el.dataset['popperPlacement']).toBe( + floatingUI.position.placement + ) + ); + } + + it('writes the resolved coordinates to the step element', async () => { + instance = new Shepherd.Tour(); + + const step = instance.addStep({ + id: 'test', + attachTo: { element: '.position-test', on: 'top' } + }); + + instance.start(); + + await positionWith(step, { x: 13, y: 42, placement: 'bottom' }); + + // `x` is horizontal and `y` is vertical. Transposing them puts every step + // on the wrong side of its target. + expect(step.el.style.left).toBe('13px'); + expect(step.el.style.top).toBe('42px'); + + destroyTooltip(step); + }); + + it('writes coordinates unmodified while the page is scrolled', async () => { + instance = new Shepherd.Tour(); + + const step = instance.addStep({ + id: 'test', + attachTo: { element: '.position-test', on: 'top' }, + floatingUIOptions: { strategy: 'fixed' } + }); + + instance.start(); + + window.scrollTo(120, 340); + + await positionWith(step, { + x: 13, + y: 42, + strategy: 'fixed', + placement: 'bottom' + }); + + // This is the shape of #3269: Floating UI's payload is already in the + // coordinate space its own `strategy` implies, so adding (or subtracting) + // the scroll offset here re-separates the step from its target by exactly + // the scroll delta. + expect(step.el.style.left).toBe('13px'); + expect(step.el.style.top).toBe('42px'); + expect(step.el.style.position).toBe('fixed'); + + destroyTooltip(step); + }); + + it('takes the strategy from the resolved payload rather than the step options', async () => { + instance = new Shepherd.Tour(); + + // No `floatingUIOptions` on the step at all. The CSS `position` still has + // to follow what `computePosition` reports it used, because that payload — + // not the raw user option — is what the returned coordinates are relative + // to. Reading `step.options.floatingUIOptions.strategy` instead would write + // `absolute` here and reintroduce the coordinate-space mismatch. + const step = instance.addStep({ + id: 'test', + attachTo: { element: '.position-test', on: 'top' } + }); + + instance.start(); + + expect(step.options.floatingUIOptions?.strategy).toBeUndefined(); + + await positionWith(step, { + x: 13, + y: 42, + strategy: 'fixed', + placement: 'bottom' + }); + + expect(step.el.style.position).toBe('fixed'); + + destroyTooltip(step); + }); + + it('keeps centered steps viewport-centered whatever the payload says', async () => { + instance = new Shepherd.Tour(); + + // No `attachTo`, so this step is centered. Centering uses `left`/`top: 50%` + // with a `translate(-50%, -50%)`, and those percentages have to resolve + // against the viewport, so this branch must not follow the strategy. + const step = instance.addStep({ id: 'test' }); + + instance.start(); + + await positionWith(step, { + x: 13, + y: 42, + strategy: 'absolute', + placement: 'bottom' + }); + + expect(step.el.style.position).toBe('fixed'); + expect(step.el.style.left).toBe('50%'); + expect(step.el.style.top).toBe('50%'); + expect(step.el.style.transform).toBe('translate(-50%, -50%)'); + + destroyTooltip(step); + }); +});