Skip to content

Commit 0897822

Browse files
Implement multiple element highlighting feature (#2995)
* Implement multiple element highlighting feature * Fix overlay path string formatting * Sync shepherd classes with extra highlight elements * Sync highlight classes option with extra highlight elements * Add tests for extra highligt elements * Revert changes in landing page * Skip elements to highlight if it's contained by another * Include an example in the demo and provide cookbook example * Correct the position of extraHighlights in the alphabetic order in StepOptions
1 parent 857b24b commit 0897822

File tree

11 files changed

+403
-78
lines changed

11 files changed

+403
-78
lines changed

docs-src/src/content/docs/recipes/cookbook.md

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,24 +12,24 @@ starting the tour, then `bodyScrollLock.clearAllBodyScrollLocks();` after stoppi
1212

1313
### Highlighting multiple elements
1414

15-
The most obvious use case for this, is around a group of elements, or more specifically the column in a TABLE. This can be achieved using CSS to absolutely position the element and give it the width and height you need. e.g.,
15+
Highlighting multiple elements is supported by Shepherd out of the box. You can pass an array of selectors to the `extraHighlights` option in the step configuration. This will highlight all the elements in the array as well as the target element defined in the `attachTo` option.
1616

17-
```html
18-
<colgroup class="shepherd-step-highlight"></colgroup>
19-
```
20-
21-
and setting your CSS to something like:
22-
23-
```css
24-
colgroup.shepherd-step-highlight {
25-
display: block;
26-
height: 100px;
27-
position: absolute;
28-
width: 200px;
29-
}
17+
```javascript
18+
const tour = new Shepherd.Tour({
19+
steps: [
20+
{
21+
text: 'This is a step with multiple highlights',
22+
attachTo: {
23+
element: '.target-element',
24+
on: 'bottom'
25+
},
26+
extraHighlights: ['.example-selector', '.example-selector-2']
27+
}
28+
]
29+
});
3030
```
3131

32-
Similar results could be had by adding elements purely for the purpose of exposing more than one element in the overlay and positioning the element absolutely.
32+
If an element to be highlighted is contained by another element that is also being highlighted, the contained element will not be highlighted. This is to prevent the contained element from being obscured by the containing element.
3333

3434
### Offsets
3535

landing/src/components/Header.astro

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ const { isHome } = Astro.props;
7979
{
8080
isHome && (
8181
<div class="flex flex-wrap max-w-6xl p-4 w-full lg:flex-nowrap">
82-
<div class="m-4 relative w-full lg:w-1/3">
82+
<div class="feature m-4 relative w-full lg:w-1/3">
8383
<div class="border-4 border-navy w-full">
8484
<img
8585
class="absolute a11y-icon z-20"
@@ -105,7 +105,7 @@ const { isHome } = Astro.props;
105105
</div>
106106
</div>
107107

108-
<div class="m-4 relative w-full lg:w-1/3">
108+
<div class="customizable m-4 relative w-full lg:w-1/3">
109109
<div class="border-4 border-navy w-full">
110110
<img
111111
class="absolute customizable-icon z-20"
@@ -132,7 +132,7 @@ const { isHome } = Astro.props;
132132
</div>
133133
</div>
134134

135-
<div class="m-4 relative w-full lg:w-1/3">
135+
<div class="feature m-4 relative w-full lg:w-1/3">
136136
<div class="border-4 border-navy w-full">
137137
<img
138138
class="absolute framework-icon z-20"

landing/src/pages/index.astro

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,31 @@ import MainPage from '@layouts/MainPage.astro';
142142
],
143143
id: 'welcome'
144144
},
145+
{
146+
title: 'Features',
147+
text: 'Shepherd has many built-in features to guide users through your app. You can easily customize the look and feel of your tour by adding your own styles. Also, you can highlight multiple elements at once to draw attention to key areas of your application.',
148+
attachTo: {
149+
element: '.customizable',
150+
on: 'bottom'
151+
},
152+
extraHighlights: ['.feature'],
153+
buttons: [
154+
{
155+
action() {
156+
return this.back();
157+
},
158+
secondary: true,
159+
text: 'Back'
160+
},
161+
{
162+
action() {
163+
return this.next();
164+
},
165+
text: 'Next'
166+
}
167+
],
168+
id: 'features'
169+
},
145170
{
146171
title: 'Including',
147172
text: element,

shepherd.js/src/components/shepherd-modal.svelte

Lines changed: 56 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,15 @@
1313
export const getElement = () => element;
1414
1515
export function closeModalOpening() {
16-
openingProperties = {
17-
width: 0,
18-
height: 0,
19-
x: 0,
20-
y: 0,
21-
r: 0
22-
};
16+
openingProperties = [
17+
{
18+
width: 0,
19+
height: 0,
20+
x: 0,
21+
y: 0,
22+
r: 0
23+
}
24+
];
2325
}
2426
2527
/**
@@ -47,21 +49,53 @@
4749
modalOverlayOpeningXOffset = 0,
4850
modalOverlayOpeningYOffset = 0,
4951
scrollParent,
50-
targetElement
52+
targetElement,
53+
extraHighlights
5154
) {
5255
if (targetElement) {
53-
const { y, height } = _getVisibleHeight(targetElement, scrollParent);
54-
const { x, width, left } = targetElement.getBoundingClientRect();
55-
56-
// getBoundingClientRect is not consistent. Some browsers use x and y, while others use left and top
57-
openingProperties = {
58-
width: width + modalOverlayOpeningPadding * 2,
59-
height: height + modalOverlayOpeningPadding * 2,
60-
x:
61-
(x || left) + modalOverlayOpeningXOffset - modalOverlayOpeningPadding,
62-
y: y + modalOverlayOpeningYOffset - modalOverlayOpeningPadding,
63-
r: modalOverlayOpeningRadius
64-
};
56+
const elementsToHighlight = [targetElement, ...(extraHighlights || [])];
57+
openingProperties = [];
58+
59+
for (const element of elementsToHighlight) {
60+
if (!element) continue;
61+
62+
// Skip duplicate elements
63+
if (
64+
elementsToHighlight.indexOf(element) !==
65+
elementsToHighlight.lastIndexOf(element)
66+
) {
67+
continue;
68+
}
69+
70+
const { y, height } = _getVisibleHeight(element, scrollParent);
71+
const { x, width, left } = element.getBoundingClientRect();
72+
73+
// Check if the element is contained by another element
74+
const isContained = elementsToHighlight.some((otherElement) => {
75+
if (otherElement === element) return false;
76+
const otherRect = otherElement.getBoundingClientRect();
77+
return (
78+
x >= otherRect.left &&
79+
x + width <= otherRect.right &&
80+
y >= otherRect.top &&
81+
y + height <= otherRect.bottom
82+
);
83+
});
84+
85+
if (isContained) continue;
86+
87+
// getBoundingClientRect is not consistent. Some browsers use x and y, while others use left and top
88+
openingProperties.push({
89+
width: width + modalOverlayOpeningPadding * 2,
90+
height: height + modalOverlayOpeningPadding * 2,
91+
x:
92+
(x || left) +
93+
modalOverlayOpeningXOffset -
94+
modalOverlayOpeningPadding,
95+
y: y + modalOverlayOpeningYOffset - modalOverlayOpeningPadding,
96+
r: modalOverlayOpeningRadius
97+
});
98+
}
6599
} else {
66100
closeModalOpening();
67101
}
@@ -149,7 +183,8 @@
149183
modalOverlayOpeningXOffset + iframeOffset.left,
150184
modalOverlayOpeningYOffset + iframeOffset.top,
151185
scrollParent,
152-
step.target
186+
step.target,
187+
step._resolvedExtraHighlightElements
153188
);
154189
rafId = requestAnimationFrame(rafLoop);
155190
};

shepherd.js/src/step.ts

Lines changed: 61 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,12 @@ import {
88
isUndefined
99
} from './utils/type-check.ts';
1010
import { bindAdvance } from './utils/bind.ts';
11-
import { parseAttachTo, normalizePrefix, uuid } from './utils/general.ts';
11+
import {
12+
parseAttachTo,
13+
normalizePrefix,
14+
uuid,
15+
parseExtraHighlights
16+
} from './utils/general.ts';
1217
import {
1318
setupTooltip,
1419
destroyTooltip,
@@ -94,6 +99,18 @@ export interface StepOptions {
9499
*/
95100
classes?: string;
96101

102+
/**
103+
* An array of extra element selectors to highlight when the overlay is shown
104+
* The tooltip won't be fixed to these elements, but they will be highlighted
105+
* just like the `attachTo` element.
106+
* ```js
107+
* const step = new Step(tour, {
108+
* extraHighlights: [ '.pricing', '#docs' ],
109+
* ...moreOptions
110+
* });
111+
*/
112+
extraHighlights?: ReadonlyArray<string>;
113+
97114
/**
98115
* An extra class to apply to the `attachTo` element when it is
99116
* highlighted (that is, when its step is active). You can then target that selector in your CSS.
@@ -275,6 +292,7 @@ export interface StepOptionsWhen {
275292
*/
276293
export class Step extends Evented {
277294
_resolvedAttachTo: StepOptionsAttachTo | null;
295+
_resolvedExtraHighlightElements?: HTMLElement[];
278296
classPrefix?: string;
279297
// eslint-disable-next-line @typescript-eslint/ban-types
280298
declare cleanup: Function | null;
@@ -369,6 +387,15 @@ export class Step extends Evented {
369387
this.trigger('hide');
370388
}
371389

390+
/**
391+
* Resolves attachTo options.
392+
* @returns {{}|{element, on}}
393+
*/
394+
_resolveExtraHiglightElements() {
395+
this._resolvedExtraHighlightElements = parseExtraHighlights(this);
396+
return this._resolvedExtraHighlightElements;
397+
}
398+
372399
/**
373400
* Resolves attachTo options.
374401
* @returns {{}|{element, on}}
@@ -576,6 +603,7 @@ export class Step extends Evented {
576603

577604
// Force resolve to make sure the options are updated on subsequent shows.
578605
this._resolveAttachToOptions();
606+
this._resolveExtraHiglightElements();
579607
this._setupElements();
580608

581609
if (!this.tour.modal) {
@@ -605,10 +633,17 @@ export class Step extends Evented {
605633
// @ts-expect-error TODO: get types for Svelte components
606634
const content = this.shepherdElementComponent.getElement();
607635
const target = this.target || document.body;
636+
const extraHighlightElements = this._resolvedExtraHighlightElements;
637+
608638
target.classList.add(`${this.classPrefix}shepherd-enabled`);
609639
target.classList.add(`${this.classPrefix}shepherd-target`);
610640
content.classList.add('shepherd-enabled');
611641

642+
extraHighlightElements?.forEach((el) => {
643+
el.classList.add(`${this.classPrefix}shepherd-enabled`);
644+
el.classList.add(`${this.classPrefix}shepherd-target`);
645+
});
646+
612647
this.trigger('show');
613648
}
614649

@@ -621,19 +656,28 @@ export class Step extends Evented {
621656
*/
622657
_styleTargetElementForStep(step: Step) {
623658
const targetElement = step.target;
659+
const extraHighlightElements = step._resolvedExtraHighlightElements;
624660

625661
if (!targetElement) {
626662
return;
627663
}
628664

629-
if (step.options.highlightClass) {
630-
targetElement.classList.add(step.options.highlightClass);
665+
const highlightClass = step.options.highlightClass;
666+
if (highlightClass) {
667+
targetElement.classList.add(highlightClass);
668+
extraHighlightElements?.forEach((el) => el.classList.add(highlightClass));
631669
}
632670

633671
targetElement.classList.remove('shepherd-target-click-disabled');
672+
extraHighlightElements?.forEach((el) =>
673+
el.classList.remove('shepherd-target-click-disabled')
674+
);
634675

635676
if (step.options.canClickTarget === false) {
636677
targetElement.classList.add('shepherd-target-click-disabled');
678+
extraHighlightElements?.forEach((el) =>
679+
el.classList.add('shepherd-target-click-disabled')
680+
);
637681
}
638682
}
639683

@@ -644,15 +688,27 @@ export class Step extends Evented {
644688
*/
645689
_updateStepTargetOnHide() {
646690
const target = this.target || document.body;
691+
const extraHighlightElements = this._resolvedExtraHighlightElements;
647692

648-
if (this.options.highlightClass) {
649-
target.classList.remove(this.options.highlightClass);
693+
const highlightClass = this.options.highlightClass;
694+
if (highlightClass) {
695+
target.classList.remove(highlightClass);
696+
extraHighlightElements?.forEach((el) =>
697+
el.classList.remove(highlightClass)
698+
);
650699
}
651700

652701
target.classList.remove(
653702
'shepherd-target-click-disabled',
654703
`${this.classPrefix}shepherd-enabled`,
655704
`${this.classPrefix}shepherd-target`
656705
);
706+
extraHighlightElements?.forEach((el) => {
707+
el.classList.remove(
708+
'shepherd-target-click-disabled',
709+
`${this.classPrefix}shepherd-enabled`,
710+
`${this.classPrefix}shepherd-target`
711+
);
712+
});
657713
}
658714
}

shepherd.js/src/utils/cleanup.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,14 @@ export function cleanupSteps(tour: Tour) {
1818
if (isHTMLElement(step.target)) {
1919
step.target.classList.remove('shepherd-target-click-disabled');
2020
}
21+
22+
if (step._resolvedExtraHighlightElements) {
23+
step._resolvedExtraHighlightElements.forEach((element) => {
24+
if (isHTMLElement(element)) {
25+
element.classList.remove('shepherd-target-click-disabled');
26+
}
27+
});
28+
}
2129
}
2230
});
2331
}

shepherd.js/src/utils/general.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,18 @@ export function parseAttachTo(step: Step) {
6363
return returnOpts;
6464
}
6565

66+
/*
67+
* Resolves the step's `extraHighlights` option, converting any locator values to HTMLElements.
68+
*/
69+
export function parseExtraHighlights(step: Step): HTMLElement[] {
70+
if (step.options.extraHighlights) {
71+
return step.options.extraHighlights.flatMap((highlight) => {
72+
return Array.from(document.querySelectorAll(highlight)) as HTMLElement[];
73+
});
74+
}
75+
return [];
76+
}
77+
6678
/**
6779
* Checks if the step should be centered or not. Does not trigger attachTo.element evaluation, making it a pure
6880
* alternative for the deprecated step.isCentered() method.

0 commit comments

Comments
 (0)