Custom jumbotron
++ Using a series of utilities, you can create this jumbotron, just + like the one in previous versions of Bootstrap. Check out the + examples below for how you can remix and restyle it to your + liking. +
+ +diff --git a/.jshintrc b/.jshintrc index 83344265f..e8327ab55 100644 --- a/.jshintrc +++ b/.jshintrc @@ -9,6 +9,7 @@ "exports": true, "define": true, "SVGElement": true, + "MutationObserver": true, "Element": true, "module": true, "console": true, diff --git a/src/core/fetchIntroSteps.js b/src/core/fetchIntroSteps.js index 3eb4bac9b..48299c291 100644 --- a/src/core/fetchIntroSteps.js +++ b/src/core/fetchIntroSteps.js @@ -26,14 +26,37 @@ export default function fetchIntroSteps(targetElm) { //use querySelector function only when developer used CSS selector if (typeof currentItem.element === "string") { //grab the element with given selector from the page - currentItem.element = document.querySelector(currentItem.element); - } - - //intro without element - if ( + const el = document.querySelector(currentItem.element); + if (el !== null) { + currentItem.element = el; + } else { + // If element is not exists yet, we'll get it on step + const elSelector = currentItem.element; + Object.defineProperty(currentItem, "element", { + get() { + if (typeof this._element === "string") { + const result = document.querySelector(this._element); + if (result === null) + throw new Error( + "There is no element with given selector: " + this._element + ); + return result; + } else { + return this._element; + } + }, + set(value) { + this._element = value; + }, + enumerable: true, + }); + currentItem.element = elSelector; + } + } else if ( typeof currentItem.element === "undefined" || currentItem.element === null ) { + //intro without element let floatingElementQuery = document.querySelector( ".introjsFloatingElement" ); @@ -58,7 +81,7 @@ export default function fetchIntroSteps(targetElm) { currentItem.disableInteraction = this._options.disableInteraction; } - if (currentItem.element !== null) { + if (currentItem._element !== null || currentItem.element !== null) { introItems.push(currentItem); } }); diff --git a/src/core/steps.js b/src/core/steps.js index b8593b695..7d4c3f7dc 100644 --- a/src/core/steps.js +++ b/src/core/steps.js @@ -1,6 +1,7 @@ import forEach from "../util/forEach"; import showElement from "./showElement"; import exitIntro from "./exitIntro"; +import waitForElement from "../util/waitForElement"; /** * Go to specific step of introduction @@ -37,6 +38,7 @@ export function goToStepNumber(step) { */ export function nextStep() { this._direction = "forward"; + if (this._waitingNextElement) return; if (typeof this._currentStepNumber !== "undefined") { forEach(this._introItems, ({ step }, i) => { @@ -59,7 +61,8 @@ export function nextStep() { if (typeof this._introBeforeChangeCallback !== "undefined") { continueStep = this._introBeforeChangeCallback.call( this, - nextStep && nextStep.element + nextStep && + (elementBySelectorNotExists(nextStep) ? undefined : nextStep.element) ); } @@ -79,7 +82,25 @@ export function nextStep() { return; } - showElement.call(this, nextStep); + if (elementBySelectorNotExists(nextStep)) { + this._waitingNextElement = true; + waitForElement(nextStep._element, () => { + this._waitingNextElement = false; + showElement.call(this, nextStep); + }); + } else { + showElement.call(this, nextStep); + } +} + +/** + * Return true if element locates by selector and doesn't exists yet + */ +function elementBySelectorNotExists(step) { + return ( + typeof step._element === "string" && + document.querySelector(step._element) === null + ); } /** diff --git a/src/util/waitForElement.js b/src/util/waitForElement.js new file mode 100644 index 000000000..06932d887 --- /dev/null +++ b/src/util/waitForElement.js @@ -0,0 +1,62 @@ +/** + * Waits until Element will appear + * + * @api private + * @method _waitForElement + * @param {string} elSelector Selector to locate Element + * @param {() => void} callback Callback to be called after Element appearance + */ +export default function _waitForElement(elSelector, callback) { + if (document.querySelector(elSelector) !== null) { + callback(); + return; + } + + if (typeof MutationObserver !== "undefined") { + const observer = new MutationObserver(() => { + if (document.querySelector(elSelector) !== null) { + observer.disconnect(); + callback(); + } + }); + + observer.observe(document.body, { + childList: true, + subtree: true, + attributes: false, + characterData: false, + }); + } else { + // Old browsers will wait by timeout + _waitForElementByTimeout(elSelector, callback, 1000, 10000); + } +} + +/** + * @param {string} elSelector + * @param {() => void} callback + * @param {number} checkInterval In milliseconds + * @param {number} maxTimeout In milliseconds + */ +export function _waitForElementByTimeout( + elSelector, + callback, + checkInterval, + maxTimeout +) { + let startTimeInMs = Date.now(); + (function loopSearch() { + if (document.querySelector(elSelector) !== null) { + callback(); + return; + } else { + setTimeout(function () { + if (Date.now() - startTimeInMs > maxTimeout) { + callback(); + return; + } + loopSearch(); + }, checkInterval); + } + })(); +} diff --git a/tests/core/fetchIntroSteps.test.js b/tests/core/fetchIntroSteps.test.js index 5b5e95b34..dd8f937f7 100644 --- a/tests/core/fetchIntroSteps.test.js +++ b/tests/core/fetchIntroSteps.test.js @@ -10,6 +10,7 @@ describe("fetchIntroSteps", () => { steps: [ { element: "#element_does_not_exist", + position: "top", intro: "hello world", }, { @@ -23,7 +24,7 @@ describe("fetchIntroSteps", () => { expect(steps.length).toBe(2); - expect(steps[0].position).toBe("floating"); + expect(steps[0].position).toBe("top"); expect(steps[0].intro).toBe("hello world"); expect(steps[0].step).toBe(1); @@ -80,11 +81,44 @@ describe("fetchIntroSteps", () => { expect(steps[1].intro).toBe("second"); expect(steps[1].step).toBe(2); - expect(steps[2].position).toBe("floating"); + expect(steps[2].position).toBe("bottom"); expect(steps[2].intro).toBe("third"); expect(steps[2].step).toBe(3); }); + test("should throw an error on calling step.element if it is not exists yet and return element after adding", () => { + const targetElement = document.createElement("div"); + const elId = "later_added"; + const steps = fetchIntroSteps.call( + { + _options: { + tooltipPosition: "bottom", + steps: [ + { + element: "#" + elId, + }, + ], + }, + }, + targetElement + ); + + expect(steps.length).toBe(1); + + try { + const element = steps[0].element; // jshint ignore:line + } catch (e) { + if (!e.message.includes("There is no element with given selector:")) + throw e; + } + + const laterAdded = document.createElement("div"); + laterAdded.setAttribute("id", elId); + document.body.appendChild(laterAdded); + + expect(steps[0].element).toBe(laterAdded); + }); + test("should find the data-* elements from the DOM", () => { const targetElement = document.createElement("div"); diff --git a/tests/cypress/integration/tour/added-later-element.js b/tests/cypress/integration/tour/added-later-element.js new file mode 100644 index 000000000..66707c383 --- /dev/null +++ b/tests/cypress/integration/tour/added-later-element.js @@ -0,0 +1,47 @@ +context("Added later element", () => { + const addedLaterElId = "later_added"; + const stepOneText = "step one"; + const stepTwoText = "step two, click on create btn"; + const stepThreeText = "added later element"; + const createDivBtnSelector = "#create-div-button"; + beforeEach(() => { + cy.visit("./cypress/setup/create_div_btn.html").then((window) => { + window + .introJs() + .setOptions({ + disableInteraction: false, + steps: [ + { + intro: stepOneText, + }, + { + intro: stepTwoText, + element: createDivBtnSelector, + }, + { + intro: stepThreeText, + element: "#" + addedLaterElId, + }, + ], + }) + .start(); + }); + }); + + it("should find by selector and highlight added later element", () => { + cy.get(".introjs-tooltiptext").contains(stepOneText); + cy.nextStep(); + cy.get(".introjs-tooltiptext").contains(stepTwoText); + cy.wait(500); + cy.get(createDivBtnSelector).click(); + cy.nextStep(); + cy.wait(500); + cy.get("#" + addedLaterElId) + .filter(".introjs-showElement") + .contains("Later added div"); + cy.wait(2000); + cy.compareSnapshot("added-later-element-end", 0.05); + cy.doneButton(); + cy.get(".introjs-showElement").should("not.exist"); + }); +}); diff --git a/tests/cypress/integration/tour/navigation.js b/tests/cypress/integration/tour/navigation.js index cc146831a..1301bec04 100644 --- a/tests/cypress/integration/tour/navigation.js +++ b/tests/cypress/integration/tour/navigation.js @@ -47,6 +47,14 @@ context("Navigation", () => { cy.get(".introjs-showElement").should("not.exist"); }); + it("should exit the tour after right btn pressed at the end", () => { + cy.get(".introjs-tooltiptext").contains("step one"); + cy.nextStep(); + cy.get(".introjs-tooltiptext").contains("step two"); + cy.realPress("ArrowRight"); + cy.get(".introjs-showElement").should("not.exist"); + }); + it("should close the tour after clicking on the exit button", () => { cy.get(".introjs-showElement").should("exist"); cy.get(".introjs-skipbutton").click(); diff --git a/tests/cypress/setup/create_div_btn.html b/tests/cypress/setup/create_div_btn.html new file mode 100644 index 000000000..55e4f7caa --- /dev/null +++ b/tests/cypress/setup/create_div_btn.html @@ -0,0 +1,121 @@ + + +
+ + ++ Using a series of utilities, you can create this jumbotron, just + like the one in previous versions of Bootstrap. Check out the + examples below for how you can remix and restyle it to your + liking. +
+ +