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: 7 additions & 0 deletions docs-src/src/content/docs/guides/styling.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,10 @@ const tour = new Shepherd.Tour({
classPrefix: 'my-tour-'
});
```

`classPrefix` applies to exactly two things: the `shepherd-enabled` and `shepherd-target` classes Shepherd puts on
the **target** element (and on any `extraHighlights` elements), and the `data-shepherd-step-id` attribute on the popup.
Everything else keeps its unprefixed name — including the `shepherd-enabled` class on the popup element itself,
and the `shepherd-target-click-disabled` class that `canClickTarget: false` adds to the target. `shepherd.css` keys
rules on those two, and a static stylesheet cannot know your runtime prefix, so prefixing them would break the rules
they drive.
10 changes: 8 additions & 2 deletions docs-src/src/content/docs/guides/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,11 @@ const myTour = new Shepherd.Tour(options);
##### Tour Options

- `classPrefix`: The prefix to add to the `shepherd-enabled` and
`shepherd-target` class names as well as the `data-shepherd-step-id`.
`shepherd-target` class names Shepherd puts on the **target** element, as well
as to the `data-shepherd-step-id` attribute. Nothing else is prefixed: the
popup keeps its own unprefixed `shepherd-enabled` class, and
`shepherd-target-click-disabled` stays unprefixed too, because the shipped
stylesheet keys click blocking on it and cannot know your runtime prefix.
- `confirmCancel`:
- If true, will issue a `window.confirm` before cancelling
- If it is a function(support Async Function), it will be called and wait for
Expand Down Expand Up @@ -241,7 +245,9 @@ function will be called in the `before-show` phase.
},
```
- `canClickTarget` A boolean, that when set to false, will set
`pointer-events: none` on the target
`pointer-events: none` on the target. The blocking is delivered by
`shepherd.css`, so it has no effect if you have opted out of Shepherd's
stylesheet without providing an equivalent rule.
- `cancelIcon` Options for the cancel icon
- `attrs` Additional HTML attributes to apply to the cancel icon button
element. This is useful for adding data attributes for testing or analytics.
Expand Down
2 changes: 1 addition & 1 deletion landing/src/styles/shepherd.css

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

20 changes: 18 additions & 2 deletions shepherd.js/src/components/shepherd-element.css
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,23 @@
background-color: #e6e6e6;
}

.shepherd-target-click-disabled.shepherd-enabled.shepherd-target,
.shepherd-target-click-disabled.shepherd-enabled.shepherd-target * {
/**
* Blocks clicks on the target of a step with `canClickTarget: false`.
*
* Keyed only on `shepherd-target-click-disabled`, which
* `Step#_styleTargetElementForStep` adds unprefixed, and only when
* `canClickTarget === false`. The sibling
* `shepherd-enabled`/`shepherd-target` classes cannot be part of this selector:
* `classPrefix` prefixes them at runtime and a static stylesheet cannot know
* the prefix, so requiring them silently disabled click blocking for every tour
* using `classPrefix` (#1298).
*
* The class is repeated three times on purpose, to preserve this selector's
* original 0-3-0 specificity so that existing overrides keep winning. Do not
* "clean this up" to a single class without reading #1298 first.
*/
.shepherd-target-click-disabled.shepherd-target-click-disabled.shepherd-target-click-disabled,
.shepherd-target-click-disabled.shepherd-target-click-disabled.shepherd-target-click-disabled
* {
pointer-events: none;
}
4 changes: 4 additions & 0 deletions shepherd.js/src/step.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,10 @@ export interface StepOptions {

/**
* A boolean, that when set to false, will set `pointer-events: none` on the target.
*
* The blocking is delivered by `shepherd.css` (via the `shepherd-target-click-disabled`
* class), so it has no effect if you have opted out of Shepherd's stylesheet without
* providing an equivalent rule.
*/
canClickTarget?: boolean;

Expand Down
4 changes: 4 additions & 0 deletions shepherd.js/src/tour.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ export interface TourOptions {
confirmCancelMessage?: string;
/**
* The prefix to add to the `shepherd-enabled` and `shepherd-target` class names as well as the `data-shepherd-step-id`.
*
* Only those apply. The popup keeps its own unprefixed `shepherd-enabled` class, and
* `shepherd-target-click-disabled` is never prefixed either, since `shepherd.css` keys
* click blocking on it and cannot know the runtime prefix.
*/
classPrefix?: string;
/**
Expand Down
27 changes: 26 additions & 1 deletion shepherd.js/test/cypress/dummy/css/welcome.css
Original file line number Diff line number Diff line change
Expand Up @@ -120,4 +120,29 @@ pre {
.shepherd-text a:visited:hover,
.shepherd-text a:active:hover {
border-bottom-style: solid;
}
}

/*
* Fixtures for the `canClickTarget: false` click-blocking tests (#1298).
*
* These rules deliberately COMPETE with the click-blocking rule in
* shepherd.css, so that the cypress assertions exercise the shipped selector
* rather than passing for free. This file is linked after shepherd.css, so at
* equal specificity these win.
*
* - `.click-block-fixture.click-block-fixture-override` is 0-2-0 on the target.
* It loses to the click-blocking rule only while that rule holds its 0-3-0
* specificity. Collapse the repeated class in shepherd-element.css and the
* target computes `auto` again.
* - `.click-block-fixture-child` gives the child its own `pointer-events`
* value, so the child cannot simply inherit `none` from the target. It loses
* only to the descendant (`... *`) half of the click-blocking rule. Delete
* that half and the child computes `auto` again.
*/
.click-block-fixture.click-block-fixture-override {
pointer-events: auto;
}

.click-block-fixture-child {
pointer-events: auto;
}
17 changes: 17 additions & 0 deletions shepherd.js/test/cypress/dummy/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,23 @@ <h3>Example</h3>
<input id="nested-input" type="text" placeholder="Input" />
<button id="nested-button-2">Button 2</button>
</div>

<!--
Fixture for the `canClickTarget: false` click-blocking tests (#1298).
Both this element and its child carry their own competing
`pointer-events` declarations in css/welcome.css, so the assertions
in element-targeting.cy.js cannot be satisfied by inheritance or by a
weaker version of the shepherd.css selector. See the comment on those
rules for what each one pins.
-->
<div
class="click-block-fixture click-block-fixture-override"
data-test-click-block-target
>
<span class="click-block-fixture-child" data-test-click-block-child
>Click blocking fixture</span
>
</div>
</div>
</div>

Expand Down
83 changes: 83 additions & 0 deletions shepherd.js/test/cypress/integration/element-targeting.cy.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,89 @@ describe('Attaching tooltips to target elements in the DOM on each step', () =>
});
});

describe('Blocking clicks on the target with `canClickTarget: false`', () => {
let tour;

// A single step attached to the click-blocking fixture in the dummy page.
//
// That fixture is used instead of `.hero-welcome` on purpose. `pointer-events`
// is an inherited property, so a child of a blocked target computes `none`
// even with the descendant half of the rule deleted; and nothing on the page
// competes with the rule's specificity. The fixture and its child each carry
// their own competing `pointer-events: auto` declaration (see
// dummy/css/welcome.css), so these assertions fail if the shipped rule loses
// either its `... *` half or its 0-3-0 specificity.
const clickBlockStep = (shepherd) => [
{
id: 'click-block',
text: 'Click blocking fixture step',
attachTo: {
element: '[data-test-click-block-target]',
on: 'top'
},
buttons: [
{
action: shepherd.cancel,
text: 'Exit'
}
]
}
];

afterEach(() => {
tour.complete();
});

it('leaves the target and its children clickable when `canClickTarget` is not set', () => {
tour = setupTour(Shepherd, {}, clickBlockStep);
tour.start();

cy.get('[data-test-click-block-target]')
.should('have.class', 'shepherd-target')
.and('have.css', 'pointer-events', 'auto');
cy.get('[data-test-click-block-child]').should(
'have.css',
'pointer-events',
'auto'
);
});

it('blocks clicks on the target and its children when no classPrefix is set', () => {
tour = setupTour(Shepherd, { canClickTarget: false }, clickBlockStep);
tour.start();

cy.get('[data-test-click-block-target]').should(
'have.css',
'pointer-events',
'none'
);
cy.get('[data-test-click-block-child]').should(
'have.css',
'pointer-events',
'none'
);
});

// Regression test for #1298: `classPrefix` prefixes the `shepherd-enabled`
// and `shepherd-target` classes, which the click-blocking rule used to
// require, so `canClickTarget: false` silently did nothing.
it('blocks clicks on the target and its children when a classPrefix is set', () => {
tour = setupTour(Shepherd, { canClickTarget: false }, clickBlockStep, {
classPrefix: 'my-tour-'
});
tour.start();

cy.get('[data-test-click-block-target]')
.should('have.class', 'my-tour-shepherd-target')
.and('have.css', 'pointer-events', 'none');
cy.get('[data-test-click-block-child]').should(
'have.css',
'pointer-events',
'none'
);
});
});

describe('Unique selectors with multiple Tours', function () {
let firstTour, secondTour;

Expand Down
156 changes: 156 additions & 0 deletions shepherd.js/test/unit/step.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -663,6 +663,162 @@ describe('Tour | Step', () => {
});
});

/**
* The click-blocking CSS rule keys solely on the unprefixed
* `shepherd-target-click-disabled` class, because `classPrefix` prefixes the
* sibling `shepherd-enabled`/`shepherd-target` classes at runtime and a
* static stylesheet cannot know the prefix (#1298). These tests pin which
* elements carry that class and when.
*
* They deliberately do not assert computed `pointer-events`: this suite runs
* in happy-dom and never loads `shepherd.css`, so such an assertion would
* pass vacuously. The cascade half of the fix — the `... *` descendant
* clause and the 0-3-0 specificity — is pinned in
* `test/cypress/integration/element-targeting.cy.js` against a fixture that
* competes with both.
*/
describe('canClickTarget with classPrefix', () => {
const CLICK_DISABLED = 'shepherd-target-click-disabled';
let instance, targetElem, extraElem;

function buildTour(stepOptions) {
instance = new Shepherd.Tour({ classPrefix: 'my-tour-' });
instance.addStep({
id: 'test',
text: 'This is a step for testing',
attachTo: { element: '.click-disabled-target', on: 'top' },
...stepOptions
});
return instance;
}

beforeEach(() => {
targetElem = document.createElement('div');
targetElem.classList.add('click-disabled-target');
document.body.appendChild(targetElem);

extraElem = document.createElement('div');
extraElem.classList.add('click-disabled-extra');
document.body.appendChild(extraElem);
});

afterEach(() => {
instance?.complete();
instance = null;
targetElem.remove();
extraElem.remove();
});

it('adds `shepherd-target-click-disabled` unprefixed, alongside the prefixed classes', () => {
buildTour({ canClickTarget: false }).start();

expect(targetElem.classList.contains(CLICK_DISABLED)).toBe(true);
expect(targetElem.classList.contains('my-tour-shepherd-enabled')).toBe(
true
);
expect(targetElem.classList.contains('my-tour-shepherd-target')).toBe(
true
);
});

it('removes all three classes on `hide()`', () => {
buildTour({ canClickTarget: false }).start();
instance.getCurrentStep().hide();

expect(targetElem.classList.contains(CLICK_DISABLED)).toBe(false);
expect(targetElem.classList.contains('my-tour-shepherd-enabled')).toBe(
false
);
expect(targetElem.classList.contains('my-tour-shepherd-target')).toBe(
false
);
});

it('removes all three classes on `destroy()`', () => {
buildTour({ canClickTarget: false }).start();
instance.getCurrentStep().destroy();

expect(targetElem.classList.contains(CLICK_DISABLED)).toBe(false);
expect(targetElem.classList.contains('my-tour-shepherd-enabled')).toBe(
false
);
expect(targetElem.classList.contains('my-tour-shepherd-target')).toBe(
false
);
});

// The class now carries the whole meaning of the CSS rule, so it must not
// appear on targets that never opted in: any element wearing it is
// unclickable.
it('does not add `shepherd-target-click-disabled` when `canClickTarget` is unset', () => {
buildTour({}).start();

expect(targetElem.classList.contains('my-tour-shepherd-target')).toBe(
true
);
expect(targetElem.classList.contains(CLICK_DISABLED)).toBe(false);
});

it('does not add `shepherd-target-click-disabled` when `canClickTarget` is true', () => {
buildTour({ canClickTarget: true }).start();

expect(targetElem.classList.contains('my-tour-shepherd-target')).toBe(
true
);
expect(targetElem.classList.contains(CLICK_DISABLED)).toBe(false);
});

it('adds `shepherd-target-click-disabled` to `extraHighlights` elements too', () => {
buildTour({
canClickTarget: false,
extraHighlights: ['.click-disabled-extra']
}).start();

expect(extraElem.classList.contains(CLICK_DISABLED)).toBe(true);
expect(extraElem.classList.contains('my-tour-shepherd-target')).toBe(
true
);
});

it('does not add `shepherd-target-click-disabled` to `extraHighlights` elements when `canClickTarget` is unset', () => {
buildTour({ extraHighlights: ['.click-disabled-extra'] }).start();

expect(extraElem.classList.contains('my-tour-shepherd-target')).toBe(
true
);
expect(extraElem.classList.contains(CLICK_DISABLED)).toBe(false);
});

it('removes `shepherd-target-click-disabled` from `extraHighlights` elements on `hide()`', () => {
buildTour({
canClickTarget: false,
extraHighlights: ['.click-disabled-extra']
}).start();
expect(extraElem.classList.contains(CLICK_DISABLED)).toBe(true);

instance.getCurrentStep().hide();

expect(extraElem.classList.contains(CLICK_DISABLED)).toBe(false);
expect(extraElem.classList.contains('my-tour-shepherd-target')).toBe(
false
);
});

it('removes `shepherd-target-click-disabled` from `extraHighlights` elements when the tour completes', () => {
buildTour({
canClickTarget: false,
extraHighlights: ['.click-disabled-extra']
}).start();
expect(extraElem.classList.contains(CLICK_DISABLED)).toBe(true);

instance.complete();
instance = null;

expect(extraElem.classList.contains(CLICK_DISABLED)).toBe(false);
expect(targetElem.classList.contains(CLICK_DISABLED)).toBe(false);
});
});

describe('lazy attachTo evaluation', () => {
// We test this using attachTo.element callback.
// Note that lazy evaluation largely relies on `parseAttachTo`, however this does
Expand Down
Loading