Skip to content
Merged
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 @@ -292,7 +292,12 @@ function will be called in the `before-show` phase.
```
- `extraHighlights`: 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.
be highlighted just like the attachTo element. They do not have to share a
scroll container with the attachTo element: each one is clipped vertically by
the scroll containers that actually crop it, so only the part of it that is
scrolled into view is cut out of the overlay. An element positioned outside
those containers — `fixed`, or `absolute` against a containing block above
them — is cut out in full, matching where it is painted.
- `advanceOn`: An action on the page which should advance shepherd to the next
step. It should be an object with a string `selector` and an `event` name. For
example: `{selector: '.some-element', event: 'click'}`. It doesn't have to be
Expand Down
2 changes: 2 additions & 0 deletions docs-src/src/content/docs/recipes/cookbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ const tour = new Shepherd.Tour({

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.

Highlighted elements do not have to share a scroll container with the `attachTo` target. Each one is clipped vertically by the scroll containers that actually crop it, so only the part of it that is scrolled into view is cut out of the overlay. An element whose position takes it outside those containers — `fixed`, or `absolute` against a containing block above them, as a dropdown usually is — is cut out in full, wherever it is painted. Clipping is vertical only: an element scrolled out of view horizontally is still cut out in full.

### Offsets

By default, FloatingUI instances are placed directly next to their target. However, if you need to apply some margin
Expand Down
144 changes: 123 additions & 21 deletions shepherd.js/src/components/shepherd-modal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export interface ShepherdModalAPI {
modalOverlayOpeningRadius?: ModalRadiusType,
modalOverlayOpeningXOffset?: number,
modalOverlayOpeningYOffset?: number,
scrollParent?: HTMLElement | null,
targetScrollParent?: HTMLElement | null,
targetElement?: HTMLElement | null,
extraHighlights?: HTMLElement[]
) => void;
Expand All @@ -47,6 +47,9 @@ export interface ShepherdModalAPI {

export function createShepherdModal(container: HTMLElement): ShepherdModalAPI {
let rafId: number | undefined;
// Memoizes the chain of scroll parents of a highlighted element for the
// lifetime of a single step. Reset in `_cleanupStepEventListeners`.
let _stepScrollParents = new WeakMap<HTMLElement, HTMLElement[]>();
let openingProperties: OpeningProperty[] = [
{ width: 0, height: 0, x: 0, y: 0, r: 0 }
];
Expand Down Expand Up @@ -84,19 +87,37 @@ export function createShepherdModal(container: HTMLElement): ShepherdModalAPI {
element.classList.add('shepherd-modal-is-visible');
}

/**
* @param targetScrollParent The nearest scroll parent of `targetElement`
* only. Extra highlights can live in entirely different scroll containers,
* so each one resolves its own chain via `_cachedScrollParents`. Any scroll
* containers above `targetScrollParent` are resolved here too.
*/
function positionModal(
modalOverlayOpeningPadding = 0,
modalOverlayOpeningRadius: ModalRadiusType = 0,
modalOverlayOpeningXOffset = 0,
modalOverlayOpeningYOffset = 0,
scrollParent?: HTMLElement | null,
targetScrollParent?: HTMLElement | null,
targetElement?: HTMLElement | null,
extraHighlights?: HTMLElement[]
) {
if (targetElement) {
const elementsToHighlight = [targetElement, ...(extraHighlights || [])];
const newOpenings: OpeningProperty[] = [];

// The target's nearest scroll parent is supplied by the caller; the rest
// of its chain is resolved the same way as for any other highlight.
const targetScrollParents = targetScrollParent
? [
targetScrollParent,
..._cachedScrollParents(targetScrollParent.parentElement)
]
: [];

const scrollParentsFor = (el: HTMLElement) =>
el === targetElement ? targetScrollParents : _cachedScrollParents(el);

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

Expand All @@ -108,18 +129,19 @@ export function createShepherdModal(container: HTMLElement): ShepherdModalAPI {
continue;
}

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

// Check if the element is contained by another element.
// Use _getVisibleHeight for otherElement too so both sides
// compare scroll-clipped geometry on the y-axis.
// Use _getVisibleHeight for otherElement too so both sides compare
// scroll-clipped geometry on the y-axis, each element being clipped
// by its own scroll parents.
const isContained = elementsToHighlight.some((otherElement) => {
if (otherElement === el) return false;
const otherRect = otherElement.getBoundingClientRect();
const { y: otherY, height: otherHeight } = _getVisibleHeight(
otherElement,
scrollParent
scrollParentsFor(otherElement)
);
return (
x >= otherRect.left &&
Expand Down Expand Up @@ -195,6 +217,9 @@ export function createShepherdModal(container: HTMLElement): ShepherdModalAPI {
rafId = undefined;
}

// Scroll parents are only memoized for the duration of a single step.
_stepScrollParents = new WeakMap();

window.removeEventListener('touchmove', _preventModalBodyTouch, {
passive: false
} as EventListenerOptions);
Expand All @@ -209,7 +234,7 @@ export function createShepherdModal(container: HTMLElement): ShepherdModalAPI {
} = step.options;

const iframeOffset = _getIframeOffset(step.target);
const scrollParent = _getScrollParent(step.target);
const targetScrollParent = _getScrollParent(step.target);

const rafLoop = () => {
rafId = undefined;
Expand All @@ -218,7 +243,7 @@ export function createShepherdModal(container: HTMLElement): ShepherdModalAPI {
modalOverlayOpeningRadius,
modalOverlayOpeningXOffset + iframeOffset.left,
modalOverlayOpeningYOffset + iframeOffset.top,
scrollParent,
targetScrollParent,
step.target,
step._resolvedExtraHighlightElements
);
Expand All @@ -229,18 +254,98 @@ export function createShepherdModal(container: HTMLElement): ShepherdModalAPI {
_addStepEventListeners();
}

function _getScrollParent(el?: HTMLElement | null): HTMLElement | null {
if (!el) return null;
/**
* Whether `el` crops overflowing descendants on the y-axis.
*
* @param el The candidate scroll container
* @param style `el`'s computed style, already resolved by the caller
*/
function _isScrollable(el: HTMLElement, style: CSSStyleDeclaration) {
const { overflowY } = style;

return (
overflowY !== 'hidden' &&
overflowY !== 'visible' &&
el.scrollHeight >= el.clientHeight
);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const isHtmlElement = el instanceof HTMLElement;
const overflowY = isHtmlElement && window.getComputedStyle(el).overflowY;
const isScrollable = overflowY !== 'hidden' && overflowY !== 'visible';
/**
* Every scroll container that clips `el`, nearest first, including `el`
* itself when it is one.
*
* Clipping against the nearest alone is wrong as soon as scroll containers
* nest: an element sitting inside an inner container that has itself been
* scrolled out of an outer one measures as fully visible against the inner
* container, and the overlay would cut a hole where nothing is on screen.
*
* DOM ancestry is not the clipping chain either. A `fixed` element is laid
* out against the viewport, and an `absolute` element is cropped only from
* its containing block upwards -- scrollable ancestors below that block paint
* it without cropping it. Walking `parentElement` unconditionally would size
* the opening for an absolutely positioned dropdown to whichever panel it
* happens to be nested in rather than to where it is painted, and would drop
* the opening entirely once that panel is scrolled away.
*
* The containing block is derived from each ancestor's computed `position`
* rather than from `offsetParent`, which happy-dom does not implement and the
* unit tests therefore cannot exercise.
*/
function _getScrollParents(el?: HTMLElement | null): HTMLElement[] {
if (!(el instanceof HTMLElement)) return [];

const { position } = window.getComputedStyle(el);

// Laid out against the viewport, so no ancestor crops it.
if (position === 'fixed') return [];

const scrollParents: HTMLElement[] = [];
let crops = position !== 'absolute';

for (
let current: HTMLElement | null = el;
current;
current = current.parentElement
) {
const style = window.getComputedStyle(current);

// The nearest positioned ancestor is an absolutely positioned element's
// containing block; from there upwards the usual overflow rules apply.
if (!crops && current !== el && style.position !== 'static') {
crops = true;
}

if (isScrollable && el.scrollHeight >= el.clientHeight) {
return el;
if (crops && _isScrollable(current, style)) {
scrollParents.push(current);
}
}

return _getScrollParent(el.parentElement);
return scrollParents;
}

function _getScrollParent(el?: HTMLElement | null): HTMLElement | null {
return _getScrollParents(el)[0] ?? null;
}

/**
* Memoized `_getScrollParents`, scoped to the current step.
*
* Resolving inline would be prohibitively expensive: the containment check in
* `positionModal` is O(n^2) over the highlighted elements and runs on every
* animation frame, while each walk costs one `window.getComputedStyle` call
* per ancestor. The answer cannot change between frames of a step, so it is
* resolved once per element instead — matching the once-per-step contract the
* target already has in `_styleForStep`.
*/
function _cachedScrollParents(el?: HTMLElement | null): HTMLElement[] {
if (!el) return [];

const cached = _stepScrollParents.get(el);
if (cached) return cached;

const scrollParents = _getScrollParents(el);
_stepScrollParents.set(el, scrollParents);
return scrollParents;
}

function _getIframeOffset(el?: HTMLElement | null) {
Expand Down Expand Up @@ -269,15 +374,12 @@ export function createShepherdModal(container: HTMLElement): ShepherdModalAPI {
return offset;
}

function _getVisibleHeight(
el: HTMLElement,
scrollParent?: HTMLElement | null
) {
function _getVisibleHeight(el: HTMLElement, scrollParents: HTMLElement[]) {
const elementRect = el.getBoundingClientRect();
let top = elementRect.y || elementRect.top;
let bottom = elementRect.bottom || top + elementRect.height;

if (scrollParent) {
for (const scrollParent of scrollParents) {
const scrollRect = scrollParent.getBoundingClientRect();
const scrollTop = scrollRect.y || scrollRect.top;
const scrollBottom = scrollRect.bottom || scrollTop + scrollRect.height;
Expand Down
Loading
Loading