diff --git a/docs-src/src/content/docs/guides/usage.md b/docs-src/src/content/docs/guides/usage.md index f889eba67..928356222 100644 --- a/docs-src/src/content/docs/guides/usage.md +++ b/docs-src/src/content/docs/guides/usage.md @@ -172,6 +172,11 @@ const myTour = new Shepherd.Tour(options); - `off(eventName, [handler])`: Unbind an event - `once(eventName, handler, [context])`: Bind just the next instance of an event +`start()` always returns a promise, and `show()`, `next()`, and `back()` return +one while a step is waiting on its `attachTo.element` (see `waitForElement` +below). Await them if you need to know the step is on screen — otherwise they +can be called and ignored, as before. + ##### Tour Events - `complete`: Triggered when the last step is advanced @@ -212,7 +217,9 @@ const new Step(tour, { If you don’t specify an `attachTo` the element will appear in the middle of the screen. The same will happen if your `attachTo.element` callback returns `null`, -`undefined`, or a selector that does not exist in the DOM. +`undefined`, or a selector that does not exist in the DOM. The `waitForElement` +and `skipMissingElement` options described below let you change this behavior +for elements that are missing or rendered late. If you omit the `on` portion of `attachTo`, the element will still be highlighted, but the tooltip will appear in the middle of the screen, without an @@ -256,6 +263,10 @@ function will be called in the `before-show` phase. - `label` The label to add for `aria-label` - `classes`: A string of extra classes to add to the step's content element. +- `data`: Arbitrary, JSON-serializable data to associate with the step. Shepherd + does not use it internally; read it back from `step.options.data` in your + event handlers and button actions. Useful for analytics ids or other metadata, + e.g. on generated tour definitions. - `buttons`: An array of buttons to add to the step. These will be rendered in a footer below the main body text. Each button in the array is an object of the format: @@ -302,6 +313,27 @@ function will be called in the `before-show` phase. [Floating UI](https://floating-ui.com/docs/getting-started) - `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` + cannot be found in the DOM is skipped (like `showOn` returning false) instead + of being shown centered. If all remaining steps are skipped, the tour + completes going forward, or cancels going backward. It can also be set on + `defaultStepOptions` to apply to every step. Steps without an `attachTo` + element are never skipped, since they are intentionally centered. +- `waitForElement`: The maximum amount of time, in milliseconds, to wait for the + `attachTo.element` to appear in the DOM before showing the step. The DOM is + watched with a `MutationObserver`, falling back to polling where + `MutationObserver` is unavailable, so the step attaches as soon as the element + appears. If the timeout expires, the step falls back to its default behavior: + skipped when `skipMissingElement` is true, otherwise shown centered. It can + also be set on `defaultStepOptions` to apply to every step. Useful for targets + that are rendered asynchronously. + + Both options look the target up before the step's own `beforeShowPromise` and + `before-show` handlers run, so they cannot see an element that those handlers + create — keep using `beforeShowPromise` for targets the step itself renders. + Both are plain JSON values, so a tour definition using them stays + serializable. + - `scrollTo`: Should the element be scrolled to when this step is shown? If true, uses the default `scrollIntoView`, if an object, passes that object as the params to `scrollIntoView` i.e. `{behavior: 'smooth', block: 'center'}` diff --git a/shepherd.js/src/step.ts b/shepherd.js/src/step.ts index 9e1587a0f..cf5cc75c4 100644 --- a/shepherd.js/src/step.ts +++ b/shepherd.js/src/step.ts @@ -101,6 +101,15 @@ export interface StepOptions { */ classes?: string; + /** + * Arbitrary, JSON-serializable data to associate with the step. Shepherd + * does not use this value internally; it is a place to store your own + * metadata (for example analytics ids, or context produced by a tour + * generator) and read it back from `step.options.data` in event handlers + * and button actions. + */ + data?: Record; + /** * 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 @@ -174,6 +183,24 @@ export interface StepOptions { */ showOn?: () => boolean; + /** + * When `true`, a step whose `attachTo.element` selector (or function + * locator) does not resolve to an element in the DOM is skipped, advancing + * to the next step (or the previous step when navigating backwards) instead + * of being shown centered. If all remaining steps are skipped, the tour + * completes (going forward) or cancels (going backward), mirroring the + * `showOn` semantics. Can be set on `defaultStepOptions` to apply to every + * step. Combine with `waitForElement` to give the element time to appear + * before skipping. Steps without an `attachTo` element are never skipped, + * since they are intentionally centered. + * + * Note that the target is looked up before the step's own `beforeShowPromise` + * and `before-show` handlers run, so an element that those handlers create is + * not visible to this check. Use `beforeShowPromise` on its own for targets + * the step itself renders. + */ + skipMissingElement?: boolean; + /** * The text in the body of the step. It can be one of four types: * ``` @@ -194,6 +221,20 @@ export interface StepOptions { */ title?: StringOrStringFunction; + /** + * The maximum amount of time, in milliseconds, to wait for the + * `attachTo.element` to appear in the DOM before showing the step. The DOM + * is watched with a `MutationObserver` (falling back to polling when it is + * unavailable), so the step attaches as soon as the element appears. If the + * timeout expires, the step falls back to its default behavior: skipped + * when `skipMissingElement` is `true`, otherwise shown centered. + * + * The wait starts before the step's own `beforeShowPromise` and `before-show` + * handlers run, so it cannot observe a target that those handlers create, and + * a function locator is re-evaluated on each DOM change until it resolves. + */ + waitForElement?: number; + /** * You can define `show`, `hide`, etc events inside `when`. For example: * ```js diff --git a/shepherd.js/src/tour.ts b/shepherd.js/src/tour.ts index 387b04e4e..6255ea691 100644 --- a/shepherd.js/src/tour.ts +++ b/shepherd.js/src/tour.ts @@ -8,7 +8,12 @@ import { isUndefined } from './utils/type-check.ts'; import { cleanupSteps } from './utils/cleanup.ts'; -import { normalizePrefix, uuid } from './utils/general.ts'; +import { + normalizePrefix, + resolveAttachToElement, + uuid, + waitForAttachToElement +} from './utils/general.ts'; import { createShepherdModal, type ShepherdModalAPI @@ -113,6 +118,13 @@ export class Tour extends Evented { options: TourOptions; steps: Array; + /** + * Monotonically increasing id of `show()` calls, used to invalidate pending + * `waitForElement` waits that were superseded by a newer `show()`. + * @private + */ + _showGeneration = 0; + constructor(options: TourOptions = {}) { super(); @@ -196,7 +208,7 @@ export class Tour extends Evented { */ back() { const index = this.steps.indexOf(this.currentStep as Step); - this.show(index - 1, false); + return this.show(index - 1, false); } /** @@ -279,7 +291,7 @@ export class Tour extends Evented { if (index === this.steps.length - 1) { this.complete(); } else { - this.show(index + 1, true); + return this.show(index + 1, true); } } @@ -316,29 +328,85 @@ export class Tour extends Evented { * Show a specific step in the tour * @param {number | string} key - The key to look up the step by * @param {boolean} forward - True if we are going forward, false if backward + * @returns A promise when the step's `waitForElement` option makes showing + * asynchronous, otherwise `undefined` */ - show(key: number | string = 0, forward = true) { + show(key: number | string = 0, forward = true): void | Promise { const step = isString(key) ? this.getById(key) : this.steps[key]; - if (step) { - this._updateStateBeforeShow(); + if (!step) { + return; + } - const shouldSkipStep = - isFunction(step.options.showOn) && !step.options.showOn(); + this._updateStateBeforeShow(); - // If `showOn` returns false, we want to skip the step, otherwise, show the step like normal - if (shouldSkipStep) { - this._skipStep(step, forward); - } else { - this.currentStep = step; - this.trigger('show', { - step, - previous: this.currentStep + // Invalidate any pending `waitForElement` wait from a previous call. + const generation = ++this._showGeneration; + + const shouldSkipStep = + isFunction(step.options.showOn) && !step.options.showOn(); + + // If `showOn` returns false, we want to skip the step, otherwise, show the step like normal + // Return the result, since the step we skip to may itself be waiting on an element. + if (shouldSkipStep) { + return this._skipStep(step, forward); + } + + const { skipMissingElement, waitForElement } = step.options; + const attachToElement = + step.options.attachTo && step.options.attachTo.element; + // `waitForElement` and `skipMissingElement` only apply to steps that + // locate their target dynamically. Steps without an `attachTo` element + // are intentionally centered and never treated as missing. + const hasElementLocator = + isString(attachToElement) || isFunction(attachToElement); + const waitTimeout = + typeof waitForElement === 'number' && waitForElement > 0 + ? waitForElement + : 0; + + if ( + hasElementLocator && + (skipMissingElement || waitTimeout > 0) && + !resolveAttachToElement(step) + ) { + if (waitTimeout > 0) { + return waitForAttachToElement(step, waitTimeout).then((element) => { + // A newer `show()`, or cancelling/completing the tour, supersedes + // this pending wait. + if (generation !== this._showGeneration || !this.isActive()) { + return; + } + + if (!element && skipMissingElement) { + return this._skipStep(step, forward); + } + + this._showStep(step); }); + } - step.show(); + if (skipMissingElement) { + return this._skipStep(step, forward); } } + + this._showStep(step); + } + + /** + * Sets the given step as the current step and shows it + * @param {Step} step - The step to show + * @private + */ + _showStep(step: Step) { + this.currentStep = step; + this.trigger('show', { + step, + previous: this.currentStep + }); + + step.show(); } /** @@ -355,7 +423,7 @@ export class Tour extends Evented { this.setupModal(); this._setupActiveTour(); - this.next(); + return this.next(); } /** @@ -425,7 +493,7 @@ export class Tour extends Evented { } else if (nextIndex >= this.steps.length) { this.complete(); } else { - this.show(nextIndex, forward); + return this.show(nextIndex, forward); } } diff --git a/shepherd.js/src/utils/general.ts b/shepherd.js/src/utils/general.ts index 43e180699..f1f74b6c4 100644 --- a/shepherd.js/src/utils/general.ts +++ b/shepherd.js/src/utils/general.ts @@ -53,7 +53,7 @@ export function parseAttachTo(step: Step) { } catch (_e) { // TODO } - if (!returnOpts.element) { + if (!returnOpts.element && !step.options.skipMissingElement) { console.error( `The element for this Shepherd step was not found ${options.element}` ); @@ -63,6 +63,92 @@ export function parseAttachTo(step: Step) { return returnOpts; } +/** + * Resolves a step's `attachTo.element` to an HTMLElement, evaluating function + * locators and querying selector strings. Unlike `parseAttachTo`, this never + * logs and simply returns `null` when the element cannot be resolved. + * @param step - The step instance + * @returns The resolved element, or `null` if it could not be found + */ +export function resolveAttachToElement(step: Step): HTMLElement | null { + const attachTo = step.options.attachTo || {}; + let element = isFunction(attachTo.element) + ? attachTo.element.call(step) + : attachTo.element; + + if (isString(element)) { + try { + element = document.querySelector(element) as HTMLElement | null; + } catch (_e) { + element = null; + } + } + + return element || null; +} + +/** + * Waits for a step's `attachTo.element` to be resolvable, watching the DOM + * with a `MutationObserver` (falling back to polling when it is unavailable) + * until `timeout` milliseconds have elapsed. + * @param step - The step instance + * @param timeout - The maximum amount of time, in milliseconds, to wait + * @returns A promise resolving to the element, or `null` if it did not appear + * within `timeout` milliseconds + */ +export function waitForAttachToElement( + step: Step, + timeout: number +): Promise { + return new Promise((resolve) => { + const element = resolveAttachToElement(step); + + if (element || !(timeout > 0)) { + resolve(element); + return; + } + + let observer: MutationObserver | null = null; + let pollTimer: ReturnType | null = null; + let timeoutTimer: ReturnType | null = null; + + const finish = (result: HTMLElement | null) => { + observer?.disconnect(); + + if (pollTimer !== null) { + clearInterval(pollTimer); + } + + if (timeoutTimer !== null) { + clearTimeout(timeoutTimer); + } + + resolve(result); + }; + + const check = () => { + const found = resolveAttachToElement(step); + + if (found) { + finish(found); + } + }; + + timeoutTimer = setTimeout(() => finish(null), timeout); + + if (typeof MutationObserver !== 'undefined') { + observer = new MutationObserver(check); + observer.observe(document.documentElement, { + attributes: true, + childList: true, + subtree: true + }); + } else { + pollTimer = setInterval(check, 50); + } + }); +} + /* * Resolves the step's `extraHighlights` option, converting any locator values to HTMLElements. */ diff --git a/shepherd.js/test/unit/step.spec.js b/shepherd.js/test/unit/step.spec.js index 3a7ec9d9f..15222727f 100644 --- a/shepherd.js/test/unit/step.spec.js +++ b/shepherd.js/test/unit/step.spec.js @@ -917,4 +917,19 @@ describe('Tour | Step', () => { expect(testElement.getAttribute('tabindex')).toBe('3'); }); }); + + describe('data option', () => { + it('exposes the data option at step.options.data', () => { + const data = { foo: 'bar', nested: { a: 1 } }; + const step = tour.addStep({ id: 'data-step', data }); + + expect(step.options.data).toEqual(data); + }); + + it('leaves step.options.data undefined when data is not passed', () => { + const step = tour.addStep({ id: 'no-data-step' }); + + expect(step.options.data).toBeUndefined(); + }); + }); }); diff --git a/shepherd.js/test/unit/tour.spec.js b/shepherd.js/test/unit/tour.spec.js index 0a31a5ca6..28f670b60 100644 --- a/shepherd.js/test/unit/tour.spec.js +++ b/shepherd.js/test/unit/tour.spec.js @@ -817,4 +817,487 @@ describe('Tour | Top-Level Class', function () { expect(modalContainer.contains(modalElement)).toBe(true); }); }); + + describe('skipMissingElement / waitForElement', () => { + const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + + it('skips a step whose attachTo element is missing when skipMissingElement is true', () => { + instance = new Shepherd.Tour(); + + instance.addStep({ + id: 'first', + attachTo: { element: 'body', on: 'top' } + }); + + instance.addStep({ + id: 'missing', + attachTo: { element: '.does-not-exist-xyz', on: 'top' }, + skipMissingElement: true + }); + + instance.addStep({ + id: 'third', + attachTo: { element: 'body', on: 'top' } + }); + + instance.start(); + expect(instance.getCurrentStep().id, 'first step is shown').toBe('first'); + + instance.next(); + expect( + instance.getCurrentStep().id, + 'missing step is skipped, advancing to the third step' + ).toBe('third'); + }); + + it('applies skipMissingElement from defaultStepOptions', () => { + instance = new Shepherd.Tour({ + defaultStepOptions: { skipMissingElement: true } + }); + + instance.addStep({ + id: 'first', + attachTo: { element: 'body', on: 'top' } + }); + + instance.addStep({ + id: 'missing', + attachTo: { element: '.does-not-exist-xyz', on: 'top' } + }); + + instance.addStep({ + id: 'third', + attachTo: { element: 'body', on: 'top' } + }); + + instance.start(); + expect(instance.getCurrentStep().id, 'first step is shown').toBe('first'); + + instance.next(); + expect( + instance.getCurrentStep().id, + 'missing step is skipped via defaultStepOptions' + ).toBe('third'); + }); + + it('completes the tour when all trailing steps are skipped', () => { + instance = new Shepherd.Tour(); + + instance.addStep({ + id: 'first', + attachTo: { element: 'body', on: 'top' } + }); + + instance.addStep({ + id: 'missing-1', + attachTo: { element: '.does-not-exist-xyz', on: 'top' }, + skipMissingElement: true + }); + + instance.addStep({ + id: 'missing-2', + attachTo: { element: '.does-not-exist-abc', on: 'top' }, + skipMissingElement: true + }); + + let completeFired = false; + instance.on('complete', () => { + completeFired = true; + }); + + instance.start(); + expect(instance.getCurrentStep().id, 'first step is shown').toBe('first'); + + instance.next(); + + expect( + completeFired, + 'complete fires when all trailing steps are skipped' + ).toBe(true); + expect( + Shepherd.activeTour, + 'activeTour is null after the tour completes' + ).toBeNull(); + }); + + it('cancels when going back past a skipped first step', () => { + instance = new Shepherd.Tour(); + + instance.addStep({ + id: 'missing', + attachTo: { element: '.does-not-exist-xyz', on: 'top' }, + skipMissingElement: true + }); + + instance.addStep({ + id: 'second', + attachTo: { element: 'body', on: 'top' } + }); + + let cancelFired = false; + instance.on('cancel', () => { + cancelFired = true; + }); + + instance.start(); + expect( + instance.getCurrentStep().id, + 'missing first step is skipped on start' + ).toBe('second'); + + instance.back(); + + expect( + cancelFired, + 'cancel fires when going back past a skipped first step' + ).toBe(true); + expect(Shepherd.activeTour, 'activeTour is null after cancel').toBeNull(); + }); + + it('still shows centered steps that have no attachTo', () => { + instance = new Shepherd.Tour(); + + instance.addStep({ + id: 'centered', + skipMissingElement: true + }); + + instance.start(); + + expect( + instance.getCurrentStep().id, + 'a step with no attachTo is shown centered, not skipped' + ).toBe('centered'); + }); + + it('shows a step normally when its element exists even with skipMissingElement', () => { + instance = new Shepherd.Tour(); + + instance.addStep({ + id: 'exists', + attachTo: { element: 'body', on: 'top' }, + skipMissingElement: true + }); + + instance.start(); + + expect( + instance.getCurrentStep().id, + 'a step whose element exists is shown normally' + ).toBe('exists'); + }); + + it('waitForElement waits for the element to appear and then attaches', async () => { + instance = new Shepherd.Tour(); + + instance.addStep({ + id: 'first', + attachTo: { element: 'body', on: 'top' } + }); + + instance.addStep({ + id: 'second', + attachTo: { element: '.appears-later-xyz', on: 'top' }, + waitForElement: 1000 + }); + + instance.start(); + expect(instance.getCurrentStep().id, 'first step is shown').toBe('first'); + + const promise = instance.next(); + + let appearedElement; + setTimeout(() => { + appearedElement = document.createElement('div'); + appearedElement.classList.add('appears-later-xyz'); + document.body.appendChild(appearedElement); + }, 30); + + await promise; + + expect( + instance.getCurrentStep().id, + 'second step is shown once its element appears' + ).toBe('second'); + expect( + instance.getCurrentStep().target, + 'resolved target is the element that appeared' + ).toBe(appearedElement); + + document.body.removeChild(appearedElement); + }); + + it('falls back to a centered step when waitForElement times out', async () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + instance = new Shepherd.Tour(); + + instance.addStep({ + id: 'first', + attachTo: { element: 'body', on: 'top' } + }); + + instance.addStep({ + id: 'second', + attachTo: { element: '.does-not-exist-xyz', on: 'top' }, + waitForElement: 50 + }); + + instance.start(); + + await instance.next(); + + expect( + instance.getCurrentStep().id, + 'step is shown centered once the wait times out' + ).toBe('second'); + expect( + spy, + 'console.error is logged for the still-missing element' + ).toHaveBeenCalled(); + expect( + instance.getCurrentStep().target, + 'target is falsy since the element never resolved' + ).toBeFalsy(); + + spy.mockRestore(); + }); + + it('skips the step when waitForElement times out and skipMissingElement is true', async () => { + instance = new Shepherd.Tour(); + + instance.addStep({ + id: 'first', + attachTo: { element: 'body', on: 'top' } + }); + + instance.addStep({ + id: 'second', + attachTo: { element: '.does-not-exist-xyz', on: 'top' }, + waitForElement: 50, + skipMissingElement: true + }); + + instance.addStep({ + id: 'third', + attachTo: { element: 'body', on: 'top' } + }); + + instance.start(); + + await instance.next(); + + expect( + instance.getCurrentStep().id, + 'second step is skipped once the wait times out' + ).toBe('third'); + }); + + it('completes after waiting when trailing steps are skipped', async () => { + instance = new Shepherd.Tour(); + + instance.addStep({ + id: 'first', + attachTo: { element: 'body', on: 'top' } + }); + + instance.addStep({ + id: 'second', + attachTo: { element: '.does-not-exist-xyz', on: 'top' }, + waitForElement: 50, + skipMissingElement: true + }); + + let completeFired = false; + instance.on('complete', () => { + completeFired = true; + }); + + instance.start(); + + await instance.next(); + + expect( + completeFired, + 'complete fires once the trailing step finishes waiting and is skipped' + ).toBe(true); + }); + + it('a newer show() supersedes a pending waitForElement wait', async () => { + instance = new Shepherd.Tour(); + + instance.addStep({ + id: 'first', + attachTo: { element: 'body', on: 'top' } + }); + + instance.addStep({ + id: 'second', + attachTo: { element: '.does-not-exist-xyz', on: 'top' }, + waitForElement: 200 + }); + + instance.addStep({ + id: 'third', + attachTo: { element: 'body', on: 'top' } + }); + + instance.start(); + + const shownStepIds = []; + instance.on('show', ({ step }) => { + shownStepIds.push(step.id); + }); + + const promise = instance.show(1); + instance.show(2); + + expect( + instance.getCurrentStep().id, + 'show(2) synchronously supersedes the pending wait from show(1)' + ).toBe('third'); + + await promise; + await sleep(250); + + expect( + instance.getCurrentStep().id, + 'currentStep is still the third step after the superseded wait settles' + ).toBe('third'); + expect( + shownStepIds.includes('second'), + 'the show event never fires for the superseded second step' + ).toBe(false); + }); + + it('cancelling during a waitForElement wait does not resurrect the tour', async () => { + instance = new Shepherd.Tour(); + + instance.addStep({ + id: 'first', + attachTo: { element: 'body', on: 'top' } + }); + + instance.addStep({ + id: 'second', + attachTo: { element: '.does-not-exist-xyz', on: 'top' }, + waitForElement: 100, + skipMissingElement: true + }); + + instance.start(); + + let showFiredAfterCancel = false; + let completeFiredAfterCancel = false; + instance.on('show', () => { + showFiredAfterCancel = true; + }); + instance.on('complete', () => { + completeFiredAfterCancel = true; + }); + + const promise = instance.next(); + instance.cancel(); + + await promise; + await sleep(150); + + expect( + Shepherd.activeTour, + 'activeTour stays null after cancel' + ).toBeNull(); + expect(showFiredAfterCancel, 'no show event fires after cancel').toBe( + false + ); + expect( + completeFiredAfterCancel, + 'no complete event fires after cancel' + ).toBe(false); + }); + + it('awaits a wait on the step skipped to by skipMissingElement', async () => { + instance = new Shepherd.Tour(); + + instance.addStep({ + id: 'first', + attachTo: { element: 'body', on: 'top' } + }); + + instance.addStep({ + id: 'skipped', + attachTo: { element: '.does-not-exist-xyz', on: 'top' }, + skipMissingElement: true + }); + + instance.addStep({ + id: 'waits', + attachTo: { element: '.appears-after-skip-xyz', on: 'top' }, + waitForElement: 1000 + }); + + instance.start(); + + const promise = instance.next(); + + let appearedElement; + setTimeout(() => { + appearedElement = document.createElement('div'); + appearedElement.classList.add('appears-after-skip-xyz'); + document.body.appendChild(appearedElement); + }, 30); + + await promise; + + expect( + instance.getCurrentStep().id, + 'next() resolves only once the step skipped to has finished waiting' + ).toBe('waits'); + expect( + instance.getCurrentStep().target, + 'the waited-for element is the resolved target' + ).toBe(appearedElement); + + document.body.removeChild(appearedElement); + }); + + it('awaits a wait on the step skipped to by showOn', async () => { + instance = new Shepherd.Tour(); + + instance.addStep({ + id: 'first', + attachTo: { element: 'body', on: 'top' } + }); + + instance.addStep({ + id: 'skipped', + showOn: () => false + }); + + instance.addStep({ + id: 'waits', + attachTo: { element: '.appears-after-show-on-xyz', on: 'top' }, + waitForElement: 1000 + }); + + instance.start(); + + const promise = instance.next(); + + let appearedElement; + setTimeout(() => { + appearedElement = document.createElement('div'); + appearedElement.classList.add('appears-after-show-on-xyz'); + document.body.appendChild(appearedElement); + }, 30); + + await promise; + + expect( + instance.getCurrentStep().id, + 'next() resolves only once the step skipped to has finished waiting' + ).toBe('waits'); + + document.body.removeChild(appearedElement); + }); + }); }); diff --git a/shepherd.js/test/unit/utils/general.spec.js b/shepherd.js/test/unit/utils/general.spec.js index a2dcdfd23..397548e07 100644 --- a/shepherd.js/test/unit/utils/general.spec.js +++ b/shepherd.js/test/unit/utils/general.spec.js @@ -3,7 +3,9 @@ import { Step } from '../../../src/step'; import { parseAttachTo, shouldCenterStep, - parseExtraHighlights + parseExtraHighlights, + resolveAttachToElement, + waitForAttachToElement } from '../../../src/utils/general'; import { getFloatingUIOptions } from '../../../src/utils/floating-ui'; @@ -74,6 +76,266 @@ describe('General Utils', function () { parseAttachTo(step); }); + + it('logs a console.error when a selector does not resolve and skipMissingElement is not set', function () { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const step = new Step( + {}, + { + attachTo: { element: '.element-does-not-exist', on: 'center' } + } + ); + + parseAttachTo(step); + + expect( + spy, + 'console.error is called when skipMissingElement is not set' + ).toHaveBeenCalled(); + + spy.mockRestore(); + }); + + it('does not log a console.error when skipMissingElement is true', function () { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const step = new Step( + {}, + { + attachTo: { element: '.element-does-not-exist', on: 'center' }, + skipMissingElement: true + } + ); + + const { element } = parseAttachTo(step); + + expect(element, 'resolved element is still falsy').toBeFalsy(); + expect( + spy, + 'console.error is not called when skipMissingElement is true' + ).not.toHaveBeenCalled(); + + spy.mockRestore(); + }); + }); + + describe('resolveAttachToElement()', function () { + it('resolves a selector string to the element', function () { + const step = new Step( + {}, + { + attachTo: { element: '.options-test', on: 'center' } + } + ); + + const element = resolveAttachToElement(step); + expect(element).toBe(optionsElement); + }); + + it('returns null for a selector matching nothing', function () { + const step = new Step( + {}, + { + attachTo: { element: '.element-does-not-exist', on: 'center' } + } + ); + + const element = resolveAttachToElement(step); + expect(element).toBeNull(); + }); + + it('resolves a function returning an element', function () { + const step = new Step( + {}, + { + attachTo: { element: () => optionsElement, on: 'center' } + } + ); + + const element = resolveAttachToElement(step); + expect(element).toBe(optionsElement); + }); + + it('resolves a function returning a selector string', function () { + const step = new Step( + {}, + { + attachTo: { element: () => '.options-test', on: 'center' } + } + ); + + const element = resolveAttachToElement(step); + expect(element).toBe(optionsElement); + }); + + it('returns null when a function returns null', function () { + const step = new Step( + {}, + { + attachTo: { element: () => null, on: 'center' } + } + ); + + const element = resolveAttachToElement(step); + expect(element).toBeNull(); + }); + + it('returns null when the step has no attachTo', function () { + const step = new Step({}, {}); + + const element = resolveAttachToElement(step); + expect(element).toBeNull(); + }); + + it('returns null for an invalid selector without throwing', function () { + const step = new Step( + {}, + { + attachTo: { element: ':::', on: 'center' } + } + ); + + expect(() => resolveAttachToElement(step)).not.toThrow(); + expect(resolveAttachToElement(step)).toBeNull(); + }); + }); + + describe('waitForAttachToElement()', function () { + it('resolves immediately with the element when it already exists', async function () { + const step = new Step( + {}, + { + attachTo: { element: '.options-test', on: 'center' } + } + ); + + const element = await waitForAttachToElement(step, 1000); + expect(element).toBe(optionsElement); + }); + + it('resolves with the element when appended later', async function () { + const step = new Step( + {}, + { + attachTo: { element: '.added-later', on: 'center' } + } + ); + + const promise = waitForAttachToElement(step, 1000); + let addedElement; + + setTimeout(() => { + addedElement = document.createElement('div'); + addedElement.classList.add('added-later'); + document.body.appendChild(addedElement); + }, 20); + + const element = await promise; + + expect( + element, + 'resolves with the element appended after the wait started' + ).toBe(addedElement); + + document.body.removeChild(addedElement); + }); + + it('resolves with the element when an attribute change makes the selector match', async function () { + const div = document.createElement('div'); + document.body.appendChild(div); + + const step = new Step( + {}, + { + attachTo: { element: '.added-later-class', on: 'center' } + } + ); + + const promise = waitForAttachToElement(step, 1000); + + setTimeout(() => { + div.classList.add('added-later-class'); + }, 20); + + const element = await promise; + + expect( + element, + 'resolves with the element once its class matches the selector' + ).toBe(div); + + document.body.removeChild(div); + }); + + it('resolves with null after timeout when the element never appears', async function () { + const step = new Step( + {}, + { + attachTo: { element: '.never-appears-xyz', on: 'center' } + } + ); + + const element = await waitForAttachToElement(step, 50); + expect(element).toBeNull(); + }); + + it('resolves with null immediately when missing and timeout is 0', async function () { + const step = new Step( + {}, + { + attachTo: { element: '.never-appears-xyz', on: 'center' } + } + ); + + const element = await waitForAttachToElement(step, 0); + expect(element).toBeNull(); + }); + + it('falls back to polling when MutationObserver is unavailable', async function () { + vi.stubGlobal('MutationObserver', undefined); + + const step = new Step( + {}, + { + attachTo: { element: '.polled-for-xyz', on: 'center' } + } + ); + + const promise = waitForAttachToElement(step, 1000); + let addedElement; + + setTimeout(() => { + addedElement = document.createElement('div'); + addedElement.classList.add('polled-for-xyz'); + document.body.appendChild(addedElement); + }, 20); + + const element = await promise; + + expect( + element, + 'polling resolves with the element without a MutationObserver' + ).toBe(addedElement); + + document.body.removeChild(addedElement); + vi.unstubAllGlobals(); + }); + + it('resolves with null after timeout when polling and the element never appears', async function () { + vi.stubGlobal('MutationObserver', undefined); + + const step = new Step( + {}, + { + attachTo: { element: '.never-appears-xyz', on: 'center' } + } + ); + + const element = await waitForAttachToElement(step, 80); + + expect(element, 'polling gives up once the timeout expires').toBeNull(); + + vi.unstubAllGlobals(); + }); }); describe('parseExtraHighlights()', function () {