Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement multiple element highlighting feature #2995

Merged
merged 10 commits into from
Oct 21, 2024
30 changes: 15 additions & 15 deletions docs-src/src/content/docs/recipes/cookbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,24 +12,24 @@ starting the tour, then `bodyScrollLock.clearAllBodyScrollLocks();` after stoppi

### Highlighting multiple elements

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.,
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.

```html
<colgroup class="shepherd-step-highlight"></colgroup>
```

and setting your CSS to something like:

```css
colgroup.shepherd-step-highlight {
display: block;
height: 100px;
position: absolute;
width: 200px;
}
```javascript
const tour = new Shepherd.Tour({
steps: [
{
text: 'This is a step with multiple highlights',
attachTo: {
element: '.target-element',
on: 'bottom'
},
extraHighlights: ['.example-selector', '.example-selector-2']
}
]
});
```

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.
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.

### Offsets

Expand Down
6 changes: 3 additions & 3 deletions landing/src/components/Header.astro
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ const { isHome } = Astro.props;
{
isHome && (
<div class="flex flex-wrap max-w-6xl p-4 w-full lg:flex-nowrap">
<div class="m-4 relative w-full lg:w-1/3">
<div class="feature m-4 relative w-full lg:w-1/3">
<div class="border-4 border-navy w-full">
<img
class="absolute a11y-icon z-20"
Expand All @@ -103,7 +103,7 @@ const { isHome } = Astro.props;
</div>
</div>

<div class="m-4 relative w-full lg:w-1/3">
<div class="customizable m-4 relative w-full lg:w-1/3">
<div class="border-4 border-navy w-full">
<img
class="absolute customizable-icon z-20"
Expand All @@ -130,7 +130,7 @@ const { isHome } = Astro.props;
</div>
</div>

<div class="m-4 relative w-full lg:w-1/3">
<div class="feature m-4 relative w-full lg:w-1/3">
<div class="border-4 border-navy w-full">
<img
class="absolute framework-icon z-20"
Expand Down
37 changes: 31 additions & 6 deletions landing/src/pages/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -15,20 +15,20 @@ import MainPage from '@layouts/MainPage.astro';
/>
</div>

<div slot='content'>
<div class='hero-including mt-8' id='hero-including'>
<h3 class='demo-heading font-heading text-2xl uppercase'>
<div slot="content">
<div class="hero-including mt-8" id="hero-including">
<h3 class="demo-heading font-heading text-2xl uppercase">
01. How to Include
</h3>
<div class='hero-example-code text-left'>
<div class="hero-example-code text-left">
<Code
code={`
<link rel="stylesheet" href="shepherd.js/dist/css/shepherd.css"/>
<script type="module" src="shepherd.js/dist/shepherd.mjs"></script>

`}
lang='js'
theme='nord'
lang="js"
theme="nord"
wrap
/>
</div>
Expand Down Expand Up @@ -142,6 +142,31 @@ import MainPage from '@layouts/MainPage.astro';
],
id: 'welcome'
},
{
title: 'Features',
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.',
attachTo: {
element: '.customizable',
on: 'bottom'
},
extraHighlights: ['.feature'],
buttons: [
{
action() {
return this.back();
},
secondary: true,
text: 'Back'
},
{
action() {
return this.next();
},
text: 'Next'
}
],
id: 'features'
},
{
title: 'Including',
text: element,
Expand Down
77 changes: 56 additions & 21 deletions shepherd.js/src/components/shepherd-modal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,15 @@
export const getElement = () => element;

export function closeModalOpening() {
openingProperties = {
width: 0,
height: 0,
x: 0,
y: 0,
r: 0
};
openingProperties = [
{
width: 0,
height: 0,
x: 0,
y: 0,
r: 0
}
];
}

/**
Expand Down Expand Up @@ -47,21 +49,53 @@
modalOverlayOpeningXOffset = 0,
modalOverlayOpeningYOffset = 0,
scrollParent,
targetElement
targetElement,
extraHighlights
) {
if (targetElement) {
const { y, height } = _getVisibleHeight(targetElement, scrollParent);
const { x, width, left } = targetElement.getBoundingClientRect();

// getBoundingClientRect is not consistent. Some browsers use x and y, while others use left and top
openingProperties = {
width: width + modalOverlayOpeningPadding * 2,
height: height + modalOverlayOpeningPadding * 2,
x:
(x || left) + modalOverlayOpeningXOffset - modalOverlayOpeningPadding,
y: y + modalOverlayOpeningYOffset - modalOverlayOpeningPadding,
r: modalOverlayOpeningRadius
};
const elementsToHighlight = [targetElement, ...(extraHighlights || [])];
openingProperties = [];

for (const element of elementsToHighlight) {
if (!element) continue;

// Skip duplicate elements
if (
elementsToHighlight.indexOf(element) !==
elementsToHighlight.lastIndexOf(element)
) {
continue;
}

const { y, height } = _getVisibleHeight(element, scrollParent);
const { x, width, left } = element.getBoundingClientRect();

// Check if the element is contained by another element
const isContained = elementsToHighlight.some((otherElement) => {
if (otherElement === element) return false;
const otherRect = otherElement.getBoundingClientRect();
return (
x >= otherRect.left &&
x + width <= otherRect.right &&
y >= otherRect.top &&
y + height <= otherRect.bottom
);
});

if (isContained) continue;

// getBoundingClientRect is not consistent. Some browsers use x and y, while others use left and top
openingProperties.push({
width: width + modalOverlayOpeningPadding * 2,
height: height + modalOverlayOpeningPadding * 2,
x:
(x || left) +
modalOverlayOpeningXOffset -
modalOverlayOpeningPadding,
y: y + modalOverlayOpeningYOffset - modalOverlayOpeningPadding,
r: modalOverlayOpeningRadius
});
}
} else {
closeModalOpening();
}
Expand Down Expand Up @@ -149,7 +183,8 @@
modalOverlayOpeningXOffset + iframeOffset.left,
modalOverlayOpeningYOffset + iframeOffset.top,
scrollParent,
step.target
step.target,
step._resolvedExtraHighlightElements
);
rafId = requestAnimationFrame(rafLoop);
};
Expand Down
66 changes: 61 additions & 5 deletions shepherd.js/src/step.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@ import {
isUndefined
} from './utils/type-check.ts';
import { bindAdvance } from './utils/bind.ts';
import { parseAttachTo, normalizePrefix, uuid } from './utils/general.ts';
import {
parseAttachTo,
normalizePrefix,
uuid,
parseExtraHighlights
} from './utils/general.ts';
import {
setupTooltip,
destroyTooltip,
Expand Down Expand Up @@ -93,6 +98,18 @@ export interface StepOptions {
*/
classes?: string;

/**
* An array of extra element selectors to highlight when the overlay is shown
* The tooltip won't be fixed to these elements, but they will be highlighted
* just like the `attachTo` element.
* ```js
* const step = new Step(tour, {
* extraHighlights: [ '.pricing', '#docs' ],
* ...moreOptions
* });
*/
extraHighlights?: ReadonlyArray<string>;

/**
* An extra class to apply to the `attachTo` element when it is
* highlighted (that is, when its step is active). You can then target that selector in your CSS.
Expand Down Expand Up @@ -274,6 +291,7 @@ export interface StepOptionsWhen {
*/
export class Step extends Evented {
_resolvedAttachTo: StepOptionsAttachTo | null;
_resolvedExtraHighlightElements?: HTMLElement[];
classPrefix?: string;
// eslint-disable-next-line @typescript-eslint/ban-types
declare cleanup: Function | null;
Expand Down Expand Up @@ -368,6 +386,15 @@ export class Step extends Evented {
this.trigger('hide');
}

/**
* Resolves attachTo options.
* @returns {{}|{element, on}}
*/
_resolveExtraHiglightElements() {
this._resolvedExtraHighlightElements = parseExtraHighlights(this);
return this._resolvedExtraHighlightElements;
}

/**
* Resolves attachTo options.
* @returns {{}|{element, on}}
Expand Down Expand Up @@ -575,6 +602,7 @@ export class Step extends Evented {

// Force resolve to make sure the options are updated on subsequent shows.
this._resolveAttachToOptions();
this._resolveExtraHiglightElements();
this._setupElements();

if (!this.tour.modal) {
Expand Down Expand Up @@ -604,10 +632,17 @@ export class Step extends Evented {
// @ts-expect-error TODO: get types for Svelte components
const content = this.shepherdElementComponent.getElement();
const target = this.target || document.body;
const extraHighlightElements = this._resolvedExtraHighlightElements;

target.classList.add(`${this.classPrefix}shepherd-enabled`);
target.classList.add(`${this.classPrefix}shepherd-target`);
content.classList.add('shepherd-enabled');

extraHighlightElements?.forEach((el) => {
el.classList.add(`${this.classPrefix}shepherd-enabled`);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Forgive my ignorance, but why do we want to add these classes to the extra highlighted elements?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought we would want the same classes applied to the target element (attackTo.element) on the extra highlighted elements as well.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if we do? We just want the cutout overlay I would assume? I am not sure what the classes do anymore though.

Copy link
Contributor Author

@yunusemre-dev yunusemre-dev Oct 9, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For example, one of the classes I added disables the click event if canClickTarget option is false. I guess it's needed since we're cutting out the overlay and the extra highlighted elements will be exposed as well.

el.classList.add(`${this.classPrefix}shepherd-target`);
});

this.trigger('show');
}

Expand All @@ -620,19 +655,28 @@ export class Step extends Evented {
*/
_styleTargetElementForStep(step: Step) {
const targetElement = step.target;
const extraHighlightElements = step._resolvedExtraHighlightElements;

if (!targetElement) {
return;
}

if (step.options.highlightClass) {
targetElement.classList.add(step.options.highlightClass);
const highlightClass = step.options.highlightClass;
if (highlightClass) {
targetElement.classList.add(highlightClass);
extraHighlightElements?.forEach((el) => el.classList.add(highlightClass));
}

targetElement.classList.remove('shepherd-target-click-disabled');
extraHighlightElements?.forEach((el) =>
el.classList.remove('shepherd-target-click-disabled')
);

if (step.options.canClickTarget === false) {
targetElement.classList.add('shepherd-target-click-disabled');
extraHighlightElements?.forEach((el) =>
el.classList.add('shepherd-target-click-disabled')
);
}
}

Expand All @@ -643,15 +687,27 @@ export class Step extends Evented {
*/
_updateStepTargetOnHide() {
const target = this.target || document.body;
const extraHighlightElements = this._resolvedExtraHighlightElements;

if (this.options.highlightClass) {
target.classList.remove(this.options.highlightClass);
const highlightClass = this.options.highlightClass;
if (highlightClass) {
target.classList.remove(highlightClass);
extraHighlightElements?.forEach((el) =>
el.classList.remove(highlightClass)
);
}

target.classList.remove(
'shepherd-target-click-disabled',
`${this.classPrefix}shepherd-enabled`,
`${this.classPrefix}shepherd-target`
);
extraHighlightElements?.forEach((el) => {
el.classList.remove(
'shepherd-target-click-disabled',
`${this.classPrefix}shepherd-enabled`,
`${this.classPrefix}shepherd-target`
);
});
}
}
8 changes: 8 additions & 0 deletions shepherd.js/src/utils/cleanup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ export function cleanupSteps(tour: Tour) {
if (isHTMLElement(step.target)) {
step.target.classList.remove('shepherd-target-click-disabled');
}

if (step._resolvedExtraHighlightElements) {
step._resolvedExtraHighlightElements.forEach((element) => {
if (isHTMLElement(element)) {
element.classList.remove('shepherd-target-click-disabled');
}
});
}
}
});
}
Expand Down
Loading