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
7 changes: 6 additions & 1 deletion docs-src/src/content/docs/guides/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
45 changes: 45 additions & 0 deletions docs-src/src/content/docs/recipes/cookbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions shepherd.js/src/step.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
16 changes: 14 additions & 2 deletions shepherd.js/src/utils/floating-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
}) => {
Expand All @@ -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%',
Expand All @@ -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`
});
Expand Down
57 changes: 57 additions & 0 deletions shepherd.js/test/cypress/examples/positioning-strategy.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<!doctype html>
<html>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
<head>
<link rel="stylesheet" href="../../../dist/css/shepherd.css" />
<script type="module">
import Shepherd from '../../../dist/js/shepherd.mjs';
window.Shepherd = Shepherd;
</script>
</head>

<body>
<style>
body {
margin: 0;
/* Tall enough that the window can scroll with the target on screen. */
height: 3000px;
}

.page-target {
position: absolute;
top: 1200px;
left: 400px;
padding: 20px;
background: red;
}

.overflow-container {
position: absolute;
top: 200px;
left: 400px;
width: 400px;
height: 300px;
overflow: auto;
background: #eee;
}

.overflow-content {
height: 1200px;
}

.overflow-target {
margin: 600px 0 0 40px;
padding: 20px;
width: 120px;
background: blue;
}
</style>

<div class="page-target">Page target</div>

<div class="overflow-container">
<div class="overflow-content">
<div class="overflow-target">Overflow target</div>
</div>
</div>
</body>
</html>
150 changes: 150 additions & 0 deletions shepherd.js/test/cypress/integration/positioning-strategy.cy.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
Loading
Loading