diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index dd15d3162..32d1fe6fd 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -17,8 +17,8 @@ jobs: uses: actions/setup-node@v1 with: node-version: ${{ matrix.node-version }} - - run: npm install + - run: npm ci - run: npm run build - - run: npm test + - run: npm run test env: CI: true diff --git a/.gitignore b/.gitignore index 5047bfc61..6a4420618 100644 --- a/.gitignore +++ b/.gitignore @@ -37,7 +37,7 @@ dist/ .changelog # Tests -tests/cypress/setup/dist -tests/cypress/videos/ -tests/cypress/snapshots/actual -tests/cypress/snapshots/diff +cypress/setup/dist +cypress/videos/ +cypress/snapshots/actual +cypress/snapshots/diff diff --git a/cypress.config.ts b/cypress.config.ts new file mode 100644 index 000000000..a365f0dac --- /dev/null +++ b/cypress.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from "cypress"; +import { configureVisualRegression } from "cypress-visual-regression"; + +module.exports = defineConfig({ + trashAssetsBeforeRuns: true, + env: { + failSilently: false, + }, + e2e: { + screenshotsFolder: "./cypress/snapshots/actual", + supportFile: "./cypress/support/index.ts", + specPattern: "**/*.*cy.*", + setupNodeEvents(on, config) { + configureVisualRegression(on); + + return config; + }, + }, +}); diff --git a/cypress/plugins/index.ts b/cypress/plugins/index.ts new file mode 100644 index 000000000..eb7e40298 --- /dev/null +++ b/cypress/plugins/index.ts @@ -0,0 +1,4 @@ +module.exports = (on, config) => { + const getCompareSnapshotsPlugin = require("cypress-visual-regression/dist/plugin"); + getCompareSnapshotsPlugin(on, config); +}; diff --git a/tests/cypress/setup/index.html b/cypress/setup/index.html similarity index 94% rename from tests/cypress/setup/index.html rename to cypress/setup/index.html index a05a22b0b..c2a6e9cbb 100644 --- a/tests/cypress/setup/index.html +++ b/cypress/setup/index.html @@ -14,7 +14,8 @@ crossorigin="anonymous" /> - + + class="pt-3 mt-4 text-muted border-top" data-hint="this is the footer" > - © 2021 + © YEAR @@ -178,6 +179,7 @@

console.log(3); }; - + + diff --git a/tests/cypress/snapshots/base/tour/dont-show-again.cy.js/dont-show-again-checkbox-first-step-base.png b/cypress/snapshots/base/src/packages/tour/dont-show-again.cy.ts/dont_show_again_checkbox_first_step.png similarity index 100% rename from tests/cypress/snapshots/base/tour/dont-show-again.cy.js/dont-show-again-checkbox-first-step-base.png rename to cypress/snapshots/base/src/packages/tour/dont-show-again.cy.ts/dont_show_again_checkbox_first_step.png diff --git a/tests/cypress/snapshots/base/tour/dont-show-again.cy.js/dont-show-again-checkbox-second-step-base.png b/cypress/snapshots/base/src/packages/tour/dont-show-again.cy.ts/dont_show_again_checkbox_second_step.png similarity index 100% rename from tests/cypress/snapshots/base/tour/dont-show-again.cy.js/dont-show-again-checkbox-second-step-base.png rename to cypress/snapshots/base/src/packages/tour/dont-show-again.cy.ts/dont_show_again_checkbox_second_step.png diff --git a/tests/cypress/snapshots/base/tour/dont-show-again.cy.js/dont-show-again-clicked-after-exit-base.png b/cypress/snapshots/base/src/packages/tour/dont-show-again.cy.ts/dont_show_again_clicked_after_exit.png similarity index 100% rename from tests/cypress/snapshots/base/tour/dont-show-again.cy.js/dont-show-again-clicked-after-exit-base.png rename to cypress/snapshots/base/src/packages/tour/dont-show-again.cy.ts/dont_show_again_clicked_after_exit.png diff --git a/tests/cypress/snapshots/base/tour/dont-show-again.cy.js/dont-show-again-clicked-after-second-start-base.png b/cypress/snapshots/base/src/packages/tour/dont-show-again.cy.ts/dont_show_again_clicked_after_second_start.png similarity index 100% rename from tests/cypress/snapshots/base/tour/dont-show-again.cy.js/dont-show-again-clicked-after-second-start-base.png rename to cypress/snapshots/base/src/packages/tour/dont-show-again.cy.ts/dont_show_again_clicked_after_second_start.png diff --git a/tests/cypress/snapshots/base/tour/dont-show-again.cy.js/dont-show-again-clicked-first-step-base.png b/cypress/snapshots/base/src/packages/tour/dont-show-again.cy.ts/dont_show_again_clicked_first_step.png similarity index 100% rename from tests/cypress/snapshots/base/tour/dont-show-again.cy.js/dont-show-again-clicked-first-step-base.png rename to cypress/snapshots/base/src/packages/tour/dont-show-again.cy.ts/dont_show_again_clicked_first_step.png diff --git a/tests/cypress/snapshots/base/tour/highlight.cy.js/highlight-element-first-step-base.png b/cypress/snapshots/base/src/packages/tour/highlight.cy.ts/highlight_element_first_step.png similarity index 100% rename from tests/cypress/snapshots/base/tour/highlight.cy.js/highlight-element-first-step-base.png rename to cypress/snapshots/base/src/packages/tour/highlight.cy.ts/highlight_element_first_step.png diff --git a/tests/cypress/snapshots/base/tour/highlight.cy.js/highlight-element-second-step-base.png b/cypress/snapshots/base/src/packages/tour/highlight.cy.ts/highlight_element_second_step.png similarity index 100% rename from tests/cypress/snapshots/base/tour/highlight.cy.js/highlight-element-second-step-base.png rename to cypress/snapshots/base/src/packages/tour/highlight.cy.ts/highlight_element_second_step.png diff --git a/tests/cypress/snapshots/base/tour/highlight.cy.js/highlight-fixed-element-base.png b/cypress/snapshots/base/src/packages/tour/highlight.cy.ts/highlight_fixed_element.png similarity index 100% rename from tests/cypress/snapshots/base/tour/highlight.cy.js/highlight-fixed-element-base.png rename to cypress/snapshots/base/src/packages/tour/highlight.cy.ts/highlight_fixed_element.png diff --git a/tests/cypress/snapshots/base/tour/highlight.cy.js/highlight-fixed-element-scroll-base.png b/cypress/snapshots/base/src/packages/tour/highlight.cy.ts/highlight_fixed_element_scroll.png similarity index 100% rename from tests/cypress/snapshots/base/tour/highlight.cy.js/highlight-fixed-element-scroll-base.png rename to cypress/snapshots/base/src/packages/tour/highlight.cy.ts/highlight_fixed_element_scroll.png diff --git a/tests/cypress/snapshots/base/tour/highlight.cy.js/highlight-fixed-parent-element-scroll-base.png b/cypress/snapshots/base/src/packages/tour/highlight.cy.ts/highlight_fixed_parent_element_scroll.png similarity index 100% rename from tests/cypress/snapshots/base/tour/highlight.cy.js/highlight-fixed-parent-element-scroll-base.png rename to cypress/snapshots/base/src/packages/tour/highlight.cy.ts/highlight_fixed_parent_element_scroll.png diff --git a/tests/cypress/snapshots/base/tour/modal.cy.js/exit-base.png b/cypress/snapshots/base/src/packages/tour/modal.cy.ts/exit.png similarity index 100% rename from tests/cypress/snapshots/base/tour/modal.cy.js/exit-base.png rename to cypress/snapshots/base/src/packages/tour/modal.cy.ts/exit.png diff --git a/tests/cypress/snapshots/base/tour/modal.cy.js/first-step-base.png b/cypress/snapshots/base/src/packages/tour/modal.cy.ts/first_step.png similarity index 100% rename from tests/cypress/snapshots/base/tour/modal.cy.js/first-step-base.png rename to cypress/snapshots/base/src/packages/tour/modal.cy.ts/first_step.png diff --git a/tests/cypress/snapshots/base/tour/modal.cy.js/position-bottom-base.png b/cypress/snapshots/base/src/packages/tour/modal.cy.ts/position_bottom.png similarity index 100% rename from tests/cypress/snapshots/base/tour/modal.cy.js/position-bottom-base.png rename to cypress/snapshots/base/src/packages/tour/modal.cy.ts/position_bottom.png diff --git a/tests/cypress/snapshots/base/tour/modal.cy.js/position-left-base.png b/cypress/snapshots/base/src/packages/tour/modal.cy.ts/position_left.png similarity index 100% rename from tests/cypress/snapshots/base/tour/modal.cy.js/position-left-base.png rename to cypress/snapshots/base/src/packages/tour/modal.cy.ts/position_left.png diff --git a/tests/cypress/snapshots/base/tour/modal.cy.js/position-right-base.png b/cypress/snapshots/base/src/packages/tour/modal.cy.ts/position_right.png similarity index 100% rename from tests/cypress/snapshots/base/tour/modal.cy.js/position-right-base.png rename to cypress/snapshots/base/src/packages/tour/modal.cy.ts/position_right.png diff --git a/tests/cypress/snapshots/base/tour/modal.cy.js/refresh-first-step-base.png b/cypress/snapshots/base/src/packages/tour/modal.cy.ts/refresh_first_step.png similarity index 100% rename from tests/cypress/snapshots/base/tour/modal.cy.js/refresh-first-step-base.png rename to cypress/snapshots/base/src/packages/tour/modal.cy.ts/refresh_first_step.png diff --git a/tests/cypress/snapshots/base/tour/modal.cy.js/refresh-second-step-base.png b/cypress/snapshots/base/src/packages/tour/modal.cy.ts/refresh_second_step.png similarity index 100% rename from tests/cypress/snapshots/base/tour/modal.cy.js/refresh-second-step-base.png rename to cypress/snapshots/base/src/packages/tour/modal.cy.ts/refresh_second_step.png diff --git a/tests/cypress/snapshots/base/tour/modal.cy.js/refresh-third-step-base.png b/cypress/snapshots/base/src/packages/tour/modal.cy.ts/refresh_third_step.png similarity index 100% rename from tests/cypress/snapshots/base/tour/modal.cy.js/refresh-third-step-base.png rename to cypress/snapshots/base/src/packages/tour/modal.cy.ts/refresh_third_step.png diff --git a/tests/cypress/snapshots/base/tour/modal.cy.js/second-step-base.png b/cypress/snapshots/base/src/packages/tour/modal.cy.ts/second_step.png similarity index 100% rename from tests/cypress/snapshots/base/tour/modal.cy.js/second-step-base.png rename to cypress/snapshots/base/src/packages/tour/modal.cy.ts/second_step.png diff --git a/tests/cypress/snapshots/base/tour/progressbar.cy.js/exit-base.png b/cypress/snapshots/base/src/packages/tour/progressbar.cy.ts/exit.png similarity index 100% rename from tests/cypress/snapshots/base/tour/progressbar.cy.js/exit-base.png rename to cypress/snapshots/base/src/packages/tour/progressbar.cy.ts/exit.png diff --git a/tests/cypress/snapshots/base/tour/progressbar.cy.js/first-step-base.png b/cypress/snapshots/base/src/packages/tour/progressbar.cy.ts/first_step.png similarity index 100% rename from tests/cypress/snapshots/base/tour/progressbar.cy.js/first-step-base.png rename to cypress/snapshots/base/src/packages/tour/progressbar.cy.ts/first_step.png diff --git a/tests/cypress/snapshots/base/tour/progressbar.cy.js/second-step-base.png b/cypress/snapshots/base/src/packages/tour/progressbar.cy.ts/second_step.png similarity index 100% rename from tests/cypress/snapshots/base/tour/progressbar.cy.js/second-step-base.png rename to cypress/snapshots/base/src/packages/tour/progressbar.cy.ts/second_step.png diff --git a/cypress/snapshots/diff/tour/dont-show-again.cy.js/dont-show-again-checkbox-first-step-diff.png b/cypress/snapshots/diff/tour/dont-show-again.cy.js/dont-show-again-checkbox-first-step-diff.png new file mode 100644 index 000000000..a3dcc6eb5 Binary files /dev/null and b/cypress/snapshots/diff/tour/dont-show-again.cy.js/dont-show-again-checkbox-first-step-diff.png differ diff --git a/cypress/snapshots/diff/tour/dont-show-again.cy.js/dont-show-again-clicked-first-step-diff.png b/cypress/snapshots/diff/tour/dont-show-again.cy.js/dont-show-again-clicked-first-step-diff.png new file mode 100644 index 000000000..0a315441c Binary files /dev/null and b/cypress/snapshots/diff/tour/dont-show-again.cy.js/dont-show-again-clicked-first-step-diff.png differ diff --git a/cypress/snapshots/diff/tour/highlight.cy.js/highlight-element-first-step-diff.png b/cypress/snapshots/diff/tour/highlight.cy.js/highlight-element-first-step-diff.png new file mode 100644 index 000000000..e44670a66 Binary files /dev/null and b/cypress/snapshots/diff/tour/highlight.cy.js/highlight-element-first-step-diff.png differ diff --git a/cypress/snapshots/diff/tour/highlight.cy.js/highlight-fixed-element-diff.png b/cypress/snapshots/diff/tour/highlight.cy.js/highlight-fixed-element-diff.png new file mode 100644 index 000000000..e786f1dad Binary files /dev/null and b/cypress/snapshots/diff/tour/highlight.cy.js/highlight-fixed-element-diff.png differ diff --git a/cypress/snapshots/diff/tour/highlight.cy.js/highlight-fixed-element-scroll-diff.png b/cypress/snapshots/diff/tour/highlight.cy.js/highlight-fixed-element-scroll-diff.png new file mode 100644 index 000000000..45dea2c54 Binary files /dev/null and b/cypress/snapshots/diff/tour/highlight.cy.js/highlight-fixed-element-scroll-diff.png differ diff --git a/cypress/snapshots/diff/tour/highlight.cy.js/highlight-fixed-parent-element-scroll-diff.png b/cypress/snapshots/diff/tour/highlight.cy.js/highlight-fixed-parent-element-scroll-diff.png new file mode 100644 index 000000000..361d263ce Binary files /dev/null and b/cypress/snapshots/diff/tour/highlight.cy.js/highlight-fixed-parent-element-scroll-diff.png differ diff --git a/cypress/snapshots/diff/tour/modal.cy.js/first-step-diff.png b/cypress/snapshots/diff/tour/modal.cy.js/first-step-diff.png new file mode 100644 index 000000000..e44670a66 Binary files /dev/null and b/cypress/snapshots/diff/tour/modal.cy.js/first-step-diff.png differ diff --git a/cypress/snapshots/diff/tour/modal.cy.js/position-bottom-diff.png b/cypress/snapshots/diff/tour/modal.cy.js/position-bottom-diff.png new file mode 100644 index 000000000..164cb4ea8 Binary files /dev/null and b/cypress/snapshots/diff/tour/modal.cy.js/position-bottom-diff.png differ diff --git a/cypress/snapshots/diff/tour/modal.cy.js/refresh-first-step-diff.png b/cypress/snapshots/diff/tour/modal.cy.js/refresh-first-step-diff.png new file mode 100644 index 000000000..74dd5ce46 Binary files /dev/null and b/cypress/snapshots/diff/tour/modal.cy.js/refresh-first-step-diff.png differ diff --git a/cypress/snapshots/diff/tour/progressbar.cy.js/first-step-diff.png b/cypress/snapshots/diff/tour/progressbar.cy.js/first-step-diff.png new file mode 100644 index 000000000..90cfc96f7 Binary files /dev/null and b/cypress/snapshots/diff/tour/progressbar.cy.js/first-step-diff.png differ diff --git a/cypress/support/index.d.ts b/cypress/support/index.d.ts new file mode 100644 index 000000000..1e543e4e0 --- /dev/null +++ b/cypress/support/index.d.ts @@ -0,0 +1,13 @@ + +declare namespace Cypress { + interface Chainable{ + nextStep(): Chainable; + prevStep(): Chainable; + } + interface Window { + introJs: any; + click: () => void; + clickRelative: () => void; + clickAbsolute: () => void; + } +} diff --git a/cypress/support/index.ts b/cypress/support/index.ts new file mode 100644 index 000000000..daca73448 --- /dev/null +++ b/cypress/support/index.ts @@ -0,0 +1,24 @@ +/// + +import { addCompareSnapshotCommand } from "cypress-visual-regression/dist/command"; + +addCompareSnapshotCommand({ + capture: "fullPage", +}); + +Cypress.Commands.add("nextStep", () => { + cy.get(".introjs-nextbutton").click(); +}); + +Cypress.Commands.add("prevStep", () => { + cy.get(".introjs-prevbutton").click(); +}); + +Cypress.on("window:before:load", (win) => { + const htmlNode = win.document.querySelector("html"); + const node = win.document.createElement("style"); + node.innerHTML = "html { scroll-behavior: inherit !important; }"; + htmlNode?.appendChild(node); +}); + +import "cypress-real-events/support"; diff --git a/jest.config.js b/jest.config.js index 837b2d397..edfe6e42d 100644 --- a/jest.config.js +++ b/jest.config.js @@ -2,7 +2,7 @@ module.exports = { testEnvironment: 'node', clearMocks: true, setupFilesAfterEnv: ["jest-extended/all"], - roots: ["/tests/jest"], + roots: ["/tests/jest", "/src"], transform: { '^.+\\.tsx?$': ['ts-jest', { ...require('./tsconfig.test.json') @@ -17,6 +17,7 @@ module.exports = { coverageDirectory: 'coverage', collectCoverageFrom: [ 'src/**/*.{ts,tsx,js,jsx}', + '!src/**/*.cy.ts', '!src/**/*.d.ts', '!src/**/*.test.ts', ], diff --git a/package-lock.json b/package-lock.json index 29b0d45ab..72e3f6153 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "intro.js", - "version": "7.2.0", + "version": "8.0.0-beta.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "intro.js", - "version": "7.2.0", + "version": "8.0.0-beta.1", "license": "AGPL-3.0", "devDependencies": { "@babel/core": "^7.12.3", @@ -21,14 +21,13 @@ "autoprefixer": "^9.0.0", "babel-jest": "^29.2.2", "core-js": "^3.6.5", - "cypress": "^12.8.1", - "cypress-real-events": "^1.7.6", - "cypress-visual-regression": "^2.1.1", + "cypress": "^13.12.0", + "cypress-real-events": "^1.13.0", + "cypress-visual-regression": "^5.0.2", "eslint": "^8.0.1", "jest": "^29.5.0", "jest-extended": "^3.2.4", "jsdom": "^21.1.1", - "jshint": "^2.12.0", "lerna-changelog": "^2.1.0", "minify": "^9.1.0", "node-sass": "^8.0.0", @@ -47,7 +46,9 @@ "rollup-plugin-node-resolve": "^5.2.0", "rollup-plugin-postcss": "^4.0.0", "rollup-plugin-progress": "^1.1.2", + "rollup-plugin-serve": "^1.1.1", "rollup-plugin-terser": "^7.0.2", + "start-server-and-test": "^2.0.4", "ts-jest": "^29.1.1", "tslib": "^2.6.0", "typescript": "^5.1.6" @@ -1857,9 +1858,9 @@ "dev": true }, "node_modules/@cypress/request": { - "version": "2.88.12", - "resolved": "https://registry.npmjs.org/@cypress/request/-/request-2.88.12.tgz", - "integrity": "sha512-tOn+0mDZxASFM+cuAP9szGUGPI1HwWVSvdzm7V4cCsPdFTx6qMj29CwaQmRAMIEhORIUBFBsYROYJcveK4uOjA==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@cypress/request/-/request-3.0.1.tgz", + "integrity": "sha512-TWivJlJi8ZDx2wGOw1dbLuHJKUYX7bWySw377nlnGOW3hP9/MUKIsEdXT/YngWxVdgNCHRBmFlBipE+5/2ZZlQ==", "dev": true, "dependencies": { "aws-sign2": "~0.7.0", @@ -1875,7 +1876,7 @@ "json-stringify-safe": "~5.0.1", "mime-types": "~2.1.19", "performance-now": "^2.1.0", - "qs": "~6.10.3", + "qs": "6.10.4", "safe-buffer": "^5.1.2", "tough-cookie": "^4.1.3", "tunnel-agent": "^0.6.0", @@ -2011,6 +2012,21 @@ "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", "dev": true }, + "node_modules/@hapi/hoek": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", + "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", + "dev": true + }, + "node_modules/@hapi/topo": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", + "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", + "dev": true, + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.10.tgz", @@ -3498,6 +3514,27 @@ } } }, + "node_modules/@sideway/address": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", + "integrity": "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==", + "dev": true, + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, + "node_modules/@sideway/formula": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz", + "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==", + "dev": true + }, + "node_modules/@sideway/pinpoint": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", + "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==", + "dev": true + }, "node_modules/@sigstore/protobuf-specs": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/@sigstore/protobuf-specs/-/protobuf-specs-0.1.0.tgz", @@ -4007,6 +4044,12 @@ "safe-buffer": "~5.2.0" } }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true + }, "node_modules/argparse": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", @@ -4149,9 +4192,40 @@ } }, "node_modules/aws4": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.12.0.tgz", - "integrity": "sha512-NmWvPnx0F1SfrQbYwOi7OeaNGokp9XhzNioJ/CSBs8Qa4vxug81mhJEAVZwxXuBmYB5KDRfMq/F3RR0BIU7sWg==", + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.0.tgz", + "integrity": "sha512-3AungXC4I8kKsS9PuS4JH2nc+0bVY/mjgrephHTIi8fpEeGsTHBUJeosp0Wc1myYMElmD0B3Oc4XL/HVJ4PV2g==", + "dev": true + }, + "node_modules/axios": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.2.tgz", + "integrity": "sha512-2A8QhOMrbomlDuiLeK9XibIBzuHeRcqqNOHp0Cyp5EoJ1IFDh+XZH3A6BkXtv0K4gFGCI0Y4BM7B1wOEi0Rmgw==", + "dev": true, + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/axios/node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dev": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/axios/node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "dev": true }, "node_modules/babel-jest": { @@ -4534,12 +4608,12 @@ } }, "node_modules/braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, "dependencies": { - "fill-range": "^7.0.1" + "fill-range": "^7.1.1" }, "engines": { "node": ">=8" @@ -4809,9 +4883,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001519", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001519.tgz", - "integrity": "sha512-0QHgqR+Jv4bxHMp8kZ1Kn8CH55OikjKJ6JmKkZYP1F3D7w+lnFXF70nG5eNfsZS89jadi5Ywy5UCSKLAglIRkg==", + "version": "1.0.30001640", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001640.tgz", + "integrity": "sha512-lA4VMpW0PSUrFnkmVuEKBUovSWKhj7puyCg8StBChgu298N1AtuF1sKWEvfDuimSEDbhlb/KqPKC3fs1HbuQUA==", "dev": true, "funding": [ { @@ -4914,19 +4988,6 @@ "node": ">=6" } }, - "node_modules/cli": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/cli/-/cli-1.0.1.tgz", - "integrity": "sha512-41U72MB56TfUMGndAKK8vJ78eooOD4Z5NOL4xEfjc0c23s+6EYKXlXsmACBVclLP1yOfWCgEganVzddVrSNoTg==", - "dev": true, - "dependencies": { - "exit": "0.1.2", - "glob": "^7.1.1" - }, - "engines": { - "node": ">=0.2.5" - } - }, "node_modules/cli-boxes": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-2.2.1.tgz", @@ -5211,15 +5272,6 @@ "source-map": "^0.6.1" } }, - "node_modules/console-browserify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/console-browserify/-/console-browserify-1.1.0.tgz", - "integrity": "sha512-duS7VP5pvfsNLDvL1O4VOEbw37AI3A4ZUQYemvDlnpGrNu9tprR7BYWpDYwC0Xia0Zxz5ZupdiIrUp0GH1aXfg==", - "dev": true, - "dependencies": { - "date-now": "^0.1.4" - } - }, "node_modules/console-control-strings": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", @@ -5521,21 +5573,20 @@ } }, "node_modules/cypress": { - "version": "12.17.4", - "resolved": "https://registry.npmjs.org/cypress/-/cypress-12.17.4.tgz", - "integrity": "sha512-gAN8Pmns9MA5eCDFSDJXWKUpaL3IDd89N9TtIupjYnzLSmlpVr+ZR+vb4U/qaMp+lB6tBvAmt7504c3Z4RU5KQ==", + "version": "13.12.0", + "resolved": "https://registry.npmjs.org/cypress/-/cypress-13.12.0.tgz", + "integrity": "sha512-udzS2JilmI9ApO/UuqurEwOvThclin5ntz7K0BtnHBs+tg2Bl9QShLISXpSEMDv/u8b6mqdoAdyKeZiSqKWL8g==", "dev": true, "hasInstallScript": true, "dependencies": { - "@cypress/request": "2.88.12", + "@cypress/request": "^3.0.0", "@cypress/xvfb": "^1.2.4", - "@types/node": "^16.18.39", "@types/sinonjs__fake-timers": "8.1.1", "@types/sizzle": "^2.3.2", "arch": "^2.2.0", "blob-util": "^2.0.2", "bluebird": "^3.7.2", - "buffer": "^5.6.0", + "buffer": "^5.7.1", "cachedir": "^2.3.0", "chalk": "^4.1.0", "check-more-types": "^2.24.0", @@ -5553,7 +5604,7 @@ "figures": "^3.2.0", "fs-extra": "^9.1.0", "getos": "^3.2.1", - "is-ci": "^3.0.0", + "is-ci": "^3.0.1", "is-installed-globally": "~0.4.0", "lazy-ass": "^1.6.0", "listr2": "^3.8.3", @@ -5575,38 +5626,106 @@ "cypress": "bin/cypress" }, "engines": { - "node": "^14.0.0 || ^16.0.0 || >=18.0.0" + "node": "^16.0.0 || ^18.0.0 || >=20.0.0" } }, "node_modules/cypress-real-events": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/cypress-real-events/-/cypress-real-events-1.7.6.tgz", - "integrity": "sha512-yP6GnRrbm6HK5q4DH6Nnupz37nOfZu/xn1xFYqsE2o4G73giPWQOdu6375QYpwfU1cvHNCgyD2bQ2hPH9D7NMw==", + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/cypress-real-events/-/cypress-real-events-1.13.0.tgz", + "integrity": "sha512-LoejtK+dyZ1jaT8wGT5oASTPfsNV8/ClRp99ruN60oPj8cBJYod80iJDyNwfPAu4GCxTXOhhAv9FO65Hpwt6Hg==", "dev": true, "peerDependencies": { - "cypress": "^4.x || ^5.x || ^6.x || ^7.x || ^8.x || ^9.x || ^10.x || ^11.x || ^12.x" + "cypress": "^4.x || ^5.x || ^6.x || ^7.x || ^8.x || ^9.x || ^10.x || ^11.x || ^12.x || ^13.x" } }, "node_modules/cypress-visual-regression": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/cypress-visual-regression/-/cypress-visual-regression-2.1.1.tgz", - "integrity": "sha512-oVDBL3hEMd6luj7eYLXzaNbqKPT8e1ZDGg/mptCRlIgw/uo09zv5TRHe6eqptPuZH8qFpfG3Eijk656zP2PcZg==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/cypress-visual-regression/-/cypress-visual-regression-5.0.2.tgz", + "integrity": "sha512-Rr/W2uSw1KxG5AE9q6MT8yZfT81GaLVO0z/QyKg9pCZ7XvObUklyh9j5gWe7yUzDnY/2g9XpczZKNKL2zZ2PAg==", "dev": true, "dependencies": { + "chalk": "^4.1.2", "pixelmatch": "^5.2.1", "pngjs": "^6.0.0", "sanitize-filename": "^1.6.3" }, + "engines": { + "node": ">=18" + }, "peerDependencies": { - "cypress": ">=9.7.0" + "cypress": ">=12" + } + }, + "node_modules/cypress-visual-regression/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cypress-visual-regression/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cypress-visual-regression/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" } }, - "node_modules/cypress/node_modules/@types/node": { - "version": "16.18.39", - "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.39.tgz", - "integrity": "sha512-8q9ZexmdYYyc5/cfujaXb4YOucpQxAV4RMG0himLyDUOEr8Mr79VrqsFI+cQ2M2h89YIuy95lbxuYjxT4Hk4kQ==", + "node_modules/cypress-visual-regression/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, + "node_modules/cypress-visual-regression/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/cypress-visual-regression/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/cypress/node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -5733,12 +5852,6 @@ "node": ">=14" } }, - "node_modules/date-now": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/date-now/-/date-now-0.1.4.tgz", - "integrity": "sha512-AsElvov3LoNB7tf5k37H2jYSB+ZZPMT5sG2QjJCcdlV5chIv6htBUBUui2IKRjgtKAKtCBN7Zbwa+MtwLjSeNw==", - "dev": true - }, "node_modules/dayjs": { "version": "1.11.3", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.3.tgz", @@ -5746,9 +5859,9 @@ "dev": true }, "node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", + "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==", "dev": true, "dependencies": { "ms": "2.1.2" @@ -5890,43 +6003,6 @@ "node": ">=6.0.0" } }, - "node_modules/dom-serializer": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.2.2.tgz", - "integrity": "sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g==", - "dev": true, - "dependencies": { - "domelementtype": "^2.0.1", - "entities": "^2.0.0" - } - }, - "node_modules/dom-serializer/node_modules/domelementtype": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", - "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ] - }, - "node_modules/dom-serializer/node_modules/entities": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", - "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", - "dev": true, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/domelementtype": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.3.1.tgz", - "integrity": "sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==", - "dev": true - }, "node_modules/domexception": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz", @@ -5939,25 +6015,6 @@ "node": ">=12" } }, - "node_modules/domhandler": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-2.3.0.tgz", - "integrity": "sha512-q9bUwjfp7Eif8jWxxxPSykdRZAb6GkguBGSgvvCrhI9wB71W2K/Kvv4E61CF/mcCfnVJDeDWx/Vb/uAqbDj6UQ==", - "dev": true, - "dependencies": { - "domelementtype": "1" - } - }, - "node_modules/domutils": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-1.5.1.tgz", - "integrity": "sha512-gSu5Oi/I+3wDENBsOWBiRK1eoGxcywYSqg3rR960/+EfY0CF4EX1VPkgHOZ3WiS/Jg2DtliF6BhWcHlfpYUcGw==", - "dev": true, - "dependencies": { - "dom-serializer": "0", - "domelementtype": "1" - } - }, "node_modules/dot-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", @@ -6039,12 +6096,6 @@ "node": ">=8.6" } }, - "node_modules/entities": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-1.0.0.tgz", - "integrity": "sha512-LbLqfXgJMmy81t+7c14mnulFHJ170cM6E+0vMXR9k/ZiZwgX8i5pNgjTCX3SO4VeUsFLV+8InixoretwU+MjBQ==", - "dev": true - }, "node_modules/env-paths": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", @@ -6574,6 +6625,21 @@ "node": ">=0.10.0" } }, + "node_modules/event-stream": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/event-stream/-/event-stream-3.3.4.tgz", + "integrity": "sha512-QHpkERcGsR0T7Qm3HNJSyXKEEj8AHNxkY3PK8TS2KJvQ7NiSHe3DDpwVKKtoYprL/AreyzFBeIkBIWChAqn60g==", + "dev": true, + "dependencies": { + "duplexer": "~0.1.1", + "from": "~0", + "map-stream": "~0.1.0", + "pause-stream": "0.0.11", + "split": "0.3", + "stream-combiner": "~0.0.4", + "through": "~2.3.1" + } + }, "node_modules/eventemitter2": { "version": "6.4.7", "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.7.tgz", @@ -6813,9 +6879,9 @@ } }, "node_modules/fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, "dependencies": { "to-regex-range": "^5.0.1" @@ -6856,6 +6922,26 @@ "integrity": "sha512-0sQoMh9s0BYsm+12Huy/rkKxVu4R1+r96YX5cG44rHV0pQ6iC3Q+mkoMFaGWObMFYQxCVT+ssG1ksneA2MI9KQ==", "dev": true }, + "node_modules/follow-redirects": { + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", + "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/forever-agent": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", @@ -6879,6 +6965,12 @@ "node": ">= 0.12" } }, + "node_modules/from": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/from/-/from-0.1.7.tgz", + "integrity": "sha512-twe20eF1OxVxp/ML/kq2p1uc6KvFK/+vs8WjEbeKmV2He22MKm7YF2ANIt+EOqhJ5L3K/SuuPhk0hWQDjOM23g==", + "dev": true + }, "node_modules/fs-extra": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", @@ -7500,19 +7592,6 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/htmlparser2": { - "version": "3.8.3", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.8.3.tgz", - "integrity": "sha512-hBxEg3CYXe+rPIua8ETe7tmG3XDn9B0edOE/e9wH2nLczxzgdu0m0aNHY+5wFZiviLWLdANPJTssa92dMcXQ5Q==", - "dev": true, - "dependencies": { - "domelementtype": "1", - "domhandler": "2.3", - "domutils": "1.5", - "entities": "1.0", - "readable-stream": "1.1" - } - }, "node_modules/http-cache-semantics": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", @@ -7916,10 +7995,29 @@ "node": ">= 0.4" } }, - "node_modules/ip": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/ip/-/ip-1.1.9.tgz", - "integrity": "sha512-cyRxvOEpNHNtchU3Ln9KC/auJgup87llfQpQ+t5ghoC/UhL16SWzbueiCsdTnWmqAWl7LadfuwhlqmtOaqMHdQ==", + "node_modules/ip-address": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", + "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==", + "dev": true, + "dependencies": { + "jsbn": "1.1.0", + "sprintf-js": "^1.1.3" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/ip-address/node_modules/jsbn": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", + "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==", + "dev": true + }, + "node_modules/ip-address/node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", "dev": true }, "node_modules/is-arrayish": { @@ -8276,12 +8374,6 @@ "node": ">=0.10.0" } }, - "node_modules/isarray": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", - "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", - "dev": true - }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -10146,6 +10238,19 @@ "integrity": "sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==", "dev": true }, + "node_modules/joi": { + "version": "17.13.3", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.3.tgz", + "integrity": "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==", + "dev": true, + "dependencies": { + "@hapi/hoek": "^9.3.0", + "@hapi/topo": "^5.1.0", + "@sideway/address": "^4.1.5", + "@sideway/formula": "^3.0.1", + "@sideway/pinpoint": "^2.0.0" + } + }, "node_modules/js-base64": { "version": "2.6.4", "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-2.6.4.tgz", @@ -10295,48 +10400,6 @@ "node": ">=4" } }, - "node_modules/jshint": { - "version": "2.13.5", - "resolved": "https://registry.npmjs.org/jshint/-/jshint-2.13.5.tgz", - "integrity": "sha512-dB2n1w3OaQ35PLcBGIWXlszjbPZwsgZoxsg6G8PtNf2cFMC1l0fObkYLUuXqTTdi6tKw4sAjfUseTdmDMHQRcg==", - "dev": true, - "dependencies": { - "cli": "~1.0.0", - "console-browserify": "1.1.x", - "exit": "0.1.x", - "htmlparser2": "3.8.x", - "lodash": "~4.17.21", - "minimatch": "~3.0.2", - "strip-json-comments": "1.0.x" - }, - "bin": { - "jshint": "bin/jshint" - } - }, - "node_modules/jshint/node_modules/minimatch": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.8.tgz", - "integrity": "sha512-6FsRAQsxQ61mw+qP1ZzbL9Bc78x2p5OqNgNpnoAFLTrX8n5Kxph0CsnhmKKNXTWjXqU5L0pGPR7hYk+XWZr60Q==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/jshint/node_modules/strip-json-comments": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-1.0.4.tgz", - "integrity": "sha512-AOPG8EBc5wAikaG1/7uFCNFJwnKOuQwFTpYBdTW6OvWHeZBQBrAA/amefHGrEiOnCPcLFZK6FUPtWVKpQVIRgg==", - "dev": true, - "bin": { - "strip-json-comments": "cli.js" - }, - "engines": { - "node": ">=0.8.0" - } - }, "node_modules/json-parse-better-errors": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", @@ -11066,6 +11129,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/map-stream": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/map-stream/-/map-stream-0.1.0.tgz", + "integrity": "sha512-CkYQrPYZfWnu/DAmVCpTSX/xHpKZ80eKh2lAkyA6AJTef6bW+6JpbQZN5rofum7da+SyN1bi5ctTm+lTfcCW3g==", + "dev": true + }, "node_modules/matched": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/matched/-/matched-1.0.2.tgz", @@ -11155,6 +11224,18 @@ "node": ">=8.6" } }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -12637,8 +12718,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/optionator": { - "version": "0.9.3", + "node_modules/opener": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz", + "integrity": "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==", + "dev": true, + "bin": { + "opener": "bin/opener-bin.js" + } + }, + "node_modules/optionator": { + "version": "0.9.3", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", "integrity": "sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==", "dev": true, @@ -13098,6 +13188,15 @@ "node": ">=4" } }, + "node_modules/pause-stream": { + "version": "0.0.11", + "resolved": "https://registry.npmjs.org/pause-stream/-/pause-stream-0.0.11.tgz", + "integrity": "sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A==", + "dev": true, + "dependencies": { + "through": "~2.3" + } + }, "node_modules/pend": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", @@ -14020,6 +14119,21 @@ "integrity": "sha512-F2JHgJQ1iqwnHDcQjVBsq3n/uoaFL+iPW/eAeL7kVxy/2RrWaN4WroKjjvbsoRtv0ftelNyC01bjRhn/bhcf4A==", "dev": true }, + "node_modules/ps-tree": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/ps-tree/-/ps-tree-1.2.0.tgz", + "integrity": "sha512-0VnamPPYHl4uaU/nSFeZZpR21QAWRz+sRv4iW9+v/GS/J5U5iZB5BNN6J0RMoOvdx2gWM2+ZFMIm58q24e4UYA==", + "dev": true, + "dependencies": { + "event-stream": "=3.3.4" + }, + "bin": { + "ps-tree": "bin/ps-tree.js" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/psl": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", @@ -14381,18 +14495,6 @@ "semver": "bin/semver" } }, - "node_modules/readable-stream": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz", - "integrity": "sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ==", - "dev": true, - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.1", - "isarray": "0.0.1", - "string_decoder": "~0.10.x" - } - }, "node_modules/readjson": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/readjson/-/readjson-2.2.2.tgz", @@ -15252,6 +15354,16 @@ "chalk": "^2.4.2" } }, + "node_modules/rollup-plugin-serve": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/rollup-plugin-serve/-/rollup-plugin-serve-1.1.1.tgz", + "integrity": "sha512-H0VarZRtFR0lfiiC9/P8jzCDvtFf1liOX4oSdIeeYqUCKrmFA7vNiQ0rg2D+TuoP7leaa/LBR8XBts5viF6lnw==", + "dev": true, + "dependencies": { + "mime": "^2", + "opener": "1" + } + }, "node_modules/rollup-plugin-terser": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/rollup-plugin-terser/-/rollup-plugin-terser-7.0.2.tgz", @@ -15356,9 +15468,9 @@ } }, "node_modules/rxjs": { - "version": "7.5.5", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.5.5.tgz", - "integrity": "sha512-sy+H0pQofO95VDmFLzyaw9xNJU4KTRSwQIGM6+iG3SypAtCiLDzpeG8sJrNCWn2Up9km+KhkvTdbkrdy+yzZdw==", + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", "dev": true, "dependencies": { "tslib": "^2.1.0" @@ -15925,16 +16037,16 @@ } }, "node_modules/socks": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.6.2.tgz", - "integrity": "sha512-zDZhHhZRY9PxRruRMR7kMhnf3I8hDs4S3f9RecfnGxvcBHQcKcIH/oUcEWffsfl1XxdYlA7nnlGbbTvPz9D8gA==", + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.3.tgz", + "integrity": "sha512-l5x7VUUWbjVFbafGLxPWkYsHIhEvmF85tbIeFZWc8ZPtoMyybuEhL7Jye/ooC4/d48FgOjSJXgsF/AJPYCW8Zw==", "dev": true, "dependencies": { - "ip": "^1.1.5", + "ip-address": "^9.0.5", "smart-buffer": "^4.2.0" }, "engines": { - "node": ">= 10.13.0", + "node": ">= 10.0.0", "npm": ">= 3.0.0" } }, @@ -16019,6 +16131,18 @@ "integrity": "sha512-Ctl2BrFiM0X3MANYgj3CkygxhRmr9mi6xhejbdO960nF6EDJApTYpn0BQnDKlnNBULKiCN1n3w9EBkHK8ZWg+g==", "dev": true }, + "node_modules/split": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/split/-/split-0.3.3.tgz", + "integrity": "sha512-wD2AeVmxXRBoX44wAycgjVpMhvbwdI2aZjCkvfNcH1YqHQvJVa1duWc73OyVGJUc05fhFaTZeQ/PYsrmyH0JVA==", + "dev": true, + "dependencies": { + "through": "2" + }, + "engines": { + "node": "*" + } + }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -16026,9 +16150,9 @@ "dev": true }, "node_modules/sshpk": { - "version": "1.17.0", - "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.17.0.tgz", - "integrity": "sha512-/9HIEs1ZXGhSPE8X6Ccm7Nam1z8KcoCqPdI7ecm1N33EzAetWahvQWVqLZtaZQ+IDKX4IyA2o0gBzqIMkAagHQ==", + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz", + "integrity": "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==", "dev": true, "dependencies": { "asn1": "~0.2.3", @@ -16090,6 +16214,74 @@ "node": ">=8" } }, + "node_modules/start-server-and-test": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/start-server-and-test/-/start-server-and-test-2.0.4.tgz", + "integrity": "sha512-CKNeBTcP0hVqIlNismHMudb9q3lLdAjcVPO13/7gfI66fcJpeIb/o4NzQd1JK/CD+lfWVqr10ZH9Y14+OwlJuw==", + "dev": true, + "dependencies": { + "arg": "^5.0.2", + "bluebird": "3.7.2", + "check-more-types": "2.24.0", + "debug": "4.3.5", + "execa": "5.1.1", + "lazy-ass": "1.6.0", + "ps-tree": "1.2.0", + "wait-on": "7.2.0" + }, + "bin": { + "server-test": "src/bin/start.js", + "start-server-and-test": "src/bin/start.js", + "start-test": "src/bin/start.js" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/start-server-and-test/node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/start-server-and-test/node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/start-server-and-test/node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "engines": { + "node": ">=10.17.0" + } + }, "node_modules/stdout-stream": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/stdout-stream/-/stdout-stream-1.4.1.tgz", @@ -16129,11 +16321,14 @@ "safe-buffer": "~5.1.0" } }, - "node_modules/string_decoder": { - "version": "0.10.31", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", - "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==", - "dev": true + "node_modules/stream-combiner": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/stream-combiner/-/stream-combiner-0.0.4.tgz", + "integrity": "sha512-rT00SPnTVyRsaSz5zgSPma/aHSOic5U1prhYdRy5HS2kTZviFpmDgzilbtsJsxiroqACmayynDN/9VzIbX5DOw==", + "dev": true, + "dependencies": { + "duplexer": "~0.1.1" + } }, "node_modules/string-hash": { "version": "1.1.3", @@ -17318,6 +17513,25 @@ "node": ">=14" } }, + "node_modules/wait-on": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-7.2.0.tgz", + "integrity": "sha512-wCQcHkRazgjG5XoAq9jbTMLpNIjoSlZslrJ2+N9MxDsGEv1HnFoVjOCexL0ESva7Y9cu350j+DWADdk54s4AFQ==", + "dev": true, + "dependencies": { + "axios": "^1.6.1", + "joi": "^17.11.0", + "lodash": "^4.17.21", + "minimist": "^1.2.8", + "rxjs": "^7.8.1" + }, + "bin": { + "wait-on": "bin/wait-on" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/walker": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", @@ -17525,9 +17739,9 @@ } }, "node_modules/ws": { - "version": "8.13.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.13.0.tgz", - "integrity": "sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", "dev": true, "engines": { "node": ">=10.0.0" @@ -18898,9 +19112,9 @@ "dev": true }, "@cypress/request": { - "version": "2.88.12", - "resolved": "https://registry.npmjs.org/@cypress/request/-/request-2.88.12.tgz", - "integrity": "sha512-tOn+0mDZxASFM+cuAP9szGUGPI1HwWVSvdzm7V4cCsPdFTx6qMj29CwaQmRAMIEhORIUBFBsYROYJcveK4uOjA==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@cypress/request/-/request-3.0.1.tgz", + "integrity": "sha512-TWivJlJi8ZDx2wGOw1dbLuHJKUYX7bWySw377nlnGOW3hP9/MUKIsEdXT/YngWxVdgNCHRBmFlBipE+5/2ZZlQ==", "dev": true, "requires": { "aws-sign2": "~0.7.0", @@ -18916,7 +19130,7 @@ "json-stringify-safe": "~5.0.1", "mime-types": "~2.1.19", "performance-now": "^2.1.0", - "qs": "~6.10.3", + "qs": "6.10.4", "safe-buffer": "^5.1.2", "tough-cookie": "^4.1.3", "tunnel-agent": "^0.6.0", @@ -19020,6 +19234,21 @@ "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", "dev": true }, + "@hapi/hoek": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", + "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", + "dev": true + }, + "@hapi/topo": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", + "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", + "dev": true, + "requires": { + "@hapi/hoek": "^9.0.0" + } + }, "@humanwhocodes/config-array": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.10.tgz", @@ -20129,6 +20358,27 @@ "picomatch": "^2.3.1" } }, + "@sideway/address": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", + "integrity": "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==", + "dev": true, + "requires": { + "@hapi/hoek": "^9.0.0" + } + }, + "@sideway/formula": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz", + "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==", + "dev": true + }, + "@sideway/pinpoint": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", + "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==", + "dev": true + }, "@sigstore/protobuf-specs": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/@sigstore/protobuf-specs/-/protobuf-specs-0.1.0.tgz", @@ -20548,6 +20798,12 @@ } } }, + "arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true + }, "argparse": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", @@ -20654,11 +20910,41 @@ "dev": true }, "aws4": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.12.0.tgz", - "integrity": "sha512-NmWvPnx0F1SfrQbYwOi7OeaNGokp9XhzNioJ/CSBs8Qa4vxug81mhJEAVZwxXuBmYB5KDRfMq/F3RR0BIU7sWg==", + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.0.tgz", + "integrity": "sha512-3AungXC4I8kKsS9PuS4JH2nc+0bVY/mjgrephHTIi8fpEeGsTHBUJeosp0Wc1myYMElmD0B3Oc4XL/HVJ4PV2g==", "dev": true }, + "axios": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.2.tgz", + "integrity": "sha512-2A8QhOMrbomlDuiLeK9XibIBzuHeRcqqNOHp0Cyp5EoJ1IFDh+XZH3A6BkXtv0K4gFGCI0Y4BM7B1wOEi0Rmgw==", + "dev": true, + "requires": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + }, + "dependencies": { + "form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dev": true, + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + } + }, + "proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "dev": true + } + } + }, "babel-jest": { "version": "29.5.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.5.0.tgz", @@ -20939,12 +21225,12 @@ } }, "braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, "requires": { - "fill-range": "^7.0.1" + "fill-range": "^7.1.1" } }, "brotli-size": { @@ -21133,9 +21419,9 @@ } }, "caniuse-lite": { - "version": "1.0.30001519", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001519.tgz", - "integrity": "sha512-0QHgqR+Jv4bxHMp8kZ1Kn8CH55OikjKJ6JmKkZYP1F3D7w+lnFXF70nG5eNfsZS89jadi5Ywy5UCSKLAglIRkg==", + "version": "1.0.30001640", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001640.tgz", + "integrity": "sha512-lA4VMpW0PSUrFnkmVuEKBUovSWKhj7puyCg8StBChgu298N1AtuF1sKWEvfDuimSEDbhlb/KqPKC3fs1HbuQUA==", "dev": true }, "caseless": { @@ -21206,16 +21492,6 @@ "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", "dev": true }, - "cli": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/cli/-/cli-1.0.1.tgz", - "integrity": "sha512-41U72MB56TfUMGndAKK8vJ78eooOD4Z5NOL4xEfjc0c23s+6EYKXlXsmACBVclLP1yOfWCgEganVzddVrSNoTg==", - "dev": true, - "requires": { - "exit": "0.1.2", - "glob": "^7.1.1" - } - }, "cli-boxes": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-2.2.1.tgz", @@ -21432,15 +21708,6 @@ "source-map": "^0.6.1" } }, - "console-browserify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/console-browserify/-/console-browserify-1.1.0.tgz", - "integrity": "sha512-duS7VP5pvfsNLDvL1O4VOEbw37AI3A4ZUQYemvDlnpGrNu9tprR7BYWpDYwC0Xia0Zxz5ZupdiIrUp0GH1aXfg==", - "dev": true, - "requires": { - "date-now": "^0.1.4" - } - }, "console-control-strings": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", @@ -21655,20 +21922,19 @@ } }, "cypress": { - "version": "12.17.4", - "resolved": "https://registry.npmjs.org/cypress/-/cypress-12.17.4.tgz", - "integrity": "sha512-gAN8Pmns9MA5eCDFSDJXWKUpaL3IDd89N9TtIupjYnzLSmlpVr+ZR+vb4U/qaMp+lB6tBvAmt7504c3Z4RU5KQ==", + "version": "13.12.0", + "resolved": "https://registry.npmjs.org/cypress/-/cypress-13.12.0.tgz", + "integrity": "sha512-udzS2JilmI9ApO/UuqurEwOvThclin5ntz7K0BtnHBs+tg2Bl9QShLISXpSEMDv/u8b6mqdoAdyKeZiSqKWL8g==", "dev": true, "requires": { - "@cypress/request": "2.88.12", + "@cypress/request": "^3.0.0", "@cypress/xvfb": "^1.2.4", - "@types/node": "^16.18.39", "@types/sinonjs__fake-timers": "8.1.1", "@types/sizzle": "^2.3.2", "arch": "^2.2.0", "blob-util": "^2.0.2", "bluebird": "^3.7.2", - "buffer": "^5.6.0", + "buffer": "^5.7.1", "cachedir": "^2.3.0", "chalk": "^4.1.0", "check-more-types": "^2.24.0", @@ -21686,7 +21952,7 @@ "figures": "^3.2.0", "fs-extra": "^9.1.0", "getos": "^3.2.1", - "is-ci": "^3.0.0", + "is-ci": "^3.0.1", "is-installed-globally": "~0.4.0", "lazy-ass": "^1.6.0", "listr2": "^3.8.3", @@ -21705,12 +21971,6 @@ "yauzl": "^2.10.0" }, "dependencies": { - "@types/node": { - "version": "16.18.39", - "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.39.tgz", - "integrity": "sha512-8q9ZexmdYYyc5/cfujaXb4YOucpQxAV4RMG0himLyDUOEr8Mr79VrqsFI+cQ2M2h89YIuy95lbxuYjxT4Hk4kQ==", - "dev": true - }, "ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -21783,21 +22043,73 @@ } }, "cypress-real-events": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/cypress-real-events/-/cypress-real-events-1.7.6.tgz", - "integrity": "sha512-yP6GnRrbm6HK5q4DH6Nnupz37nOfZu/xn1xFYqsE2o4G73giPWQOdu6375QYpwfU1cvHNCgyD2bQ2hPH9D7NMw==", + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/cypress-real-events/-/cypress-real-events-1.13.0.tgz", + "integrity": "sha512-LoejtK+dyZ1jaT8wGT5oASTPfsNV8/ClRp99ruN60oPj8cBJYod80iJDyNwfPAu4GCxTXOhhAv9FO65Hpwt6Hg==", "dev": true, "requires": {} }, "cypress-visual-regression": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/cypress-visual-regression/-/cypress-visual-regression-2.1.1.tgz", - "integrity": "sha512-oVDBL3hEMd6luj7eYLXzaNbqKPT8e1ZDGg/mptCRlIgw/uo09zv5TRHe6eqptPuZH8qFpfG3Eijk656zP2PcZg==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/cypress-visual-regression/-/cypress-visual-regression-5.0.2.tgz", + "integrity": "sha512-Rr/W2uSw1KxG5AE9q6MT8yZfT81GaLVO0z/QyKg9pCZ7XvObUklyh9j5gWe7yUzDnY/2g9XpczZKNKL2zZ2PAg==", "dev": true, "requires": { + "chalk": "^4.1.2", "pixelmatch": "^5.2.1", "pngjs": "^6.0.0", "sanitize-filename": "^1.6.3" + }, + "dependencies": { + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } } }, "dashdash": { @@ -21820,12 +22132,6 @@ "whatwg-url": "^12.0.0" } }, - "date-now": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/date-now/-/date-now-0.1.4.tgz", - "integrity": "sha512-AsElvov3LoNB7tf5k37H2jYSB+ZZPMT5sG2QjJCcdlV5chIv6htBUBUui2IKRjgtKAKtCBN7Zbwa+MtwLjSeNw==", - "dev": true - }, "dayjs": { "version": "1.11.3", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.3.tgz", @@ -21833,9 +22139,9 @@ "dev": true }, "debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", + "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==", "dev": true, "requires": { "ms": "2.1.2" @@ -21938,36 +22244,6 @@ "esutils": "^2.0.2" } }, - "dom-serializer": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.2.2.tgz", - "integrity": "sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g==", - "dev": true, - "requires": { - "domelementtype": "^2.0.1", - "entities": "^2.0.0" - }, - "dependencies": { - "domelementtype": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", - "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", - "dev": true - }, - "entities": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", - "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", - "dev": true - } - } - }, - "domelementtype": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.3.1.tgz", - "integrity": "sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==", - "dev": true - }, "domexception": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz", @@ -21977,25 +22253,6 @@ "webidl-conversions": "^7.0.0" } }, - "domhandler": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-2.3.0.tgz", - "integrity": "sha512-q9bUwjfp7Eif8jWxxxPSykdRZAb6GkguBGSgvvCrhI9wB71W2K/Kvv4E61CF/mcCfnVJDeDWx/Vb/uAqbDj6UQ==", - "dev": true, - "requires": { - "domelementtype": "1" - } - }, - "domutils": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-1.5.1.tgz", - "integrity": "sha512-gSu5Oi/I+3wDENBsOWBiRK1eoGxcywYSqg3rR960/+EfY0CF4EX1VPkgHOZ3WiS/Jg2DtliF6BhWcHlfpYUcGw==", - "dev": true, - "requires": { - "dom-serializer": "0", - "domelementtype": "1" - } - }, "dot-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", @@ -22068,12 +22325,6 @@ "ansi-colors": "^4.1.1" } }, - "entities": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-1.0.0.tgz", - "integrity": "sha512-LbLqfXgJMmy81t+7c14mnulFHJ170cM6E+0vMXR9k/ZiZwgX8i5pNgjTCX3SO4VeUsFLV+8InixoretwU+MjBQ==", - "dev": true - }, "env-paths": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", @@ -22447,6 +22698,21 @@ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true }, + "event-stream": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/event-stream/-/event-stream-3.3.4.tgz", + "integrity": "sha512-QHpkERcGsR0T7Qm3HNJSyXKEEj8AHNxkY3PK8TS2KJvQ7NiSHe3DDpwVKKtoYprL/AreyzFBeIkBIWChAqn60g==", + "dev": true, + "requires": { + "duplexer": "~0.1.1", + "from": "~0", + "map-stream": "~0.1.0", + "pause-stream": "0.0.11", + "split": "0.3", + "stream-combiner": "~0.0.4", + "through": "~2.3.1" + } + }, "eventemitter2": { "version": "6.4.7", "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.7.tgz", @@ -22638,9 +22904,9 @@ "dev": true }, "fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, "requires": { "to-regex-range": "^5.0.1" @@ -22672,6 +22938,12 @@ "integrity": "sha512-0sQoMh9s0BYsm+12Huy/rkKxVu4R1+r96YX5cG44rHV0pQ6iC3Q+mkoMFaGWObMFYQxCVT+ssG1ksneA2MI9KQ==", "dev": true }, + "follow-redirects": { + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", + "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", + "dev": true + }, "forever-agent": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", @@ -22689,6 +22961,12 @@ "mime-types": "^2.1.12" } }, + "from": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/from/-/from-0.1.7.tgz", + "integrity": "sha512-twe20eF1OxVxp/ML/kq2p1uc6KvFK/+vs8WjEbeKmV2He22MKm7YF2ANIt+EOqhJ5L3K/SuuPhk0hWQDjOM23g==", + "dev": true + }, "fs-extra": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", @@ -23157,19 +23435,6 @@ } } }, - "htmlparser2": { - "version": "3.8.3", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.8.3.tgz", - "integrity": "sha512-hBxEg3CYXe+rPIua8ETe7tmG3XDn9B0edOE/e9wH2nLczxzgdu0m0aNHY+5wFZiviLWLdANPJTssa92dMcXQ5Q==", - "dev": true, - "requires": { - "domelementtype": "1", - "domhandler": "2.3", - "domutils": "1.5", - "entities": "1.0", - "readable-stream": "1.1" - } - }, "http-cache-semantics": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", @@ -23470,11 +23735,29 @@ "side-channel": "^1.0.4" } }, - "ip": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/ip/-/ip-1.1.9.tgz", - "integrity": "sha512-cyRxvOEpNHNtchU3Ln9KC/auJgup87llfQpQ+t5ghoC/UhL16SWzbueiCsdTnWmqAWl7LadfuwhlqmtOaqMHdQ==", - "dev": true + "ip-address": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", + "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==", + "dev": true, + "requires": { + "jsbn": "1.1.0", + "sprintf-js": "^1.1.3" + }, + "dependencies": { + "jsbn": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", + "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==", + "dev": true + }, + "sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "dev": true + } + } }, "is-arrayish": { "version": "0.2.1", @@ -23716,12 +23999,6 @@ "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", "dev": true }, - "isarray": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", - "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", - "dev": true - }, "isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -25092,6 +25369,19 @@ "integrity": "sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==", "dev": true }, + "joi": { + "version": "17.13.3", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.3.tgz", + "integrity": "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==", + "dev": true, + "requires": { + "@hapi/hoek": "^9.3.0", + "@hapi/topo": "^5.1.0", + "@sideway/address": "^4.1.5", + "@sideway/formula": "^3.0.1", + "@sideway/pinpoint": "^2.0.0" + } + }, "js-base64": { "version": "2.6.4", "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-2.6.4.tgz", @@ -25205,38 +25495,6 @@ "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", "dev": true }, - "jshint": { - "version": "2.13.5", - "resolved": "https://registry.npmjs.org/jshint/-/jshint-2.13.5.tgz", - "integrity": "sha512-dB2n1w3OaQ35PLcBGIWXlszjbPZwsgZoxsg6G8PtNf2cFMC1l0fObkYLUuXqTTdi6tKw4sAjfUseTdmDMHQRcg==", - "dev": true, - "requires": { - "cli": "~1.0.0", - "console-browserify": "1.1.x", - "exit": "0.1.x", - "htmlparser2": "3.8.x", - "lodash": "~4.17.21", - "minimatch": "~3.0.2", - "strip-json-comments": "1.0.x" - }, - "dependencies": { - "minimatch": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.8.tgz", - "integrity": "sha512-6FsRAQsxQ61mw+qP1ZzbL9Bc78x2p5OqNgNpnoAFLTrX8n5Kxph0CsnhmKKNXTWjXqU5L0pGPR7hYk+XWZr60Q==", - "dev": true, - "requires": { - "brace-expansion": "^1.1.7" - } - }, - "strip-json-comments": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-1.0.4.tgz", - "integrity": "sha512-AOPG8EBc5wAikaG1/7uFCNFJwnKOuQwFTpYBdTW6OvWHeZBQBrAA/amefHGrEiOnCPcLFZK6FUPtWVKpQVIRgg==", - "dev": true - } - } - }, "json-parse-better-errors": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", @@ -25792,6 +26050,12 @@ "integrity": "sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==", "dev": true }, + "map-stream": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/map-stream/-/map-stream-0.1.0.tgz", + "integrity": "sha512-CkYQrPYZfWnu/DAmVCpTSX/xHpKZ80eKh2lAkyA6AJTef6bW+6JpbQZN5rofum7da+SyN1bi5ctTm+lTfcCW3g==", + "dev": true + }, "matched": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/matched/-/matched-1.0.2.tgz", @@ -25862,6 +26126,12 @@ "picomatch": "^2.3.1" } }, + "mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true + }, "mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -26976,6 +27246,12 @@ "mimic-fn": "^2.1.0" } }, + "opener": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz", + "integrity": "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==", + "dev": true + }, "optionator": { "version": "0.9.3", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", @@ -27329,6 +27605,15 @@ } } }, + "pause-stream": { + "version": "0.0.11", + "resolved": "https://registry.npmjs.org/pause-stream/-/pause-stream-0.0.11.tgz", + "integrity": "sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A==", + "dev": true, + "requires": { + "through": "~2.3" + } + }, "pend": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", @@ -27935,6 +28220,15 @@ "integrity": "sha512-F2JHgJQ1iqwnHDcQjVBsq3n/uoaFL+iPW/eAeL7kVxy/2RrWaN4WroKjjvbsoRtv0ftelNyC01bjRhn/bhcf4A==", "dev": true }, + "ps-tree": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/ps-tree/-/ps-tree-1.2.0.tgz", + "integrity": "sha512-0VnamPPYHl4uaU/nSFeZZpR21QAWRz+sRv4iW9+v/GS/J5U5iZB5BNN6J0RMoOvdx2gWM2+ZFMIm58q24e4UYA==", + "dev": true, + "requires": { + "event-stream": "=3.3.4" + } + }, "psl": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", @@ -28212,18 +28506,6 @@ } } }, - "readable-stream": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz", - "integrity": "sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ==", - "dev": true, - "requires": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.1", - "isarray": "0.0.1", - "string_decoder": "~0.10.x" - } - }, "readjson": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/readjson/-/readjson-2.2.2.tgz", @@ -28877,6 +29159,16 @@ "chalk": "^2.4.2" } }, + "rollup-plugin-serve": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/rollup-plugin-serve/-/rollup-plugin-serve-1.1.1.tgz", + "integrity": "sha512-H0VarZRtFR0lfiiC9/P8jzCDvtFf1liOX4oSdIeeYqUCKrmFA7vNiQ0rg2D+TuoP7leaa/LBR8XBts5viF6lnw==", + "dev": true, + "requires": { + "mime": "^2", + "opener": "1" + } + }, "rollup-plugin-terser": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/rollup-plugin-terser/-/rollup-plugin-terser-7.0.2.tgz", @@ -28956,9 +29248,9 @@ } }, "rxjs": { - "version": "7.5.5", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.5.5.tgz", - "integrity": "sha512-sy+H0pQofO95VDmFLzyaw9xNJU4KTRSwQIGM6+iG3SypAtCiLDzpeG8sJrNCWn2Up9km+KhkvTdbkrdy+yzZdw==", + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", "dev": true, "requires": { "tslib": "^2.1.0" @@ -29409,12 +29701,12 @@ "dev": true }, "socks": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.6.2.tgz", - "integrity": "sha512-zDZhHhZRY9PxRruRMR7kMhnf3I8hDs4S3f9RecfnGxvcBHQcKcIH/oUcEWffsfl1XxdYlA7nnlGbbTvPz9D8gA==", + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.3.tgz", + "integrity": "sha512-l5x7VUUWbjVFbafGLxPWkYsHIhEvmF85tbIeFZWc8ZPtoMyybuEhL7Jye/ooC4/d48FgOjSJXgsF/AJPYCW8Zw==", "dev": true, "requires": { - "ip": "^1.1.5", + "ip-address": "^9.0.5", "smart-buffer": "^4.2.0" } }, @@ -29489,6 +29781,15 @@ "integrity": "sha512-Ctl2BrFiM0X3MANYgj3CkygxhRmr9mi6xhejbdO960nF6EDJApTYpn0BQnDKlnNBULKiCN1n3w9EBkHK8ZWg+g==", "dev": true }, + "split": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/split/-/split-0.3.3.tgz", + "integrity": "sha512-wD2AeVmxXRBoX44wAycgjVpMhvbwdI2aZjCkvfNcH1YqHQvJVa1duWc73OyVGJUc05fhFaTZeQ/PYsrmyH0JVA==", + "dev": true, + "requires": { + "through": "2" + } + }, "sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -29496,9 +29797,9 @@ "dev": true }, "sshpk": { - "version": "1.17.0", - "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.17.0.tgz", - "integrity": "sha512-/9HIEs1ZXGhSPE8X6Ccm7Nam1z8KcoCqPdI7ecm1N33EzAetWahvQWVqLZtaZQ+IDKX4IyA2o0gBzqIMkAagHQ==", + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz", + "integrity": "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==", "dev": true, "requires": { "asn1": "~0.2.3", @@ -29544,6 +29845,53 @@ } } }, + "start-server-and-test": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/start-server-and-test/-/start-server-and-test-2.0.4.tgz", + "integrity": "sha512-CKNeBTcP0hVqIlNismHMudb9q3lLdAjcVPO13/7gfI66fcJpeIb/o4NzQd1JK/CD+lfWVqr10ZH9Y14+OwlJuw==", + "dev": true, + "requires": { + "arg": "^5.0.2", + "bluebird": "3.7.2", + "check-more-types": "2.24.0", + "debug": "4.3.5", + "execa": "5.1.1", + "lazy-ass": "1.6.0", + "ps-tree": "1.2.0", + "wait-on": "7.2.0" + }, + "dependencies": { + "execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "requires": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + } + }, + "get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true + }, + "human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true + } + } + }, "stdout-stream": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/stdout-stream/-/stdout-stream-1.4.1.tgz", @@ -29585,11 +29933,14 @@ } } }, - "string_decoder": { - "version": "0.10.31", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", - "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==", - "dev": true + "stream-combiner": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/stream-combiner/-/stream-combiner-0.0.4.tgz", + "integrity": "sha512-rT00SPnTVyRsaSz5zgSPma/aHSOic5U1prhYdRy5HS2kTZviFpmDgzilbtsJsxiroqACmayynDN/9VzIbX5DOw==", + "dev": true, + "requires": { + "duplexer": "~0.1.1" + } }, "string-hash": { "version": "1.1.3", @@ -30486,6 +30837,19 @@ "xml-name-validator": "^4.0.0" } }, + "wait-on": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-7.2.0.tgz", + "integrity": "sha512-wCQcHkRazgjG5XoAq9jbTMLpNIjoSlZslrJ2+N9MxDsGEv1HnFoVjOCexL0ESva7Y9cu350j+DWADdk54s4AFQ==", + "dev": true, + "requires": { + "axios": "^1.6.1", + "joi": "^17.11.0", + "lodash": "^4.17.21", + "minimist": "^1.2.8", + "rxjs": "^7.8.1" + } + }, "walker": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", @@ -30646,9 +31010,9 @@ } }, "ws": { - "version": "8.13.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.13.0.tgz", - "integrity": "sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", "dev": true, "requires": {} }, diff --git a/package.json b/package.json index c281d77dc..de74666c5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "intro.js", - "version": "7.2.0", + "version": "8.0.0-beta.1", "description": "User Onboarding and Product Walkthrough Library", "keywords": [ "onboarding", @@ -22,16 +22,16 @@ "types": "src/index.d.ts", "scripts": { "prettier": "prettier --write '(src|tests)/**/*.(js|ts|json|html)' '!tests/cypress/setup/dist'", - "test": "run-p test:prettier test:jest test:jshint test:cypress", + "test": "run-p test:prettier test:jest test:cypress", "test:prettier": "prettier --check '(src|tests)/**/*.(js|ts|json|html)' '!tests/cypress/setup/dist'", "test:watch": "jest --watch", "test:jest": "jest --coverage --silent --ci --coverage --coverageReporters=\"text\" --coverageReporters=\"text-summary\"", - "test:jshint": "jshint ./src --verbose && jshint ./tests --verbose", - "test:cypress": "npm run build && cp -r ./dist ./tests/cypress/setup && cd ./tests && cypress run --env type=actual", + "test:cypress": "start-server-and-test dev http://localhost:10001/dist/intro.js 'docker run -it -v $PWD:/e2e -w /e2e --entrypoint=cypress cypress/included run --env type=regression || cypress run --env type=regression'", "release": "./bin/release.sh || true", "prebuild": "rimraf ./dist", "build": "rollup -c", - "build:watch": "rollup -c -w" + "build:watch": "rollup -c -w", + "dev": "npm run build:watch -- --configPlugin serve" }, "devDependencies": { "@babel/core": "^7.12.3", @@ -46,14 +46,13 @@ "autoprefixer": "^9.0.0", "babel-jest": "^29.2.2", "core-js": "^3.6.5", - "cypress": "^12.8.1", - "cypress-real-events": "^1.7.6", - "cypress-visual-regression": "^2.1.1", + "cypress": "^13.12.0", + "cypress-real-events": "^1.13.0", + "cypress-visual-regression": "^5.0.2", "eslint": "^8.0.1", "jest": "^29.5.0", "jest-extended": "^3.2.4", "jsdom": "^21.1.1", - "jshint": "^2.12.0", "lerna-changelog": "^2.1.0", "minify": "^9.1.0", "node-sass": "^8.0.0", @@ -72,7 +71,9 @@ "rollup-plugin-node-resolve": "^5.2.0", "rollup-plugin-postcss": "^4.0.0", "rollup-plugin-progress": "^1.1.2", + "rollup-plugin-serve": "^1.1.1", "rollup-plugin-terser": "^7.0.2", + "start-server-and-test": "^2.0.4", "ts-jest": "^29.1.1", "tslib": "^2.6.0", "typescript": "^5.1.6" diff --git a/src/core/DOMEvent.ts b/src/core/DOMEvent.ts deleted file mode 100644 index ee50a172f..000000000 --- a/src/core/DOMEvent.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { IntroJs } from "../intro"; -import stamp from "../util/stamp"; - -/** - * DOMEvent Handles all DOM events - * - * methods: - * - * on - add event handler - * off - remove event - */ - -class DOMEvent { - private readonly events_key: string = "introjs_event"; - - /** - * Gets a unique ID for an event listener - */ - private _id(type: string, listener: Function, context: IntroJs) { - return type + stamp(listener) + (context ? `_${stamp(context)}` : ""); - } - - /** - * Adds event listener - */ - public on( - obj: EventTarget, - type: string, - listener: ( - context: IntroJs | EventTarget, - e: Event - ) => void | undefined | string | Promise, - context: IntroJs, - useCapture: boolean - ) { - const id = this._id(type, listener, context); - const handler = (e: Event) => listener(context || obj, e || window.event); - - if ("addEventListener" in obj) { - obj.addEventListener(type, handler, useCapture); - } else if ("attachEvent" in obj) { - // @ts-ignore - obj.attachEvent(`on${type}`, handler); - } - - // @ts-ignore - obj[this.events_key] = obj[this.events_key] || {}; - // @ts-ignore - obj[this.events_key][id] = handler; - } - - /** - * Removes event listener - */ - public off( - obj: EventTarget, - type: string, - listener: ( - context: IntroJs | EventTarget, - e: Event - ) => void | undefined | string | Promise, - context: IntroJs, - useCapture: boolean - ) { - const id = this._id(type, listener, context); - // @ts-ignore - const handler = obj[this.events_key] && obj[this.events_key][id]; - - if (!handler) { - return; - } - - if ("removeEventListener" in obj) { - obj.removeEventListener(type, handler, useCapture); - } else if ("detachEvent" in obj) { - // @ts-ignore - obj.detachEvent(`on${type}`, handler); - } - - // @ts-ignore - obj[this.events_key][id] = null; - } -} - -export default new DOMEvent(); diff --git a/src/core/addOverlayLayer.ts b/src/core/addOverlayLayer.ts deleted file mode 100644 index 219a9a350..000000000 --- a/src/core/addOverlayLayer.ts +++ /dev/null @@ -1,40 +0,0 @@ -import exitIntro from "./exitIntro"; -import createElement from "../util/createElement"; -import setStyle from "../util/setStyle"; -import { IntroJs } from "../intro"; - -/** - * Add overlay layer to the page - * - * @api private - */ -export default function addOverlayLayer( - intro: IntroJs, - targetElm: HTMLElement -) { - const overlayLayer = createElement("div", { - className: "introjs-overlay", - }); - - setStyle(overlayLayer, { - top: 0, - bottom: 0, - left: 0, - right: 0, - position: "fixed", - }); - - targetElm.appendChild(overlayLayer); - - if (intro._options.exitOnOverlayClick === true) { - setStyle(overlayLayer, { - cursor: "pointer", - }); - - overlayLayer.onclick = async () => { - await exitIntro(intro, targetElm); - }; - } - - return true; -} diff --git a/src/core/dontShowAgain.ts b/src/core/dontShowAgain.ts deleted file mode 100644 index 388a2ebb8..000000000 --- a/src/core/dontShowAgain.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { IntroJs } from "../intro"; -import { deleteCookie, getCookie, setCookie } from "../util/cookie"; - -const dontShowAgainCookieValue = "true"; - -/** - * Set the "Don't show again" state - * - * @api private - */ -export function setDontShowAgain(intro: IntroJs, dontShowAgain: boolean) { - if (dontShowAgain) { - setCookie( - intro._options.dontShowAgainCookie, - dontShowAgainCookieValue, - intro._options.dontShowAgainCookieDays - ); - } else { - deleteCookie(intro._options.dontShowAgainCookie); - } -} - -/** - * Get the "Don't show again" state from cookies - * - * @api private - */ -export function getDontShowAgain(intro: IntroJs): boolean { - const dontShowCookie = getCookie(intro._options.dontShowAgainCookie); - return dontShowCookie !== "" && dontShowCookie === dontShowAgainCookieValue; -} diff --git a/src/core/exitIntro.ts b/src/core/exitIntro.ts deleted file mode 100644 index 3331bb828..000000000 --- a/src/core/exitIntro.ts +++ /dev/null @@ -1,83 +0,0 @@ -import DOMEvent from "./DOMEvent"; -import onKeyDown from "./onKeyDown"; -import onResize from "./onResize"; -import removeShowElement from "./removeShowElement"; -import removeChild from "../util/removeChild"; -import { IntroJs } from "../intro"; -import isFunction from "../util/isFunction"; - -/** - * Exit from intro - * - * @api private - * @param {Boolean} force - Setting to `true` will skip the result of beforeExit callback - */ -export default async function exitIntro( - intro: IntroJs, - targetElement: HTMLElement, - force: boolean = false -) { - let continueExit = true; - - // calling onbeforeexit callback - // - // If this callback return `false`, it would halt the process - if (intro._introBeforeExitCallback !== undefined) { - continueExit = await intro._introBeforeExitCallback.call( - intro, - targetElement - ); - } - - // skip this check if `force` parameter is `true` - // otherwise, if `onbeforeexit` returned `false`, don't exit the intro - if (!force && continueExit === false) return; - - // remove overlay layers from the page - const overlayLayers = Array.from( - targetElement.querySelectorAll(".introjs-overlay") - ); - - if (overlayLayers && overlayLayers.length) { - for (const overlayLayer of overlayLayers) { - removeChild(overlayLayer); - } - } - - //remove all helper layers - const helperLayer = targetElement.querySelector( - ".introjs-helperLayer" - ); - removeChild(helperLayer, true); - - const referenceLayer = targetElement.querySelector( - ".introjs-tooltipReferenceLayer" - ); - removeChild(referenceLayer); - - //remove disableInteractionLayer - const disableInteractionLayer = targetElement.querySelector( - ".introjs-disableInteraction" - ); - removeChild(disableInteractionLayer); - - //remove intro floating element - const floatingElement = document.querySelector( - ".introjsFloatingElement" - ); - removeChild(floatingElement); - - removeShowElement(); - - //clean listeners - DOMEvent.off(window, "keydown", onKeyDown, intro, true); - DOMEvent.off(window, "resize", onResize, intro, true); - - //check if any callback is defined - if (isFunction(intro._introExitCallback)) { - await intro._introExitCallback.call(intro); - } - - // set the step to default - intro._currentStep = -1; -} diff --git a/src/core/fetchIntroSteps.ts b/src/core/fetchIntroSteps.ts deleted file mode 100644 index 91187374e..000000000 --- a/src/core/fetchIntroSteps.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { IntroJs } from "../intro"; -import cloneObject from "../util/cloneObject"; -import createElement from "../util/createElement"; -import { IntroStep, ScrollTo, TooltipPosition } from "./steps"; - -/** - * Finds all Intro steps from the data-* attributes and the options.steps array - * - * @api private - */ -export default function fetchIntroSteps( - intro: IntroJs, - targetElm: HTMLElement -) { - let introItems: IntroStep[] = []; - - if (intro._options.steps && intro._options.steps.length) { - //use steps passed programmatically - for (const step of intro._options.steps) { - const currentItem = cloneObject(step); - - //set the step - currentItem.step = introItems.length + 1; - - currentItem.title = currentItem.title || ""; - - //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) || undefined; - } - - //intro without element - if ( - typeof currentItem.element === "undefined" || - currentItem.element === null - ) { - let floatingElementQuery = document.querySelector( - ".introjsFloatingElement" - ); - - if (floatingElementQuery === null) { - floatingElementQuery = createElement("div", { - className: "introjsFloatingElement", - }); - - document.body.appendChild(floatingElementQuery); - } - - currentItem.element = floatingElementQuery; - currentItem.position = "floating"; - } - - currentItem.position = - currentItem.position || - (intro._options.tooltipPosition as TooltipPosition); - currentItem.scrollTo = currentItem.scrollTo || intro._options.scrollTo; - - if (typeof currentItem.disableInteraction === "undefined") { - currentItem.disableInteraction = intro._options.disableInteraction; - } - - if (currentItem.element !== null) { - introItems.push(currentItem as IntroStep); - } - } - } else { - const elements: HTMLElement[] = Array.from( - targetElm.querySelectorAll("*[data-intro]") - ); - - //if there's no element to intro - if (elements.length < 1) { - return []; - } - - const itemsWithoutStep: IntroStep[] = []; - - for (const element of elements) { - // start intro for groups of elements - if ( - intro._options.group && - element.getAttribute("data-intro-group") !== intro._options.group - ) { - continue; - } - - // skip hidden elements - if (element.style.display === "none") { - continue; - } - - // get the step for the current element or set as 0 if is not present - const step = parseInt(element.getAttribute("data-step") || "0", 10); - - let disableInteraction = intro._options.disableInteraction; - if (element.hasAttribute("data-disable-interaction")) { - disableInteraction = !!element.getAttribute("data-disable-interaction"); - } - - const newIntroStep: IntroStep = { - step, - element, - title: element.getAttribute("data-title") || "", - intro: element.getAttribute("data-intro") || "", - tooltipClass: element.getAttribute("data-tooltip-class") || undefined, - highlightClass: - element.getAttribute("data-highlight-class") || undefined, - position: (element.getAttribute("data-position") || - intro._options.tooltipPosition) as TooltipPosition, - scrollTo: - (element.getAttribute("data-scroll-to") as ScrollTo) || - intro._options.scrollTo, - disableInteraction, - }; - - if (step > 0) { - introItems[step - 1] = newIntroStep; - } else { - itemsWithoutStep.push(newIntroStep); - } - } - - // fill items without step in blanks and update their step - for (let i = 0; itemsWithoutStep.length > 0; i++) { - if (typeof introItems[i] === "undefined") { - const newStep = itemsWithoutStep.shift(); - if (!newStep) break; - - newStep.step = i + 1; - introItems[i] = newStep; - } - } - } - - // removing undefined/null elements - introItems = introItems.filter((n) => n); - - // Sort all items with given steps - introItems.sort((a, b) => a.step - b.step); - - return introItems; -} diff --git a/src/core/hint.ts b/src/core/hint.ts deleted file mode 100644 index f1a13d563..000000000 --- a/src/core/hint.ts +++ /dev/null @@ -1,496 +0,0 @@ -import addClass from "../util/addClass"; -import removeClass from "../util/removeClass"; -import isFixed from "../util/isFixed"; -import getOffset from "../util/getOffset"; -import cloneObject from "../util/cloneObject"; -import DOMEvent from "./DOMEvent"; -import setAnchorAsButton from "../util/setAnchorAsButton"; -import setHelperLayerPosition from "./setHelperLayerPosition"; -import placeTooltip from "./placeTooltip"; -import createElement from "../util/createElement"; -import debounce from "../util/debounce"; -import { HintPosition, HintStep, TooltipPosition } from "./steps"; -import { IntroJs } from "../intro"; -import isFunction from "../util/isFunction"; - -/** - * Get a queryselector within the hint wrapper - */ -export function hintQuerySelectorAll(selector: string): HTMLElement[] { - const hintsWrapper = document.querySelector(".introjs-hints"); - return hintsWrapper - ? Array.from(hintsWrapper.querySelectorAll(selector)) - : []; -} - -/** - * Hide a hint - * - * @api private - */ -export async function hideHint(intro: IntroJs, stepId: number) { - const hint = hintQuerySelectorAll(`.introjs-hint[data-step="${stepId}"]`)[0]; - - removeHintTooltip(); - - if (hint) { - addClass(hint, "introjs-hidehint"); - } - - // call the callback function (if any) - if (isFunction(intro._hintCloseCallback)) { - await intro._hintCloseCallback.call(intro, stepId); - } -} - -/** - * Hide all hints - * - * @api private - */ -export async function hideHints(intro: IntroJs) { - const hints = hintQuerySelectorAll(".introjs-hint"); - - for (const hint of hints) { - const step = hint.getAttribute("data-step"); - if (!step) continue; - - await hideHint(intro, parseInt(step, 10)); - } -} - -/** - * Show all hints - * - * @api private - */ -export async function showHints(intro: IntroJs) { - const hints = hintQuerySelectorAll(".introjs-hint"); - - if (hints && hints.length) { - for (const hint of hints) { - const step = hint.getAttribute("data-step"); - if (!step) continue; - - showHint(parseInt(step, 10)); - } - } else { - await populateHints(intro, intro._targetElement); - } -} - -/** - * Show a hint - * - * @api private - */ -export function showHint(stepId: number) { - const hint = hintQuerySelectorAll(`.introjs-hint[data-step="${stepId}"]`)[0]; - - if (hint) { - removeClass(hint, /introjs-hidehint/g); - } -} - -/** - * Removes all hint elements on the page - * Useful when you want to destroy the elements and add them again (e.g. a modal or popup) - * - * @api private - */ -export function removeHints(intro: IntroJs) { - const hints = hintQuerySelectorAll(".introjs-hint"); - - for (const hint of hints) { - const step = hint.getAttribute("data-step"); - if (!step) continue; - - removeHint(parseInt(step, 10)); - } - - DOMEvent.off(document, "click", removeHintTooltip, intro, false); - DOMEvent.off(window, "resize", reAlignHints, intro, true); - - if (intro._hintsAutoRefreshFunction) { - DOMEvent.off( - window, - "scroll", - intro._hintsAutoRefreshFunction, - intro, - true - ); - } -} - -/** - * Remove one single hint element from the page - * Useful when you want to destroy the element and add them again (e.g. a modal or popup) - * Use removeHints if you want to remove all elements. - * - * @api private - */ -export function removeHint(stepId: number) { - const hint = hintQuerySelectorAll(`.introjs-hint[data-step="${stepId}"]`)[0]; - - if (hint && hint.parentNode) { - hint.parentNode.removeChild(hint); - } -} - -/** - * Add all available hints to the page - * - * @api private - */ -export async function addHints(intro: IntroJs) { - let hintsWrapper = document.querySelector(".introjs-hints"); - - if (hintsWrapper === null) { - hintsWrapper = createElement("div", { - className: "introjs-hints", - }); - } - - /** - * Returns an event handler unique to the hint iteration - */ - const getHintClick = (i: number) => (e: Event) => { - const evt = e ? e : window.event; - - if (evt && evt.stopPropagation) { - evt.stopPropagation(); - } - - if (evt && evt.cancelBubble !== null) { - evt.cancelBubble = true; - } - - showHintDialog(intro, i); - }; - - for (let i = 0; i < intro._hintItems.length; i++) { - const item = intro._hintItems[i]; - - // avoid append a hint twice - if (document.querySelector(`.introjs-hint[data-step="${i}"]`)) { - return; - } - - const hint = createElement("a", { - className: "introjs-hint", - }); - setAnchorAsButton(hint); - - hint.onclick = getHintClick(i); - - if (!item.hintAnimation) { - addClass(hint, "introjs-hint-no-anim"); - } - - // hint's position should be fixed if the target element's position is fixed - if (isFixed(item.element as HTMLElement)) { - addClass(hint, "introjs-fixedhint"); - } - - const hintDot = createElement("div", { - className: "introjs-hint-dot", - }); - - const hintPulse = createElement("div", { - className: "introjs-hint-pulse", - }); - - hint.appendChild(hintDot); - hint.appendChild(hintPulse); - hint.setAttribute("data-step", i.toString()); - - // we swap the hint element with target element - // because _setHelperLayerPosition uses `element` property - item.hintTargetElement = item.element as HTMLElement; - item.element = hint; - - // align the hint position - alignHintPosition( - item.hintPosition, - hint, - item.hintTargetElement as HTMLElement - ); - - hintsWrapper.appendChild(hint); - } - - // adding the hints wrapper - document.body.appendChild(hintsWrapper); - - // call the callback function (if any) - if (isFunction(intro._hintsAddedCallback)) { - await intro._hintsAddedCallback.call(intro); - } - - if (intro._options.hintAutoRefreshInterval >= 0) { - intro._hintsAutoRefreshFunction = debounce( - () => reAlignHints(intro), - intro._options.hintAutoRefreshInterval - ); - DOMEvent.on(window, "scroll", intro._hintsAutoRefreshFunction, intro, true); - } -} - -/** - * Aligns hint position - * - * @api private - */ -export function alignHintPosition( - position: HintPosition, - hintElement: HTMLElement, - targetElement?: HTMLElement -) { - if (typeof targetElement === "undefined") { - return; - } - - // get/calculate offset of target element - const offset = getOffset(targetElement); - const iconWidth = 20; - const iconHeight = 20; - - // align the hint element - switch (position) { - default: - case "top-left": - hintElement.style.left = `${offset.left}px`; - hintElement.style.top = `${offset.top}px`; - break; - case "top-right": - hintElement.style.left = `${offset.left + offset.width - iconWidth}px`; - hintElement.style.top = `${offset.top}px`; - break; - case "bottom-left": - hintElement.style.left = `${offset.left}px`; - hintElement.style.top = `${offset.top + offset.height - iconHeight}px`; - break; - case "bottom-right": - hintElement.style.left = `${offset.left + offset.width - iconWidth}px`; - hintElement.style.top = `${offset.top + offset.height - iconHeight}px`; - break; - case "middle-left": - hintElement.style.left = `${offset.left}px`; - hintElement.style.top = `${ - offset.top + (offset.height - iconHeight) / 2 - }px`; - break; - case "middle-right": - hintElement.style.left = `${offset.left + offset.width - iconWidth}px`; - hintElement.style.top = `${ - offset.top + (offset.height - iconHeight) / 2 - }px`; - break; - case "middle-middle": - hintElement.style.left = `${ - offset.left + (offset.width - iconWidth) / 2 - }px`; - hintElement.style.top = `${ - offset.top + (offset.height - iconHeight) / 2 - }px`; - break; - case "bottom-middle": - hintElement.style.left = `${ - offset.left + (offset.width - iconWidth) / 2 - }px`; - hintElement.style.top = `${offset.top + offset.height - iconHeight}px`; - break; - case "top-middle": - hintElement.style.left = `${ - offset.left + (offset.width - iconWidth) / 2 - }px`; - hintElement.style.top = `${offset.top}px`; - break; - } -} - -/** - * Triggers when user clicks on the hint element - * - * @api private - */ -export async function showHintDialog(intro: IntroJs, stepId: number) { - const hintElement = document.querySelector( - `.introjs-hint[data-step="${stepId}"]` - ) as HTMLElement; - const item = intro._hintItems[stepId]; - - // call the callback function (if any) - if (isFunction(intro._hintClickCallback)) { - await intro._hintClickCallback.call(intro, hintElement, item, stepId); - } - - // remove all open tooltips - const removedStep = removeHintTooltip(); - - // to toggle the tooltip - if (removedStep !== undefined && parseInt(removedStep, 10) === stepId) { - return; - } - - const tooltipLayer = createElement("div", { - className: "introjs-tooltip", - }); - const tooltipTextLayer = createElement("div"); - const arrowLayer = createElement("div"); - const referenceLayer = createElement("div"); - - tooltipLayer.onclick = (e: Event) => { - //IE9 & Other Browsers - if (e.stopPropagation) { - e.stopPropagation(); - } - //IE8 and Lower - else { - e.cancelBubble = true; - } - }; - - tooltipTextLayer.className = "introjs-tooltiptext"; - - const tooltipWrapper = createElement("p"); - tooltipWrapper.innerHTML = item.hint || ""; - tooltipTextLayer.appendChild(tooltipWrapper); - - if (intro._options.hintShowButton) { - const closeButton = createElement("a"); - closeButton.className = intro._options.buttonClass; - closeButton.setAttribute("role", "button"); - closeButton.innerHTML = intro._options.hintButtonLabel; - closeButton.onclick = () => hideHint(intro, stepId); - tooltipTextLayer.appendChild(closeButton); - } - - arrowLayer.className = "introjs-arrow"; - tooltipLayer.appendChild(arrowLayer); - - tooltipLayer.appendChild(tooltipTextLayer); - - const step = hintElement.getAttribute("data-step") || ""; - - // set current step for _placeTooltip function - intro._currentStep = parseInt(step, 10); - const currentStep = intro._hintItems[intro._currentStep]; - - // align reference layer position - referenceLayer.className = - "introjs-tooltipReferenceLayer introjs-hintReference"; - referenceLayer.setAttribute("data-step", step); - setHelperLayerPosition(intro, currentStep, referenceLayer); - - referenceLayer.appendChild(tooltipLayer); - document.body.appendChild(referenceLayer); - - // set proper position - placeTooltip(intro, currentStep, tooltipLayer, arrowLayer, true); -} - -/** - * Removes open hint (tooltip hint) - * - * @api private - */ -export function removeHintTooltip(): string | undefined { - const tooltip = document.querySelector(".introjs-hintReference"); - - if (tooltip && tooltip.parentNode) { - const step = tooltip.getAttribute("data-step"); - if (!step) return undefined; - - tooltip.parentNode.removeChild(tooltip); - - return step; - } - - return undefined; -} - -/** - * Start parsing hint items - * - * @api private - */ -export async function populateHints( - intro: IntroJs, - targetElm: HTMLElement -): Promise { - intro._hintItems = []; - - if (intro._options.hints && intro._options.hints.length > 0) { - for (const hint of intro._options.hints) { - const currentItem = cloneObject(hint); - - if (typeof currentItem.element === "string") { - //grab the element with given selector from the page - currentItem.element = document.querySelector( - currentItem.element - ) as HTMLElement; - } - - currentItem.hintPosition = - currentItem.hintPosition || intro._options.hintPosition; - currentItem.hintAnimation = - currentItem.hintAnimation || intro._options.hintAnimation; - - if (currentItem.element !== null) { - intro._hintItems.push(currentItem as HintStep); - } - } - } else { - const hints = Array.from( - targetElm.querySelectorAll("*[data-hint]") - ); - - if (!hints || !hints.length) { - return false; - } - - //first add intro items with data-step - for (const currentElement of hints) { - // hint animation - let hintAnimationAttr = currentElement.getAttribute( - "data-hint-animation" - ); - - let hintAnimation: boolean = intro._options.hintAnimation; - if (hintAnimationAttr) { - hintAnimation = hintAnimationAttr === "true"; - } - - intro._hintItems.push({ - element: currentElement, - hint: currentElement.getAttribute("data-hint") || "", - hintPosition: (currentElement.getAttribute("data-hint-position") || - intro._options.hintPosition) as HintPosition, - hintAnimation, - tooltipClass: - currentElement.getAttribute("data-tooltip-class") || undefined, - position: (currentElement.getAttribute("data-position") || - intro._options.tooltipPosition) as TooltipPosition, - }); - } - } - - await addHints(intro); - - DOMEvent.on(document, "click", removeHintTooltip, intro, false); - DOMEvent.on(window, "resize", reAlignHints, intro, true); - - return true; -} - -/** - * Re-aligns all hint elements - * - * @api private - */ -export function reAlignHints(intro: IntroJs) { - for (const { hintTargetElement, hintPosition, element } of intro._hintItems) { - alignHintPosition(hintPosition, element as HTMLElement, hintTargetElement); - } -} diff --git a/src/core/introForElement.ts b/src/core/introForElement.ts deleted file mode 100644 index 4f9666be5..000000000 --- a/src/core/introForElement.ts +++ /dev/null @@ -1,50 +0,0 @@ -import addOverlayLayer from "./addOverlayLayer"; -import DOMEvent from "./DOMEvent"; -import { nextStep } from "./steps"; -import onKeyDown from "./onKeyDown"; -import onResize from "./onResize"; -import fetchIntroSteps from "./fetchIntroSteps"; -import { IntroJs } from "../intro"; -import isFunction from "../util/isFunction"; - -/** - * Initiate a new introduction/guide from an element in the page - * - * @api private - */ -export default async function introForElement( - intro: IntroJs, - targetElm: HTMLElement -): Promise { - // don't start the tour if the instance is not active - if (!intro.isActive()) return false; - - if (isFunction(intro._introStartCallback)) { - await intro._introStartCallback.call(intro, targetElm); - } - - //set it to the introJs object - const steps = fetchIntroSteps(intro, targetElm); - - if (steps.length === 0) { - return false; - } - - intro._introItems = steps; - - //add overlay layer to the page - if (addOverlayLayer(intro, targetElm)) { - //then, start the show - await nextStep(intro); - - targetElm.addEventListener; - if (intro._options.keyboardNavigation) { - DOMEvent.on(window, "keydown", onKeyDown, intro, true); - } - - //for window resize - DOMEvent.on(window, "resize", onResize, intro, true); - } - - return false; -} diff --git a/src/core/onResize.ts b/src/core/onResize.ts deleted file mode 100644 index 089b7cae9..000000000 --- a/src/core/onResize.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { IntroJs } from "../intro"; -import refresh from "./refresh"; - -export default function onResize(intro: IntroJs) { - refresh(intro); -} diff --git a/src/core/refresh.ts b/src/core/refresh.ts deleted file mode 100644 index f90465340..000000000 --- a/src/core/refresh.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { reAlignHints } from "./hint"; -import setHelperLayerPosition from "./setHelperLayerPosition"; -import placeTooltip from "./placeTooltip"; -import fetchIntroSteps from "./fetchIntroSteps"; -import { _recreateBullets, _updateProgressBar } from "./showElement"; -import { IntroJs } from "../intro"; - -/** - * Update placement of the intro objects on the screen - * @api private - */ -export default function refresh(intro: IntroJs, refreshSteps?: boolean) { - const currentStep = intro._currentStep; - - if (currentStep === undefined || currentStep === null || currentStep == -1) - return; - - const step = intro._introItems[currentStep]; - - const referenceLayer = document.querySelector( - ".introjs-tooltipReferenceLayer" - ) as HTMLElement; - const helperLayer = document.querySelector( - ".introjs-helperLayer" - ) as HTMLElement; - const disableInteractionLayer = document.querySelector( - ".introjs-disableInteraction" - ) as HTMLElement; - - // re-align intros - setHelperLayerPosition(intro, step, helperLayer); - setHelperLayerPosition(intro, step, referenceLayer); - setHelperLayerPosition(intro, step, disableInteractionLayer); - - if (refreshSteps) { - intro._introItems = fetchIntroSteps(intro, intro._targetElement); - _recreateBullets(intro, step); - _updateProgressBar(referenceLayer, currentStep, intro._introItems.length); - } - - // re-align tooltip - const oldArrowLayer = document.querySelector(".introjs-arrow"); - const oldTooltipContainer = - document.querySelector(".introjs-tooltip"); - - if (oldTooltipContainer && oldArrowLayer) { - placeTooltip( - intro, - intro._introItems[currentStep], - oldTooltipContainer, - oldArrowLayer - ); - } - - //re-align hints - reAlignHints(intro); - - return intro; -} diff --git a/src/core/removeShowElement.ts b/src/core/removeShowElement.ts deleted file mode 100644 index 1c3c57487..000000000 --- a/src/core/removeShowElement.ts +++ /dev/null @@ -1,16 +0,0 @@ -import removeClass from "../util/removeClass"; - -/** - * To remove all show element(s) - * - * @api private - */ -export default function removeShowElement() { - const elms = Array.from( - document.querySelectorAll(".introjs-showElement") - ); - - for (const elm of elms) { - removeClass(elm, /introjs-[a-zA-Z]+/g); - } -} diff --git a/src/core/setHelperLayerPosition.ts b/src/core/setHelperLayerPosition.ts deleted file mode 100644 index ac62de5bd..000000000 --- a/src/core/setHelperLayerPosition.ts +++ /dev/null @@ -1,47 +0,0 @@ -import getOffset from "../util/getOffset"; -import isFixed from "../util/isFixed"; -import addClass from "../util/addClass"; -import removeClass from "../util/removeClass"; -import setStyle from "../util/setStyle"; -import { IntroJs } from "../intro"; -import { HintStep, IntroStep } from "./steps"; - -/** - * Update the position of the helper layer on the screen - * - * @api private - */ -export default function setHelperLayerPosition( - intro: IntroJs, - step: IntroStep | HintStep, - helperLayer: HTMLElement -) { - if (!helperLayer || !step) return; - - const elementPosition = getOffset( - step.element as HTMLElement, - intro._targetElement - ); - let widthHeightPadding = intro._options.helperElementPadding; - - // If the target element is fixed, the tooltip should be fixed as well. - // Otherwise, remove a fixed class that may be left over from the previous - // step. - if (step.element instanceof Element && isFixed(step.element)) { - addClass(helperLayer, "introjs-fixedTooltip"); - } else { - removeClass(helperLayer, "introjs-fixedTooltip"); - } - - if (step.position === "floating") { - widthHeightPadding = 0; - } - - //set new position to helper layer - setStyle(helperLayer, { - width: `${elementPosition.width + widthHeightPadding}px`, - height: `${elementPosition.height + widthHeightPadding}px`, - top: `${elementPosition.top - widthHeightPadding / 2}px`, - left: `${elementPosition.left - widthHeightPadding / 2}px`, - }); -} diff --git a/src/core/showElement.ts b/src/core/showElement.ts deleted file mode 100644 index 7cdaf1d3d..000000000 --- a/src/core/showElement.ts +++ /dev/null @@ -1,641 +0,0 @@ -import setShowElement from "../util/setShowElement"; -import scrollParentToElement from "../util/scrollParentToElement"; -import addClass from "../util/addClass"; -import scrollTo from "../util/scrollTo"; -import exitIntro from "./exitIntro"; -import setAnchorAsButton from "../util/setAnchorAsButton"; -import { IntroStep, nextStep, previousStep } from "./steps"; -import setHelperLayerPosition from "./setHelperLayerPosition"; -import placeTooltip from "./placeTooltip"; -import removeShowElement from "./removeShowElement"; -import createElement from "../util/createElement"; -import setStyle from "../util/setStyle"; -import appendChild from "../util/appendChild"; -import { IntroJs } from "../intro"; -import isFunction from "../util/isFunction"; - -/** - * Gets the current progress percentage - * - * @api private - * @returns current progress percentage - */ -function _getProgress(currentStep: number, introItemsLength: number) { - // Steps are 0 indexed - return ((currentStep + 1) / introItemsLength) * 100; -} - -/** - * Add disableinteraction layer and adjust the size and position of the layer - * - * @api private - */ -function _disableInteraction(intro: IntroJs, step: IntroStep) { - let disableInteractionLayer = document.querySelector( - ".introjs-disableInteraction" - ); - - if (disableInteractionLayer === null) { - disableInteractionLayer = createElement("div", { - className: "introjs-disableInteraction", - }); - - intro._targetElement.appendChild(disableInteractionLayer); - } - - setHelperLayerPosition(intro, step, disableInteractionLayer); -} - -/** - * Creates the bullets layer - * @private - */ -function _createBullets(intro: IntroJs, targetElement: IntroStep): HTMLElement { - const bulletsLayer = createElement("div", { - className: "introjs-bullets", - }); - - if (intro._options.showBullets === false) { - bulletsLayer.style.display = "none"; - } - - const ulContainer = createElement("ul"); - ulContainer.setAttribute("role", "tablist"); - - const anchorClick = function (this: HTMLElement) { - const stepNumber = this.getAttribute("data-step-number"); - if (stepNumber == null) return; - - intro.goToStep(parseInt(stepNumber, 10)); - }; - - for (let i = 0; i < intro._introItems.length; i++) { - const { step } = intro._introItems[i]; - - const innerLi = createElement("li"); - const anchorLink = createElement("a"); - - innerLi.setAttribute("role", "presentation"); - anchorLink.setAttribute("role", "tab"); - - anchorLink.onclick = anchorClick; - - if (i === targetElement.step - 1) { - anchorLink.className = "active"; - } - - setAnchorAsButton(anchorLink); - anchorLink.innerHTML = " "; - anchorLink.setAttribute("data-step-number", step.toString()); - - innerLi.appendChild(anchorLink); - ulContainer.appendChild(innerLi); - } - - bulletsLayer.appendChild(ulContainer); - - return bulletsLayer; -} - -/** - * Deletes and recreates the bullets layer - * @private - */ -export function _recreateBullets(intro: IntroJs, targetElement: IntroStep) { - if (intro._options.showBullets) { - const existing = document.querySelector(".introjs-bullets"); - - if (existing && existing.parentNode) { - existing.parentNode.replaceChild( - _createBullets(intro, targetElement), - existing - ); - } - } -} - -/** - * Updates the bullets - */ -function _updateBullets( - showBullets: boolean, - oldReferenceLayer: HTMLElement, - targetElement: IntroStep -) { - if (showBullets) { - const oldRefActiveBullet = oldReferenceLayer.querySelector( - ".introjs-bullets li > a.active" - ); - - const oldRefBulletStepNumber = oldReferenceLayer.querySelector( - `.introjs-bullets li > a[data-step-number="${targetElement.step}"]` - ); - - if (oldRefActiveBullet && oldRefBulletStepNumber) { - oldRefActiveBullet.className = ""; - oldRefBulletStepNumber.className = "active"; - } - } -} - -/** - * Creates the progress-bar layer and elements - * @private - */ -function _createProgressBar(intro: IntroJs) { - const progressLayer = createElement("div"); - - progressLayer.className = "introjs-progress"; - - if (intro._options.showProgress === false) { - progressLayer.style.display = "none"; - } - - const progressBar = createElement("div", { - className: "introjs-progressbar", - }); - - if (intro._options.progressBarAdditionalClass) { - progressBar.className += " " + intro._options.progressBarAdditionalClass; - } - - const progress = _getProgress(intro._currentStep, intro._introItems.length); - progressBar.setAttribute("role", "progress"); - progressBar.setAttribute("aria-valuemin", "0"); - progressBar.setAttribute("aria-valuemax", "100"); - progressBar.setAttribute("aria-valuenow", progress.toString()); - progressBar.style.cssText = `width:${progress}%;`; - - progressLayer.appendChild(progressBar); - - return progressLayer; -} - -/** - * Updates an existing progress bar variables - * @private - */ -export function _updateProgressBar( - oldReferenceLayer: HTMLElement, - currentStep: number, - introItemsLength: number -) { - const progressBar = oldReferenceLayer.querySelector( - ".introjs-progress .introjs-progressbar" - ); - - if (!progressBar) return; - - const progress = _getProgress(currentStep, introItemsLength); - - progressBar.style.cssText = `width:${progress}%;`; - progressBar.setAttribute("aria-valuenow", progress.toString()); -} - -/** - * Show an element on the page - * - * @api private - */ -export default async function _showElement( - intro: IntroJs, - targetElement: IntroStep -) { - if (isFunction(intro._introChangeCallback)) { - await intro._introChangeCallback.call(intro, targetElement.element); - } - - const oldHelperLayer = document.querySelector( - ".introjs-helperLayer" - ); - const oldReferenceLayer = document.querySelector( - ".introjs-tooltipReferenceLayer" - ); - let highlightClass = "introjs-helperLayer"; - let nextTooltipButton: HTMLElement; - let prevTooltipButton: HTMLElement; - let skipTooltipButton: HTMLElement; - - //check for a current step highlight class - if (typeof targetElement.highlightClass === "string") { - highlightClass += ` ${targetElement.highlightClass}`; - } - //check for options highlight class - if (typeof intro._options.highlightClass === "string") { - highlightClass += ` ${intro._options.highlightClass}`; - } - - if (oldHelperLayer !== null && oldReferenceLayer !== null) { - const oldHelperNumberLayer = oldReferenceLayer.querySelector( - ".introjs-helperNumberLayer" - ); - const oldTooltipLayer = oldReferenceLayer.querySelector( - ".introjs-tooltiptext" - ) as HTMLElement; - const oldTooltipTitleLayer = oldReferenceLayer.querySelector( - ".introjs-tooltip-title" - ) as HTMLElement; - const oldArrowLayer = oldReferenceLayer.querySelector( - ".introjs-arrow" - ) as HTMLElement; - const oldTooltipContainer = oldReferenceLayer.querySelector( - ".introjs-tooltip" - ) as HTMLElement; - - skipTooltipButton = oldReferenceLayer.querySelector( - ".introjs-skipbutton" - ) as HTMLElement; - prevTooltipButton = oldReferenceLayer.querySelector( - ".introjs-prevbutton" - ) as HTMLElement; - nextTooltipButton = oldReferenceLayer.querySelector( - ".introjs-nextbutton" - ) as HTMLElement; - - //update or reset the helper highlight class - oldHelperLayer.className = highlightClass; - //hide the tooltip - oldTooltipContainer.style.opacity = "0"; - oldTooltipContainer.style.display = "none"; - - // if the target element is within a scrollable element - scrollParentToElement( - intro._options.scrollToElement, - targetElement.element as HTMLElement - ); - - // set new position to helper layer - setHelperLayerPosition(intro, targetElement, oldHelperLayer); - setHelperLayerPosition(intro, targetElement, oldReferenceLayer); - - //remove old classes if the element still exist - removeShowElement(); - - //we should wait until the CSS3 transition is competed (it's 0.3 sec) to prevent incorrect `height` and `width` calculation - if (intro._lastShowElementTimer) { - window.clearTimeout(intro._lastShowElementTimer); - } - - intro._lastShowElementTimer = window.setTimeout(() => { - // set current step to the label - if (oldHelperNumberLayer !== null) { - oldHelperNumberLayer.innerHTML = `${targetElement.step} ${intro._options.stepNumbersOfLabel} ${intro._introItems.length}`; - } - - // set current tooltip text - oldTooltipLayer.innerHTML = targetElement.intro || ""; - - // set current tooltip title - oldTooltipTitleLayer.innerHTML = targetElement.title || ""; - - //set the tooltip position - oldTooltipContainer.style.display = "block"; - placeTooltip(intro, targetElement, oldTooltipContainer, oldArrowLayer); - - //change active bullet - _updateBullets( - intro._options.showBullets, - oldReferenceLayer, - targetElement - ); - - _updateProgressBar( - oldReferenceLayer, - intro._currentStep, - intro._introItems.length - ); - - //show the tooltip - oldTooltipContainer.style.opacity = "1"; - - //reset button focus - if ( - typeof nextTooltipButton !== "undefined" && - nextTooltipButton !== null && - /introjs-donebutton/gi.test(nextTooltipButton.className) - ) { - // skip button is now "done" button - nextTooltipButton.focus(); - } else if ( - typeof nextTooltipButton !== "undefined" && - nextTooltipButton !== null - ) { - //still in the tour, focus on next - nextTooltipButton.focus(); - } - - // change the scroll of the window, if needed - scrollTo( - intro._options.scrollToElement, - targetElement.scrollTo, - intro._options.scrollPadding, - targetElement.element as HTMLElement, - oldTooltipLayer - ); - }, 350); - - // end of old element if-else condition - } else { - const helperLayer = createElement("div", { - className: highlightClass, - }); - const referenceLayer = createElement("div", { - className: "introjs-tooltipReferenceLayer", - }); - const arrowLayer = createElement("div", { - className: "introjs-arrow", - }); - const tooltipLayer = createElement("div", { - className: "introjs-tooltip", - }); - const tooltipTextLayer = createElement("div", { - className: "introjs-tooltiptext", - }); - const tooltipHeaderLayer = createElement("div", { - className: "introjs-tooltip-header", - }); - const tooltipTitleLayer = createElement("h1", { - className: "introjs-tooltip-title", - }); - - const buttonsLayer = createElement("div"); - - setStyle(helperLayer, { - "box-shadow": `0 0 1px 2px rgba(33, 33, 33, 0.8), rgba(33, 33, 33, ${intro._options.overlayOpacity.toString()}) 0 0 0 5000px`, - }); - - // target is within a scrollable element - scrollParentToElement( - intro._options.scrollToElement, - targetElement.element as HTMLElement - ); - - //set new position to helper layer - setHelperLayerPosition(intro, targetElement, helperLayer); - setHelperLayerPosition(intro, targetElement, referenceLayer); - - //add helper layer to target element - appendChild(intro._targetElement, helperLayer, true); - appendChild(intro._targetElement, referenceLayer); - - tooltipTextLayer.innerHTML = targetElement.intro; - tooltipTitleLayer.innerHTML = targetElement.title; - - buttonsLayer.className = "introjs-tooltipbuttons"; - if (intro._options.showButtons === false) { - buttonsLayer.style.display = "none"; - } - - tooltipHeaderLayer.appendChild(tooltipTitleLayer); - tooltipLayer.appendChild(tooltipHeaderLayer); - tooltipLayer.appendChild(tooltipTextLayer); - - // "Do not show again" checkbox - if (intro._options.dontShowAgain) { - const dontShowAgainWrapper = createElement("div", { - className: "introjs-dontShowAgain", - }); - const dontShowAgainCheckbox = createElement("input", { - type: "checkbox", - id: "introjs-dontShowAgain", - name: "introjs-dontShowAgain", - }); - dontShowAgainCheckbox.onchange = (e) => { - intro.setDontShowAgain((e.target).checked); - }; - const dontShowAgainCheckboxLabel = createElement("label", { - htmlFor: "introjs-dontShowAgain", - }); - dontShowAgainCheckboxLabel.innerText = intro._options.dontShowAgainLabel; - dontShowAgainWrapper.appendChild(dontShowAgainCheckbox); - dontShowAgainWrapper.appendChild(dontShowAgainCheckboxLabel); - - tooltipLayer.appendChild(dontShowAgainWrapper); - } - - tooltipLayer.appendChild(_createBullets(intro, targetElement)); - tooltipLayer.appendChild(_createProgressBar(intro)); - - // add helper layer number - const helperNumberLayer = createElement("div"); - - if (intro._options.showStepNumbers === true) { - helperNumberLayer.className = "introjs-helperNumberLayer"; - helperNumberLayer.innerHTML = `${targetElement.step} ${intro._options.stepNumbersOfLabel} ${intro._introItems.length}`; - tooltipLayer.appendChild(helperNumberLayer); - } - - tooltipLayer.appendChild(arrowLayer); - referenceLayer.appendChild(tooltipLayer); - - //next button - nextTooltipButton = createElement("a"); - - nextTooltipButton.onclick = async () => { - if (intro._introItems.length - 1 !== intro._currentStep) { - await nextStep(intro); - } else if (/introjs-donebutton/gi.test(nextTooltipButton.className)) { - if (isFunction(intro._introCompleteCallback)) { - await intro._introCompleteCallback.call( - intro, - intro._currentStep, - "done" - ); - } - - await exitIntro(intro, intro._targetElement); - } - }; - - setAnchorAsButton(nextTooltipButton); - nextTooltipButton.innerHTML = intro._options.nextLabel; - - //previous button - prevTooltipButton = createElement("a"); - - prevTooltipButton.onclick = async () => { - if (intro._currentStep > 0) { - await previousStep(intro); - } - }; - - setAnchorAsButton(prevTooltipButton); - prevTooltipButton.innerHTML = intro._options.prevLabel; - - //skip button - skipTooltipButton = createElement("a", { - className: "introjs-skipbutton", - }); - - setAnchorAsButton(skipTooltipButton); - skipTooltipButton.innerHTML = intro._options.skipLabel; - - skipTooltipButton.onclick = async () => { - if ( - intro._introItems.length - 1 === intro._currentStep && - isFunction(intro._introCompleteCallback) - ) { - await intro._introCompleteCallback.call( - intro, - intro._currentStep, - "skip" - ); - } - - if (isFunction(intro._introSkipCallback)) { - await intro._introSkipCallback.call(intro, intro._currentStep); - } - - await exitIntro(intro, intro._targetElement); - }; - - tooltipHeaderLayer.appendChild(skipTooltipButton); - - // in order to prevent displaying previous button always - if (intro._introItems.length > 1) { - buttonsLayer.appendChild(prevTooltipButton); - } - - // we always need the next button because this - // button changes to "Done" in the last step of the tour - buttonsLayer.appendChild(nextTooltipButton); - tooltipLayer.appendChild(buttonsLayer); - - // set proper position - placeTooltip(intro, targetElement, tooltipLayer, arrowLayer); - - // change the scroll of the window, if needed - scrollTo( - intro._options.scrollToElement, - targetElement.scrollTo, - intro._options.scrollPadding, - targetElement.element as HTMLElement, - tooltipLayer - ); - - //end of new element if-else condition - } - - // removing previous disable interaction layer - const disableInteractionLayer = intro._targetElement.querySelector( - ".introjs-disableInteraction" - ); - if (disableInteractionLayer && disableInteractionLayer.parentNode) { - disableInteractionLayer.parentNode.removeChild(disableInteractionLayer); - } - - //disable interaction - if (targetElement.disableInteraction) { - _disableInteraction(intro, targetElement); - } - - // when it's the first step of tour - if (intro._currentStep === 0 && intro._introItems.length > 1) { - if ( - typeof nextTooltipButton !== "undefined" && - nextTooltipButton !== null - ) { - nextTooltipButton.className = `${intro._options.buttonClass} introjs-nextbutton`; - nextTooltipButton.innerHTML = intro._options.nextLabel; - } - - if (intro._options.hidePrev === true) { - if ( - typeof prevTooltipButton !== "undefined" && - prevTooltipButton !== null - ) { - prevTooltipButton.className = `${intro._options.buttonClass} introjs-prevbutton introjs-hidden`; - } - if ( - typeof nextTooltipButton !== "undefined" && - nextTooltipButton !== null - ) { - addClass(nextTooltipButton, "introjs-fullbutton"); - } - } else { - if ( - typeof prevTooltipButton !== "undefined" && - prevTooltipButton !== null - ) { - prevTooltipButton.className = `${intro._options.buttonClass} introjs-prevbutton introjs-disabled`; - } - } - } else if ( - intro._introItems.length - 1 === intro._currentStep || - intro._introItems.length === 1 - ) { - // last step of tour - if ( - typeof prevTooltipButton !== "undefined" && - prevTooltipButton !== null - ) { - prevTooltipButton.className = `${intro._options.buttonClass} introjs-prevbutton`; - } - - if (intro._options.hideNext === true) { - if ( - typeof nextTooltipButton !== "undefined" && - nextTooltipButton !== null - ) { - nextTooltipButton.className = `${intro._options.buttonClass} introjs-nextbutton introjs-hidden`; - } - if ( - typeof prevTooltipButton !== "undefined" && - prevTooltipButton !== null - ) { - addClass(prevTooltipButton, "introjs-fullbutton"); - } - } else { - if ( - typeof nextTooltipButton !== "undefined" && - nextTooltipButton !== null - ) { - if (intro._options.nextToDone === true) { - nextTooltipButton.innerHTML = intro._options.doneLabel; - addClass( - nextTooltipButton, - `${intro._options.buttonClass} introjs-nextbutton introjs-donebutton` - ); - } else { - nextTooltipButton.className = `${intro._options.buttonClass} introjs-nextbutton introjs-disabled`; - } - } - } - } else { - // steps between start and end - if ( - typeof prevTooltipButton !== "undefined" && - prevTooltipButton !== null - ) { - prevTooltipButton.className = `${intro._options.buttonClass} introjs-prevbutton`; - } - if ( - typeof nextTooltipButton !== "undefined" && - nextTooltipButton !== null - ) { - nextTooltipButton.className = `${intro._options.buttonClass} introjs-nextbutton`; - nextTooltipButton.innerHTML = intro._options.nextLabel; - } - } - - if (typeof prevTooltipButton !== "undefined" && prevTooltipButton !== null) { - prevTooltipButton.setAttribute("role", "button"); - } - if (typeof nextTooltipButton !== "undefined" && nextTooltipButton !== null) { - nextTooltipButton.setAttribute("role", "button"); - } - if (typeof skipTooltipButton !== "undefined" && skipTooltipButton !== null) { - skipTooltipButton.setAttribute("role", "button"); - } - - //Set focus on "next" button, so that hitting Enter always moves you onto the next step - if (typeof nextTooltipButton !== "undefined" && nextTooltipButton !== null) { - nextTooltipButton.focus(); - } - - setShowElement(targetElement.element as HTMLElement); - - if (isFunction(intro._introAfterChangeCallback)) { - await intro._introAfterChangeCallback.call(intro, targetElement.element); - } -} diff --git a/src/core/steps.ts b/src/core/steps.ts deleted file mode 100644 index fb4bff4d7..000000000 --- a/src/core/steps.ts +++ /dev/null @@ -1,173 +0,0 @@ -import isFunction from "../util/isFunction"; -import exitIntro from "./exitIntro"; -import showElement from "./showElement"; -import { IntroJs } from "../intro"; - -export type ScrollTo = "off" | "element" | "tooltip"; - -export type TooltipPosition = - | "floating" - | "top" - | "bottom" - | "left" - | "right" - | "top-right-aligned" - | "top-left-aligned" - | "top-middle-aligned" - | "bottom-right-aligned" - | "bottom-left-aligned" - | "bottom-middle-aligned"; - -export type HintPosition = - | "top-left" - | "top-right" - | "top-middle" - | "bottom-left" - | "bottom-right" - | "bottom-middle" - | "middle-left" - | "middle-right" - | "middle-middle"; - -export type IntroStep = { - step: number; - title: string; - intro: string; - tooltipClass?: string; - highlightClass?: string; - element?: HTMLElement | string | null; - position: TooltipPosition; - scrollTo: ScrollTo; - disableInteraction?: boolean; -}; - -export type HintStep = { - element?: HTMLElement | string | null; - tooltipClass?: string; - position: TooltipPosition; - hint?: string; - hintTargetElement?: HTMLElement; - hintAnimation?: boolean; - hintPosition: HintPosition; -}; - -/** - * Go to specific step of introduction - * - * @api private - */ -export async function goToStep(intro: IntroJs, step: number) { - //because steps starts with zero - intro._currentStep = step - 2; - if (typeof intro._introItems !== "undefined") { - await nextStep(intro); - } -} - -/** - * Go to the specific step of introduction with the explicit [data-step] number - * - * @api private - */ -export async function goToStepNumber(intro: IntroJs, step: number) { - intro._currentStepNumber = step; - if (typeof intro._introItems !== "undefined") { - await nextStep(intro); - } -} - -/** - * Go to next step on intro - * - * @api private - */ -export async function nextStep(intro: IntroJs) { - intro._direction = "forward"; - - if (typeof intro._currentStepNumber !== "undefined") { - for (let i = 0; i < intro._introItems.length; i++) { - const item = intro._introItems[i]; - if (item.step === intro._currentStepNumber) { - intro._currentStep = i - 1; - intro._currentStepNumber = undefined; - } - } - } - - if (intro._currentStep === -1) { - intro._currentStep = 0; - } else { - ++intro._currentStep; - } - - const nextStep = intro._introItems[intro._currentStep]; - let continueStep = true; - - if (isFunction(intro._introBeforeChangeCallback)) { - continueStep = await intro._introBeforeChangeCallback.call( - intro, - nextStep && (nextStep.element as HTMLElement), - intro._currentStep, - intro._direction - ); - } - - // if `onbeforechange` returned `false`, stop displaying the element - if (continueStep === false) { - --intro._currentStep; - return false; - } - - if (intro._introItems.length <= intro._currentStep) { - // end of the intro - // check if any callback is defined - if (isFunction(intro._introCompleteCallback)) { - await intro._introCompleteCallback.call(intro, intro._currentStep, "end"); - } - - await exitIntro(intro, intro._targetElement); - - return false; - } - - await showElement(intro, nextStep); - - return true; -} - -/** - * Go to previous step on intro - * - * @api private - */ -export async function previousStep(intro: IntroJs) { - intro._direction = "backward"; - - if (intro._currentStep <= 0) { - return false; - } - - --intro._currentStep; - - const nextStep = intro._introItems[intro._currentStep]; - let continueStep = true; - - if (isFunction(intro._introBeforeChangeCallback)) { - continueStep = await intro._introBeforeChangeCallback.call( - intro, - nextStep && (nextStep.element as HTMLElement), - intro._currentStep, - intro._direction - ); - } - - // if `onbeforechange` returned `false`, stop displaying the element - if (continueStep === false) { - ++intro._currentStep; - return false; - } - - await showElement(intro, nextStep); - - return true; -} diff --git a/src/index.test.ts b/src/index.test.ts new file mode 100644 index 000000000..46ec3f394 --- /dev/null +++ b/src/index.test.ts @@ -0,0 +1,29 @@ +import { Tour } from "./packages/tour"; +import introJs from "./index"; +import { Hint } from "./packages/hint"; + +describe("index", () => { + it("should create a new instance of Tour", () => { + // Arrange + const stubElement = document.createElement("div"); + jest.spyOn(document, "createElement").mockReturnValue(stubElement); + + // Act + const tourInstance = introJs.tour(stubElement); + + // Assert + expect(tourInstance).toBeInstanceOf(Tour); + }); + + it("should create a new instance of Hint", () => { + // Arrange + const stubElement = document.createElement("div"); + jest.spyOn(document, "createElement").mockReturnValue(stubElement); + + // Act + const hintInstance = introJs.hint(stubElement); + + // Assert + expect(hintInstance).toBeInstanceOf(Hint); + }); +}); diff --git a/src/index.ts b/src/index.ts index 2d9c32915..b315cb5c1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,52 +1,66 @@ import { version } from "../package.json"; -import { IntroJs } from "./intro"; -import stamp from "./util/stamp"; +import { Hint } from "./packages/hint"; +import { Tour } from "./packages/tour"; -/** - * Create a new IntroJS instance - * - * @param targetElm Optional target element to start the tour/hint on - * @returns - */ -const introJs = (targetElm?: string | HTMLElement) => { - let instance: IntroJs; - - if (typeof targetElm === "object") { - instance = new IntroJs(targetElm); - } else if (typeof targetElm === "string") { - //select the target element with query selector - const targetElement = document.querySelector(targetElm); - - if (targetElement) { - instance = new IntroJs(targetElement); - } else { - throw new Error("There is no element with given selector."); - } - } else { - instance = new IntroJs(document.body); +class LegacyIntroJs extends Tour { + /** + * @deprecated introJs().addHints() is deprecated, please use introJs.hint().addHints() instead + * @param args + */ + addHints(..._: any[]) { + console.error( + "introJs().addHints() is deprecated, please use introJs.hint.addHints() instead." + ); + } + + /** + * @deprecated introJs().addHint() is deprecated, please use introJs.hint.addHint() instead + * @param args + */ + addHint(..._: any[]) { + console.error( + "introJs().addHint() is deprecated, please use introJs.hint.addHint() instead." + ); } - // add instance to list of _instances - // passing group to stamp to increment - // from 0 onward somewhat reliably - introJs.instances[stamp(instance, "introjs-instance")] = instance; - return instance; + /** + * @deprecated introJs().removeHints() is deprecated, please use introJs.hint.hideHints() instead + * @param args + */ + removeHints(..._: any[]) { + console.error( + "introJs().removeHints() is deprecated, please use introJs.hint.removeHints() instead." + ); + } +} + +/** + * Intro.js module + */ +const introJs = (elementOrSelector?: string | HTMLElement) => { + console.warn( + "introJs() is deprecated. Please use introJs.tour() or introJs.hint() instead." + ); + return new LegacyIntroJs(elementOrSelector); }; /** - * Current IntroJs version - * - * @property version - * @type String + * Create a new Intro.js Tour instance + * @param elementOrSelector Optional target element to start the Tour on */ -introJs.version = version; +introJs.tour = (elementOrSelector?: string | HTMLElement) => + new Tour(elementOrSelector); + +/** + * Create a new Intro.js Hint instance + * @param elementOrSelector Optional target element to start the Hint on + */ +introJs.hint = (elementOrSelector?: string | HTMLElement) => + new Hint(elementOrSelector); /** - * key-val object helper for introJs instances - * - * @property instances - * @type Object + * Current Intro.js version */ -introJs.instances = {} as { [key: number]: IntroJs }; +introJs.version = version; export default introJs; diff --git a/src/intro.ts b/src/intro.ts deleted file mode 100644 index 1cdcb1059..000000000 --- a/src/intro.ts +++ /dev/null @@ -1,325 +0,0 @@ -import { getDontShowAgain, setDontShowAgain } from "./core/dontShowAgain"; -import exitIntro from "./core/exitIntro"; -import { - hideHint, - hideHints, - populateHints, - removeHint, - removeHints, - showHint, - showHintDialog, - showHints, -} from "./core/hint"; -import introForElement from "./core/introForElement"; -import refresh from "./core/refresh"; -import { - HintStep, - IntroStep, - goToStep, - goToStepNumber, - nextStep, - previousStep, -} from "./core/steps"; -import { Options, getDefaultOptions, setOption, setOptions } from "./option"; -import isFunction from "./util/isFunction"; - -type introBeforeChangeCallback = ( - this: IntroJs, - targetElement: HTMLElement, - currentStep: number, - direction: "backward" | "forward" -) => Promise | boolean; -type introChangeCallback = ( - this: IntroJs, - targetElement: HTMLElement -) => void | Promise; -type introAfterChangeCallback = ( - this: IntroJs, - targetElement: HTMLElement -) => void | Promise; -type introCompleteCallback = ( - this: IntroJs, - currentStep: number, - reason: "skip" | "end" | "done" -) => void | Promise; -type introStartCallback = ( - this: IntroJs, - targetElement: HTMLElement -) => void | Promise; -type introExitCallback = (this: IntroJs) => void | Promise; -type introSkipCallback = ( - this: IntroJs, - currentStep: number -) => void | Promise; -type introBeforeExitCallback = ( - this: IntroJs, - targetElement: HTMLElement -) => boolean | Promise; -type hintsAddedCallback = (this: IntroJs) => void | Promise; -type hintClickCallback = ( - this: IntroJs, - hintElement: HTMLElement, - item: HintStep, - stepId: number -) => void | Promise; -type hintCloseCallback = ( - this: IntroJs, - stepId: number -) => void | Promise; - -export class IntroJs { - public _currentStep: number = -1; - public _currentStepNumber: number | undefined; - public _direction: "forward" | "backward"; - public _targetElement: HTMLElement; - public _introItems: IntroStep[] = []; - public _hintItems: HintStep[] = []; - public _options: Options; - public _introBeforeChangeCallback?: introBeforeChangeCallback; - public _introChangeCallback?: introChangeCallback; - public _introAfterChangeCallback?: introAfterChangeCallback; - public _introCompleteCallback?: introCompleteCallback; - public _introStartCallback?: introStartCallback; - public _introExitCallback?: introExitCallback; - public _introSkipCallback?: introSkipCallback; - public _introBeforeExitCallback?: introBeforeExitCallback; - - public _hintsAddedCallback?: hintsAddedCallback; - public _hintClickCallback?: hintClickCallback; - public _hintCloseCallback?: hintCloseCallback; - - public _lastShowElementTimer: number; - public _hintsAutoRefreshFunction: (...args: any[]) => void; - - public constructor(targetElement: HTMLElement) { - this._targetElement = targetElement; - this._options = getDefaultOptions(); - } - - isActive() { - if (this._options.dontShowAgain && getDontShowAgain(this)) { - return false; - } - - return this._options.isActive; - } - - clone() { - return new IntroJs(this._targetElement); - } - - setOption(key: K, value: Options[K]) { - this._options = setOption(this._options, key, value); - return this; - } - - setOptions(partialOptions: Partial) { - this._options = setOptions(this._options, partialOptions); - return this; - } - - async start() { - await introForElement(this, this._targetElement); - return this; - } - - async goToStep(step: number) { - await goToStep(this, step); - return this; - } - - addStep(step: Partial) { - if (!this._options.steps) { - this._options.steps = []; - } - - this._options.steps.push(step); - - return this; - } - - addSteps(steps: Partial[]) { - if (!steps.length) return this; - - for (let index = 0; index < steps.length; index++) { - this.addStep(steps[index]); - } - - return this; - } - - async goToStepNumber(step: number) { - await goToStepNumber(this, step); - return this; - } - - async nextStep() { - await nextStep(this); - return this; - } - - async previousStep() { - await previousStep(this); - return this; - } - - currentStep() { - return this._currentStep; - } - - async exit(force: boolean) { - await exitIntro(this, this._targetElement, force); - return this; - } - - refresh(refreshSteps?: boolean) { - refresh(this, refreshSteps); - return this; - } - - setDontShowAgain(dontShowAgain: boolean) { - setDontShowAgain(this, dontShowAgain); - return this; - } - - onbeforechange(providedCallback: introBeforeChangeCallback) { - if (isFunction(providedCallback)) { - this._introBeforeChangeCallback = providedCallback; - } else { - throw new Error( - "Provided callback for onbeforechange was not a function" - ); - } - return this; - } - - onchange(providedCallback: introChangeCallback) { - if (isFunction(providedCallback)) { - this._introChangeCallback = providedCallback; - } else { - throw new Error("Provided callback for onchange was not a function."); - } - return this; - } - - onafterchange(providedCallback: introAfterChangeCallback) { - if (isFunction(providedCallback)) { - this._introAfterChangeCallback = providedCallback; - } else { - throw new Error("Provided callback for onafterchange was not a function"); - } - return this; - } - - oncomplete(providedCallback: introCompleteCallback) { - if (isFunction(providedCallback)) { - this._introCompleteCallback = providedCallback; - } else { - throw new Error("Provided callback for oncomplete was not a function."); - } - return this; - } - - onhintsadded(providedCallback: hintsAddedCallback) { - if (isFunction(providedCallback)) { - this._hintsAddedCallback = providedCallback; - } else { - throw new Error("Provided callback for onhintsadded was not a function."); - } - return this; - } - - onhintclick(providedCallback: hintClickCallback) { - if (isFunction(providedCallback)) { - this._hintClickCallback = providedCallback; - } else { - throw new Error("Provided callback for onhintclick was not a function."); - } - return this; - } - - onhintclose(providedCallback: hintCloseCallback) { - if (isFunction(providedCallback)) { - this._hintCloseCallback = providedCallback; - } else { - throw new Error("Provided callback for onhintclose was not a function."); - } - return this; - } - - onstart(providedCallback: introStartCallback) { - if (isFunction(providedCallback)) { - this._introStartCallback = providedCallback; - } else { - throw new Error("Provided callback for onstart was not a function."); - } - return this; - } - - onexit(providedCallback: introExitCallback) { - if (isFunction(providedCallback)) { - this._introExitCallback = providedCallback; - } else { - throw new Error("Provided callback for onexit was not a function."); - } - return this; - } - - onskip(providedCallback: introSkipCallback) { - if (isFunction(providedCallback)) { - this._introSkipCallback = providedCallback; - } else { - throw new Error("Provided callback for onskip was not a function."); - } - return this; - } - - onbeforeexit(providedCallback: introBeforeExitCallback) { - if (isFunction(providedCallback)) { - this._introBeforeExitCallback = providedCallback; - } else { - throw new Error("Provided callback for onbeforeexit was not a function."); - } - return this; - } - - async addHints() { - await populateHints(this, this._targetElement); - return this; - } - - async hideHint(stepId: number) { - await hideHint(this, stepId); - return this; - } - - async hideHints() { - await hideHints(this); - return this; - } - - showHint(stepId: number) { - showHint(stepId); - return this; - } - - async showHints() { - await showHints(this); - return this; - } - - removeHints() { - removeHints(this); - return this; - } - - removeHint(stepId: number) { - removeHint(stepId); - return this; - } - - async showHintDialog(stepId: number) { - await showHintDialog(this, stepId); - return this; - } -} diff --git a/src/option.test.ts b/src/option.test.ts new file mode 100644 index 000000000..b8255a5e4 --- /dev/null +++ b/src/option.test.ts @@ -0,0 +1,35 @@ +import { setOption, setOptions } from "./option"; + +describe("option", () => { + describe("setOption", () => { + it("should set option", () => { + // Arrange + const mockOption = { + key1: "value1", + }; + + // Act + setOption(mockOption, "key1", "newValue1"); + + // Assert + expect(mockOption.key1).toBe("newValue1"); + }); + }); + + describe("setOptions", () => { + it("should set options", () => { + // Arrange + const mockOption = { + key1: "value1", + key2: "value2", + }; + + // Act + setOptions(mockOption, { key2: "newValue2", key1: "newValue1" }); + + // Assert + expect(mockOption.key1).toBe("newValue1"); + expect(mockOption.key2).toBe("newValue2"); + }); + }); +}); diff --git a/src/option.ts b/src/option.ts index b4901d1c7..a707dcfd5 100644 --- a/src/option.ts +++ b/src/option.ts @@ -1,158 +1,15 @@ -import { - HintPosition, - HintStep, - IntroStep, - ScrollTo, - TooltipPosition, -} from "./core/steps"; - -export interface Options { - steps: Partial[]; - hints: Partial[]; - /* Is this tour instance active? Don't show the tour again if this flag is set to false */ - isActive: boolean; - /* Next button label in tooltip box */ - nextLabel: string; - /* Previous button label in tooltip box */ - prevLabel: string; - /* Skip button label in tooltip box */ - skipLabel: string; - /* Done button label in tooltip box */ - doneLabel: string; - /* Hide previous button in the first step? Otherwise, it will be disabled button. */ - hidePrev: boolean; - /* Hide next button in the last step? Otherwise, it will be disabled button (note: this will also hide the "Done" button) */ - hideNext: boolean; - /* Change the Next button to Done in the last step of the intro? otherwise, it will render a disabled button */ - nextToDone: boolean; - /* Default tooltip box position */ - tooltipPosition: string; - /* Next CSS class for tooltip boxes */ - tooltipClass: string; - /* Start intro for a group of elements */ - group: string; - /* CSS class that is added to the helperLayer */ - highlightClass: string; - /* Close introduction when pressing Escape button? */ - exitOnEsc: boolean; - /* Close introduction when clicking on overlay layer? */ - exitOnOverlayClick: boolean; - /* Display the pagination detail */ - showStepNumbers: boolean; - /* Pagination "of" label */ - stepNumbersOfLabel: string; - /* Let user use keyboard to navigate the tour? */ - keyboardNavigation: boolean; - /* Show tour control buttons? */ - showButtons: boolean; - /* Show tour bullets? */ - showBullets: boolean; - /* Show tour progress? */ - showProgress: boolean; - /* Scroll to highlighted element? */ - scrollToElement: boolean; - /* - * Should we scroll the tooltip or target element? - * Options are: 'element', 'tooltip' or 'off' - */ - scrollTo: ScrollTo; - /* Padding to add after scrolling when element is not in the viewport (in pixels) */ - scrollPadding: number; - /* Set the overlay opacity */ - overlayOpacity: number; - /* To determine the tooltip position automatically based on the window.width/height */ - autoPosition: boolean; - /* Precedence of positions, when auto is enabled */ - positionPrecedence: TooltipPosition[]; - /* Disable an interaction with element? */ - disableInteraction: boolean; - /* To display the "Don't show again" checkbox in the tour */ - dontShowAgain: boolean; - dontShowAgainLabel: string; - /* "Don't show again" cookie name and expiry (in days) */ - dontShowAgainCookie: string; - dontShowAgainCookieDays: number; - /* Set how much padding to be used around helper element */ - helperElementPadding: number; - /* Default hint position */ - hintPosition: HintPosition; - /* Hint button label */ - hintButtonLabel: string; - /* Display the "Got it" button? */ - hintShowButton: boolean; - /* Hints auto-refresh interval in ms (set to -1 to disable) */ - hintAutoRefreshInterval: number; - /* Adding animation to hints? */ - hintAnimation: boolean; - /* additional classes to put on the buttons */ - buttonClass: string; - /* additional classes to put on progress bar */ - progressBarAdditionalClass: boolean; -} - -export function getDefaultOptions(): Options { - return { - steps: [], - hints: [], - isActive: true, - nextLabel: "Next", - prevLabel: "Back", - skipLabel: "×", - doneLabel: "Done", - hidePrev: false, - hideNext: false, - nextToDone: true, - tooltipPosition: "bottom", - tooltipClass: "", - group: "", - highlightClass: "", - exitOnEsc: true, - exitOnOverlayClick: true, - showStepNumbers: false, - stepNumbersOfLabel: "of", - keyboardNavigation: true, - showButtons: true, - showBullets: true, - showProgress: false, - scrollToElement: true, - scrollTo: "element", - scrollPadding: 30, - overlayOpacity: 0.5, - autoPosition: true, - positionPrecedence: ["bottom", "top", "right", "left"], - disableInteraction: false, - - dontShowAgain: false, - dontShowAgainLabel: "Don't show this again", - dontShowAgainCookie: "introjs-dontShowAgain", - dontShowAgainCookieDays: 365, - helperElementPadding: 10, - - hintPosition: "top-middle", - hintButtonLabel: "Got it", - hintShowButton: true, - hintAutoRefreshInterval: 10, - hintAnimation: true, - buttonClass: "introjs-button", - progressBarAdditionalClass: false, - }; -} - -export function setOption( - options: Options, +export function setOption( + options: T, key: K, - value: Options[K] -): Options { + value: T[K] +): T { options[key] = value; return options; } -export function setOptions( - options: Options, - partialOptions: Partial -): Options { +export function setOptions(options: T, partialOptions: Partial): T { for (const [key, value] of Object.entries(partialOptions)) { - options = setOption(options, key as keyof Options, value); + options = setOption(options, key as keyof T, value as T[keyof T]); } return options; } diff --git a/src/packages/hint/className.ts b/src/packages/hint/className.ts new file mode 100644 index 000000000..c550ac5cd --- /dev/null +++ b/src/packages/hint/className.ts @@ -0,0 +1,12 @@ +export const hintsClassName = "introjs-hints"; +export const hintClassName = "introjs-hint"; +export const arrowClassName = "introjs-arrow"; +export const tooltipClassName = "introjs-tooltip"; +export const hideHintClassName = "introjs-hidehint"; +export const tooltipReferenceLayerClassName = "introjs-tooltipReferenceLayer"; +export const tooltipTextClassName = "introjs-tooltiptext"; +export const hintReferenceClassName = "introjs-hintReference"; +export const hintNoAnimationClassName = "introjs-hint-no-anim"; +export const fixedHintClassName = "introjs-fixedhint"; +export const hintDotClassName = "introjs-hint-dot"; +export const hintPulseClassName = "introjs-hint-pulse"; diff --git a/src/packages/hint/dataAttributes.ts b/src/packages/hint/dataAttributes.ts new file mode 100644 index 000000000..c4abeb1c6 --- /dev/null +++ b/src/packages/hint/dataAttributes.ts @@ -0,0 +1,4 @@ +export const dataHintAttribute = "data-hint"; +export const dataStepAttribute = "data-step"; +export const dataHintPositionAttribute = "data-hint-position"; +export const dataTooltipClassAttribute = "data-tooltip-class"; diff --git a/src/packages/hint/hide.ts b/src/packages/hint/hide.ts new file mode 100644 index 000000000..029007339 --- /dev/null +++ b/src/packages/hint/hide.ts @@ -0,0 +1,41 @@ +import { Hint } from "./hint"; +import { hideHintClassName } from "./className"; +import { addClass } from "../../util/className"; +import { removeHintTooltip } from "./tooltip"; +import { dataStepAttribute } from "./dataAttributes"; +import { hintElement, hintElements } from "./selector"; + +/** + * Hide a hint + * + * @api private + */ +export async function hideHint(hint: Hint, stepId: number) { + const element = hintElement(stepId); + + removeHintTooltip(); + + if (element) { + addClass(element, hideHintClassName); + } + + // call the callback function (if any) + hint.callback("hintClose")?.call(hint, stepId); +} + +/** + * Hide all hints + * + * @api private + */ +export async function hideHints(hint: Hint) { + const elements = hintElements(); + + for (const hintElement of Array.from(elements)) { + const step = hintElement.getAttribute(dataStepAttribute); + + if (!step) continue; + + await hideHint(hint, parseInt(step, 10)); + } +} diff --git a/tests/cypress/e2e/hint/hideHints.cy.js b/src/packages/hint/hideHints.cy.ts similarity index 91% rename from tests/cypress/e2e/hint/hideHints.cy.js rename to src/packages/hint/hideHints.cy.ts index e3056df32..4969a3d30 100644 --- a/tests/cypress/e2e/hint/hideHints.cy.js +++ b/src/packages/hint/hideHints.cy.ts @@ -1,7 +1,7 @@ context("HideHints", () => { it("should hide all hints after calling hideHints()", () => { cy.visit("./cypress/setup/index.html").then(async (window) => { - const instance = window.introJs(); + const instance = window.introJs.hint(); instance.showHints(); diff --git a/src/packages/hint/hint.ts b/src/packages/hint/hint.ts new file mode 100644 index 000000000..29819ff21 --- /dev/null +++ b/src/packages/hint/hint.ts @@ -0,0 +1,331 @@ +import { Package } from "../package"; +import { getDefaultHintOptions, HintOptions } from "./option"; +import { fetchHintItems, HintItem } from "./hintItem"; +import { setOption, setOptions } from "../../option"; +import isFunction from "../../util/isFunction"; +import debounce from "../../util/debounce"; +import { reAlignHints } from "./position"; +import DOMEvent from "../../util/DOMEvent"; +import { getContainerElement } from "../../util/containerElement"; +import { renderHints } from "./render"; +import { hideHint, hideHints } from "./hide"; +import { showHint, showHints } from "./show"; +import { removeHint, removeHints } from "./remove"; +import { showHintDialog } from "./tooltip"; + +type hintsAddedCallback = (this: Hint) => void | Promise; +type hintClickCallback = ( + this: Hint, + hintElement: HTMLElement, + item: HintItem, + stepId: number +) => void | Promise; +type hintCloseCallback = (this: Hint, stepId: number) => void | Promise; + +export class Hint implements Package { + private _hints: HintItem[] = []; + private readonly _targetElement: HTMLElement; + private _options: HintOptions; + + private readonly callbacks: { + hintsAdded?: hintsAddedCallback; + hintClick?: hintClickCallback; + hintClose?: hintCloseCallback; + } = {}; + + // Event handlers + private _hintsAutoRefreshFunction?: () => void; + + /** + * Create a new Hint instance + * @param elementOrSelector Optional target element or CSS query to start the Hint on + * @param options Optional Hint options + */ + public constructor( + elementOrSelector?: string | HTMLElement, + options?: Partial + ) { + this._targetElement = getContainerElement(elementOrSelector); + this._options = options + ? setOptions(this._options, options) + : getDefaultHintOptions(); + } + + /** + * Get the callback function for the provided callback name + * @param callbackName The name of the callback + */ + callback( + callbackName: K + ): (typeof this.callbacks)[K] | undefined { + const callback = this.callbacks[callbackName]; + if (isFunction(callback)) { + return callback; + } + return undefined; + } + + /** + * Get the target element for the Hint + */ + getTargetElement(): HTMLElement { + return this._targetElement; + } + + /** + * Get the Hint items + */ + getHints(): HintItem[] { + return this._hints; + } + + /** + * Get the Hint item for the provided step ID + * @param stepId The step ID + */ + getHint(stepId: number): HintItem | undefined { + return this._hints[stepId]; + } + + /** + * Set the Hint items + * @param hints The Hint items + */ + setHints(hints: HintItem[]): this { + this._hints = hints; + return this; + } + + /** + * Add a Hint item + * @param hint The Hint item + */ + addHint(hint: HintItem): this { + this._hints.push(hint); + return this; + } + + /** + * Render hints on the page + */ + async render(): Promise { + if (!this.isActive()) { + return this; + } + + fetchHintItems(this); + await renderHints(this); + return this; + } + + /** + * @deprecated renderHints() is deprecated, please use render() instead + */ + async addHints() { + return this.render(); + } + + /** + * Hide a specific hint on the page + * @param stepId The hint step ID + */ + async hideHint(stepId: number) { + await hideHint(this, stepId); + return this; + } + + /** + * Hide all hints on the page + */ + async hideHints() { + await hideHints(this); + return this; + } + + /** + * Show a specific hint on the page + * @param stepId The hint step ID + */ + showHint(stepId: number) { + showHint(stepId); + return this; + } + + /** + * Show all hints on the page + */ + async showHints() { + await showHints(this); + return this; + } + + /** + * Destroys and removes all hint elements on the page + * Useful when you want to destroy the elements and add them again (e.g. a modal or popup) + */ + destroy() { + removeHints(this); + return this; + } + + /** + * @deprecated removeHints() is deprecated, please use destroy() instead + */ + removeHints() { + this.destroy(); + return this; + } + + /** + * Remove one single hint element from the page + * Useful when you want to destroy the element and add them again (e.g. a modal or popup) + * Use removeHints if you want to remove all elements. + * + * @param stepId The hint step ID + */ + removeHint(stepId: number) { + removeHint(stepId); + return this; + } + + /** + * Show hint dialog for a specific hint + * @param stepId The hint step ID + */ + async showHintDialog(stepId: number) { + await showHintDialog(this, stepId); + return this; + } + + /** + * Enable hint auto refresh on page scroll and resize for hints + */ + enableHintAutoRefresh(): this { + const hintAutoRefreshInterval = this.getOption("hintAutoRefreshInterval"); + if (hintAutoRefreshInterval >= 0) { + this._hintsAutoRefreshFunction = debounce( + () => reAlignHints(this), + hintAutoRefreshInterval + ); + + DOMEvent.on(window, "scroll", this._hintsAutoRefreshFunction, true); + DOMEvent.on(window, "resize", this._hintsAutoRefreshFunction, true); + } + + return this; + } + + /** + * Disable hint auto refresh on page scroll and resize for hints + */ + disableHintAutoRefresh(): this { + if (this._hintsAutoRefreshFunction) { + DOMEvent.off(window, "scroll", this._hintsAutoRefreshFunction, true); + DOMEvent.on(window, "resize", this._hintsAutoRefreshFunction, true); + + this._hintsAutoRefreshFunction = undefined; + } + + return this; + } + + /** + * Get specific Hint option + * @param key The option key + */ + getOption(key: K): HintOptions[K] { + return this._options[key]; + } + + /** + * Set Hint options + * @param partialOptions Hint options + */ + setOptions(partialOptions: Partial): this { + this._options = setOptions(this._options, partialOptions); + return this; + } + + /** + * Set specific Hint option + * @param key Option key + * @param value Option value + */ + setOption(key: K, value: HintOptions[K]): this { + this._options = setOption(this._options, key, value); + return this; + } + + /** + * Clone the Hint instance + */ + clone(): ThisType { + return new Hint(this._targetElement, this._options); + } + + /** + * Returns true if the Hint is active + */ + isActive(): boolean { + return this.getOption("isActive"); + } + + onHintsAdded(providedCallback: hintsAddedCallback) { + if (!isFunction(providedCallback)) { + throw new Error("Provided callback for onhintsadded was not a function."); + } + + this.callbacks.hintsAdded = providedCallback; + return this; + } + + /** + * @deprecated onhintsadded is deprecated, please use onHintsAdded instead + * @param providedCallback callback function + */ + onhintsadded(providedCallback: hintsAddedCallback) { + this.onHintsAdded(providedCallback); + } + + /** + * Callback for when hint items are clicked + * @param providedCallback callback function + */ + onHintClick(providedCallback: hintClickCallback) { + if (!isFunction(providedCallback)) { + throw new Error("Provided callback for onhintclick was not a function."); + } + + this.callbacks.hintClick = providedCallback; + return this; + } + + /** + * @deprecated onhintclick is deprecated, please use onHintClick instead + * @param providedCallback + */ + onhintclick(providedCallback: hintClickCallback) { + this.onHintClick(providedCallback); + } + + /** + * Callback for when hint items are closed + * @param providedCallback callback function + */ + onHintClose(providedCallback: hintCloseCallback) { + if (isFunction(providedCallback)) { + this.callbacks.hintClose = providedCallback; + } else { + throw new Error("Provided callback for onhintclose was not a function."); + } + return this; + } + + /** + * @deprecated onhintclose is deprecated, please use onHintClose instead + * @param providedCallback + */ + onhintclose(providedCallback: hintCloseCallback) { + this.onHintClose(providedCallback); + } +} diff --git a/src/packages/hint/hintItem.ts b/src/packages/hint/hintItem.ts new file mode 100644 index 000000000..aa5802ed1 --- /dev/null +++ b/src/packages/hint/hintItem.ts @@ -0,0 +1,90 @@ +import { TooltipPosition } from "../../packages/tooltip"; +import { Hint } from "./hint"; +import cloneObject from "../../util/cloneObject"; +import { queryElement, queryElements } from "../../util/queryElement"; +import { + dataHintAttribute, + dataHintPositionAttribute, + dataTooltipClassAttribute, +} from "./dataAttributes"; + +export type HintPosition = + | "top-left" + | "top-right" + | "top-middle" + | "bottom-left" + | "bottom-right" + | "bottom-middle" + | "middle-left" + | "middle-right" + | "middle-middle"; + +export type HintItem = { + element?: HTMLElement | string | null; + tooltipClass?: string; + position: TooltipPosition; + hint?: string; + hintTargetElement?: HTMLElement; + hintAnimation?: boolean; + hintPosition: HintPosition; +}; + +export const fetchHintItems = (hint: Hint) => { + hint.setHints([]); + + const targetElement = hint.getTargetElement(); + const hints = hint.getOption("hints"); + + if (hints && hints.length > 0) { + for (const _hint of hints) { + const hintItem = cloneObject(_hint); + + if (typeof hintItem.element === "string") { + // grab the element with given selector from the page + hintItem.element = queryElement(hintItem.element); + } + + hintItem.hintPosition = + hintItem.hintPosition || hint.getOption("hintPosition"); + hintItem.hintAnimation = + hintItem.hintAnimation || hint.getOption("hintAnimation"); + + if (hintItem.element !== null) { + hint.addHint(hintItem as HintItem); + } + } + } else { + const elements = Array.from( + queryElements(`*[${dataHintAttribute}]`, targetElement) + ); + + if (!elements || !elements.length) { + return false; + } + + //first add intro items with data-step + for (const element of elements) { + // hint animation + let hintAnimationAttr = element.getAttribute(dataHintPositionAttribute); + + let hintAnimation: boolean = hint.getOption("hintAnimation"); + if (hintAnimationAttr) { + hintAnimation = hintAnimationAttr === "true"; + } + + hint.addHint({ + element: element, + hint: element.getAttribute(dataHintAttribute) || "", + hintPosition: (element.getAttribute(dataHintPositionAttribute) || + hint.getOption("hintPosition")) as HintPosition, + hintAnimation, + tooltipClass: + element.getAttribute(dataTooltipClassAttribute) || undefined, + position: (element.getAttribute("data-position") || + hint.getOption("tooltipPosition")) as TooltipPosition, + }); + } + } + + return true; +}; diff --git a/src/packages/hint/index.ts b/src/packages/hint/index.ts new file mode 100644 index 000000000..954891b2c --- /dev/null +++ b/src/packages/hint/index.ts @@ -0,0 +1 @@ +export { Hint } from "./hint"; diff --git a/tests/cypress/e2e/hint/modal.cy.js b/src/packages/hint/modal.cy.ts similarity index 91% rename from tests/cypress/e2e/hint/modal.cy.js rename to src/packages/hint/modal.cy.ts index 7fb9149a6..bae0f188e 100644 --- a/tests/cypress/e2e/hint/modal.cy.js +++ b/src/packages/hint/modal.cy.ts @@ -1,7 +1,7 @@ context("Hints Modal", () => { it("should be able to open and close hint modals", () => { cy.visit("./cypress/setup/index.html").then((window) => { - const instance = window.introJs(); + const instance = window.introJs.hint(); instance.showHints(); @@ -18,7 +18,7 @@ context("Hints Modal", () => { it("should display the correct modal content", () => { cy.visit("./cypress/setup/index.html").then((window) => { - const instance = window.introJs(); + const instance = window.introJs.hint(); instance.showHints(); @@ -42,7 +42,7 @@ context("Hints Modal", () => { it("clicking on the same hint should close the modal", () => { cy.visit("./cypress/setup/index.html").then((window) => { - const instance = window.introJs(); + const instance = window.introJs.hint(); instance.showHints(); diff --git a/src/packages/hint/option.ts b/src/packages/hint/option.ts new file mode 100644 index 000000000..d64c5eb67 --- /dev/null +++ b/src/packages/hint/option.ts @@ -0,0 +1,49 @@ +import { TooltipPosition } from "../../packages/tooltip"; +import { HintItem, HintPosition } from "./hintItem"; + +export interface HintOptions { + /* List of all HintItems */ + hints: Partial[]; + /* True if the Hint instance is set to active */ + isActive: boolean; + /* Default tooltip box position */ + tooltipPosition: string; + /* Next CSS class for tooltip boxes */ + tooltipClass: string; + /* Default hint position */ + hintPosition: HintPosition; + /* Hint button label */ + hintButtonLabel: string; + /* Display the "Got it" button? */ + hintShowButton: boolean; + /* Hints auto-refresh interval in ms (set to -1 to disable) */ + hintAutoRefreshInterval: number; + /* Adding animation to hints? */ + hintAnimation: boolean; + /* additional classes to put on the buttons */ + buttonClass: string; + /* Set how much padding to be used around helper element */ + helperElementPadding: number; + /* To determine the tooltip position automatically based on the window.width/height */ + autoPosition: boolean; + /* Precedence of positions, when auto is enabled */ + positionPrecedence: TooltipPosition[]; +} + +export function getDefaultHintOptions(): HintOptions { + return { + hints: [], + isActive: true, + tooltipPosition: "bottom", + tooltipClass: "", + hintPosition: "top-middle", + hintButtonLabel: "Got it", + hintShowButton: true, + hintAutoRefreshInterval: 10, + hintAnimation: true, + buttonClass: "introjs-button", + helperElementPadding: 10, + autoPosition: true, + positionPrecedence: ["bottom", "top", "right", "left"], + }; +} diff --git a/src/packages/hint/position.ts b/src/packages/hint/position.ts new file mode 100644 index 000000000..6ef6f471a --- /dev/null +++ b/src/packages/hint/position.ts @@ -0,0 +1,87 @@ +import getOffset from "../../util/getOffset"; +import { HintPosition } from "./hintItem"; +import { Hint } from "./hint"; + +/** + * Aligns hint position + * + * @api private + */ +export const alignHintPosition = ( + position: HintPosition, + hintElement: HTMLElement, + targetElement?: HTMLElement +) => { + if (typeof targetElement === "undefined") { + return; + } + + // get/calculate offset of target element + const offset = getOffset(targetElement); + const iconWidth = 20; + const iconHeight = 20; + + // align the hint element + switch (position) { + default: + case "top-left": + hintElement.style.left = `${offset.left}px`; + hintElement.style.top = `${offset.top}px`; + break; + case "top-right": + hintElement.style.left = `${offset.left + offset.width - iconWidth}px`; + hintElement.style.top = `${offset.top}px`; + break; + case "bottom-left": + hintElement.style.left = `${offset.left}px`; + hintElement.style.top = `${offset.top + offset.height - iconHeight}px`; + break; + case "bottom-right": + hintElement.style.left = `${offset.left + offset.width - iconWidth}px`; + hintElement.style.top = `${offset.top + offset.height - iconHeight}px`; + break; + case "middle-left": + hintElement.style.left = `${offset.left}px`; + hintElement.style.top = `${ + offset.top + (offset.height - iconHeight) / 2 + }px`; + break; + case "middle-right": + hintElement.style.left = `${offset.left + offset.width - iconWidth}px`; + hintElement.style.top = `${ + offset.top + (offset.height - iconHeight) / 2 + }px`; + break; + case "middle-middle": + hintElement.style.left = `${ + offset.left + (offset.width - iconWidth) / 2 + }px`; + hintElement.style.top = `${ + offset.top + (offset.height - iconHeight) / 2 + }px`; + break; + case "bottom-middle": + hintElement.style.left = `${ + offset.left + (offset.width - iconWidth) / 2 + }px`; + hintElement.style.top = `${offset.top + offset.height - iconHeight}px`; + break; + case "top-middle": + hintElement.style.left = `${ + offset.left + (offset.width - iconWidth) / 2 + }px`; + hintElement.style.top = `${offset.top}px`; + break; + } +}; + +/** + * Re-aligns all hint elements + * + * @api private + */ +export function reAlignHints(hint: Hint) { + for (const { hintTargetElement, hintPosition, element } of hint.getHints()) { + alignHintPosition(hintPosition, element as HTMLElement, hintTargetElement); + } +} diff --git a/src/packages/hint/remove.ts b/src/packages/hint/remove.ts new file mode 100644 index 000000000..7b7d34dac --- /dev/null +++ b/src/packages/hint/remove.ts @@ -0,0 +1,38 @@ +import { Hint } from "./hint"; +import { dataStepAttribute } from "./dataAttributes"; +import { hintElement, hintElements } from "./selector"; + +/** + * Removes all hint elements on the page + * Useful when you want to destroy the elements and add them again (e.g. a modal or popup) + * + * @api private + */ +export function removeHints(hint: Hint) { + const elements = hintElements(); + + for (const hintElement of Array.from(elements)) { + const step = hintElement.getAttribute(dataStepAttribute); + + if (step === null) continue; + + removeHint(parseInt(step, 10)); + } + + hint.disableHintAutoRefresh(); +} + +/** + * Remove one single hint element from the page + * Useful when you want to destroy the element and add them again (e.g. a modal or popup) + * Use removeHints if you want to remove all elements. + * + * @api private + */ +export function removeHint(stepId: number) { + const element = hintElement(stepId); + + if (element && element.parentNode) { + element.parentNode.removeChild(element); + } +} diff --git a/tests/cypress/e2e/hint/removeHints.cy.js b/src/packages/hint/removeHints.cy.ts similarity index 92% rename from tests/cypress/e2e/hint/removeHints.cy.js rename to src/packages/hint/removeHints.cy.ts index cf8607fd2..3eacff38a 100644 --- a/tests/cypress/e2e/hint/removeHints.cy.js +++ b/src/packages/hint/removeHints.cy.ts @@ -1,7 +1,7 @@ context("RemoveHints", () => { it("should remove all hints after calling removeHints()", () => { cy.visit("./cypress/setup/index.html").then((window) => { - const instance = window.introJs().setOptions({ + const instance = window.introJs.hint().setOptions({ hints: [ { element: "#fixed-parent", diff --git a/src/packages/hint/render.ts b/src/packages/hint/render.ts new file mode 100644 index 000000000..92e71a86b --- /dev/null +++ b/src/packages/hint/render.ts @@ -0,0 +1,110 @@ +import { queryElement, queryElementByClassName } from "../../util/queryElement"; +import { Hint } from "./hint"; +import { HintPosition } from "./hintItem"; +import { + fixedHintClassName, + hintClassName, + hintDotClassName, + hintNoAnimationClassName, + hintPulseClassName, + hintsClassName, +} from "./className"; +import createElement from "../../util/createElement"; +import { dataStepAttribute } from "./dataAttributes"; +import setAnchorAsButton from "../../util/setAnchorAsButton"; +import { addClass } from "../../util/className"; +import isFixed from "../../util/isFixed"; +import { alignHintPosition } from "./position"; +import { showHintDialog } from "./tooltip"; + +/** + * Returns an event handler unique to the hint iteration + */ +const getHintClick = (hint: Hint, i: number) => (e: Event) => { + const evt = e ? e : window.event; + + if (evt && evt.stopPropagation) { + evt.stopPropagation(); + } + + if (evt && evt.cancelBubble !== null) { + evt.cancelBubble = true; + } + + showHintDialog(hint, i); +}; + +/** + * Add all available hints to the page + * + * @api private + */ +export async function renderHints(hint: Hint) { + let hintsWrapper = queryElementByClassName(hintsClassName); + + if (hintsWrapper === null) { + hintsWrapper = createElement("div", { + className: hintsClassName, + }); + } + + const hints = hint.getHints(); + for (let i = 0; i < hints.length; i++) { + const hintItem = hints[i]; + + // avoid append a hint twice + if (queryElement(`.${hintClassName}[${dataStepAttribute}="${i}"]`)) { + return; + } + + const hintElement = createElement("a", { + className: hintClassName, + }); + setAnchorAsButton(hintElement); + + hintElement.onclick = getHintClick(hint, i); + + if (!hintItem.hintAnimation) { + addClass(hintElement, hintNoAnimationClassName); + } + + // hint's position should be fixed if the target element's position is fixed + if (isFixed(hintItem.element as HTMLElement)) { + addClass(hintElement, fixedHintClassName); + } + + const hintDot = createElement("div", { + className: hintDotClassName, + }); + + const hintPulse = createElement("div", { + className: hintPulseClassName, + }); + + hintElement.appendChild(hintDot); + hintElement.appendChild(hintPulse); + hintElement.setAttribute(dataStepAttribute, i.toString()); + + // we swap the hint element with target element + // because _setHelperLayerPosition uses `element` property + hintItem.hintTargetElement = hintItem.element as HTMLElement; + hintItem.element = hintElement; + + // align the hint position + alignHintPosition( + hintItem.hintPosition as HintPosition, + hintElement, + hintItem.hintTargetElement as HTMLElement + ); + + hintsWrapper.appendChild(hintElement); + } + + // adding the hints wrapper + document.body.appendChild(hintsWrapper); + + // call the callback function (if any) + hint.callback("hintsAdded")?.call(hint); + + hint.enableHintAutoRefresh(); +} diff --git a/src/packages/hint/selector.ts b/src/packages/hint/selector.ts new file mode 100644 index 000000000..95a52b277 --- /dev/null +++ b/src/packages/hint/selector.ts @@ -0,0 +1,17 @@ +import { + queryElementByClassName, + queryElementsByClassName, +} from "../../util/queryElement"; +import { hintClassName, hintsClassName } from "./className"; +import { dataStepAttribute } from "./dataAttributes"; + +const hintsContainer = () => queryElementByClassName(hintsClassName); + +export const hintElements = () => + queryElementsByClassName(hintClassName, hintsContainer()); + +export const hintElement = (stepId: number) => + queryElementsByClassName( + `${hintClassName}[${dataStepAttribute}="${stepId}"]`, + hintsContainer() + )[0]; diff --git a/src/packages/hint/show.ts b/src/packages/hint/show.ts new file mode 100644 index 000000000..80db616b3 --- /dev/null +++ b/src/packages/hint/show.ts @@ -0,0 +1,40 @@ +import { Hint } from "./hint"; +import { hideHintClassName } from "./className"; +import { dataStepAttribute } from "./dataAttributes"; +import { removeClass } from "../../util/className"; +import { hintElement, hintElements } from "./selector"; + +/** + * Show all hints + * + * @api private + */ +export async function showHints(hint: Hint) { + const elements = hintElements(); + + if (elements?.length) { + for (const hintElement of Array.from(elements)) { + const step = hintElement.getAttribute(dataStepAttribute); + + if (!step) continue; + + showHint(parseInt(step, 10)); + } + } else { + // or render hints if there are none + await hint.render(); + } +} + +/** + * Show a hint + * + * @api private + */ +export function showHint(stepId: number) { + const element = hintElement(stepId); + + if (element) { + removeClass(element, new RegExp(hideHintClassName, "g")); + } +} diff --git a/tests/cypress/e2e/hint/showHints.cy.js b/src/packages/hint/showHints.cy.ts similarity index 81% rename from tests/cypress/e2e/hint/showHints.cy.js rename to src/packages/hint/showHints.cy.ts index 367398027..44d2056b4 100644 --- a/tests/cypress/e2e/hint/showHints.cy.js +++ b/src/packages/hint/showHints.cy.ts @@ -1,9 +1,9 @@ context("ShowHints", () => { it("should render all hints using the data-hint attributes", () => { - cy.visit("./cypress/setup/index.html").then((window) => { - const instance = window.introJs(); + cy.visit("./cypress/setup/index.html").then(async (window) => { + const instance = window.introJs.hint(); - instance.showHints(); + await instance.showHints(); cy.get(".introjs-hint").should("have.length", 4); }); @@ -11,7 +11,7 @@ context("ShowHints", () => { it("should render all hints on the page with the given JSON options", () => { cy.visit("./cypress/setup/index.html").then((window) => { - const instance = window.introJs().setOptions({ + const instance = window.introJs.hint().setOptions({ hints: [ { element: "#fixed-parent", @@ -36,7 +36,7 @@ context("ShowHints", () => { it("should prefer JSON options over data-hint attributes", () => { cy.visit("./cypress/setup/index.html").then((window) => { - const instance = window.introJs().setOptions({ + const instance = window.introJs.hint().setOptions({ hints: [ { element: "#fixed-parent", diff --git a/src/packages/hint/tooltip.ts b/src/packages/hint/tooltip.ts new file mode 100644 index 000000000..d86d5b0e7 --- /dev/null +++ b/src/packages/hint/tooltip.ts @@ -0,0 +1,150 @@ +import { queryElement, queryElementByClassName } from "../../util/queryElement"; +import { + arrowClassName, + hintClassName, + hintReferenceClassName, + tooltipClassName, + tooltipReferenceLayerClassName, + tooltipTextClassName, +} from "./className"; +import { dataStepAttribute } from "./dataAttributes"; +import { Hint } from "./hint"; +import createElement from "../../util/createElement"; +import { setClass } from "../../util/className"; +import { hideHint } from "./hide"; +import { setPositionRelativeTo } from "../../util/setPositionRelativeTo"; +import { placeTooltip } from "../../packages/tooltip"; +import DOMEvent from "../../util/DOMEvent"; + +// The hint close function used when the user clicks outside the hint +let _hintCloseFunction: () => void | undefined; + +/** + * Removes open hint (tooltip hint) + * + * @api private + */ +export function removeHintTooltip(): string | undefined { + const tooltip = queryElementByClassName(hintReferenceClassName); + + if (tooltip && tooltip.parentNode) { + const step = tooltip.getAttribute(dataStepAttribute); + if (!step) return undefined; + + tooltip.parentNode.removeChild(tooltip); + + return step; + } + + return undefined; +} + +/** + * Triggers when user clicks on the hint element + * + * @api private + */ +export async function showHintDialog(hint: Hint, stepId: number) { + const hintElement = queryElement( + `.${hintClassName}[${dataStepAttribute}="${stepId}"]` + ); + + const item = hint.getHint(stepId); + + if (!hintElement || !item) return; + + // call the callback function (if any) + await hint.callback("hintClick")?.call(hint, hintElement, item, stepId); + + // remove all open tooltips + const removedStep = removeHintTooltip(); + + // to toggle the tooltip + if (removedStep !== undefined && parseInt(removedStep, 10) === stepId) { + return; + } + + const tooltipLayer = createElement("div", { + className: tooltipClassName, + }); + const tooltipTextLayer = createElement("div"); + const arrowLayer = createElement("div"); + const referenceLayer = createElement("div"); + + tooltipLayer.onclick = (e: Event) => { + //IE9 & Other Browsers + if (e.stopPropagation) { + e.stopPropagation(); + } + //IE8 and Lower + else { + e.cancelBubble = true; + } + }; + + setClass(tooltipTextLayer, tooltipTextClassName); + + const tooltipWrapper = createElement("p"); + tooltipWrapper.innerHTML = item.hint || ""; + tooltipTextLayer.appendChild(tooltipWrapper); + + if (hint.getOption("hintShowButton")) { + const closeButton = createElement("a"); + closeButton.className = hint.getOption("buttonClass"); + closeButton.setAttribute("role", "button"); + closeButton.innerHTML = hint.getOption("hintButtonLabel"); + closeButton.onclick = () => hideHint(hint, stepId); + tooltipTextLayer.appendChild(closeButton); + } + + setClass(arrowLayer, arrowClassName); + tooltipLayer.appendChild(arrowLayer); + + tooltipLayer.appendChild(tooltipTextLayer); + + const step = hintElement.getAttribute(dataStepAttribute) || ""; + + // set current step for _placeTooltip function + const hintItem = hint.getHint(parseInt(step, 10)); + + if (!hintItem) return; + + // align reference layer position + setClass( + referenceLayer, + tooltipReferenceLayerClassName, + hintReferenceClassName + ); + referenceLayer.setAttribute(dataStepAttribute, step); + + const helperLayerPadding = hint.getOption("helperElementPadding"); + setPositionRelativeTo( + hint.getTargetElement(), + referenceLayer, + hintItem.element as HTMLElement, + helperLayerPadding + ); + + referenceLayer.appendChild(tooltipLayer); + document.body.appendChild(referenceLayer); + + // set proper position + placeTooltip( + tooltipLayer, + arrowLayer, + hintItem.element as HTMLElement, + hintItem.position, + hint.getOption("positionPrecedence"), + // hints don't have step numbers + false, + hint.getOption("autoPosition"), + hintItem.tooltipClass ?? hint.getOption("tooltipClass") + ); + + _hintCloseFunction = () => { + removeHintTooltip(); + DOMEvent.off(document, "click", _hintCloseFunction, false); + }; + + DOMEvent.on(document, "click", _hintCloseFunction, false); +} diff --git a/src/packages/package.ts b/src/packages/package.ts new file mode 100644 index 000000000..93511e33e --- /dev/null +++ b/src/packages/package.ts @@ -0,0 +1,10 @@ +/** + * Generic package interface. + */ +export interface Package { + getOption(key: K): TOption[K]; + setOptions(partialOptions: Partial): this; + setOption(key: K, value: TOption[K]): this; + clone(): ThisType; + isActive(): boolean; +} diff --git a/src/packages/tooltip/index.ts b/src/packages/tooltip/index.ts new file mode 100644 index 000000000..ca0d99c31 --- /dev/null +++ b/src/packages/tooltip/index.ts @@ -0,0 +1,2 @@ +export { TooltipPosition } from "./tooltipPosition"; +export { placeTooltip } from "./placeTooltip"; diff --git a/src/packages/tooltip/placeTooltip.test.ts b/src/packages/tooltip/placeTooltip.test.ts new file mode 100644 index 000000000..6f83a6f72 --- /dev/null +++ b/src/packages/tooltip/placeTooltip.test.ts @@ -0,0 +1,189 @@ +import * as getOffset from "../../util/getOffset"; +import * as getWindowSize from "../../util/getWindowSize"; +import { placeTooltip } from "./placeTooltip"; + +describe("placeTooltip", () => { + test("should automatically place the tooltip position when there is enough space", () => { + // Arrange + jest.spyOn(getOffset, "default").mockReturnValue({ + height: 100, + width: 100, + top: 0, + left: 0, + }); + + jest.spyOn(getWindowSize, "default").mockReturnValue({ + height: 1000, + width: 1000, + }); + + jest.spyOn(Element.prototype, "getBoundingClientRect").mockReturnValue({ + x: 0, + y: 0, + toJSON: jest.fn, + width: 100, + height: 100, + top: 200, + left: 200, + bottom: 300, + right: 300, + }); + + const stepElement = document.createElement("div"); + const tooltipLayer = document.createElement("div"); + const arrowLayer = document.createElement("div"); + + // Act + placeTooltip( + tooltipLayer, + arrowLayer, + stepElement, + "top", + ["top", "bottom", "left", "right"], + false, + true + ); + + // Assert + expect(tooltipLayer.className).toBe( + "introjs-tooltip introjs-top-right-aligned" + ); + }); + + test("should skip auto positioning when autoPosition is false", () => { + // Arrange + const stepElement = document.createElement("div"); + const tooltipLayer = document.createElement("div"); + const arrowLayer = document.createElement("div"); + + // Act + placeTooltip( + tooltipLayer, + arrowLayer, + stepElement, + "top", + ["top", "bottom"], + false, + false + ); + + // Assert + expect(tooltipLayer.className).toBe("introjs-tooltip introjs-top"); + }); + + test("should use floating tooltips when height/width is limited", () => { + // Arrange + jest.spyOn(getOffset, "default").mockReturnValue({ + height: 100, + width: 100, + top: 0, + left: 0, + }); + + jest.spyOn(getWindowSize, "default").mockReturnValue({ + height: 100, + width: 100, + }); + + jest.spyOn(Element.prototype, "getBoundingClientRect").mockReturnValue({ + x: 0, + y: 0, + toJSON: jest.fn, + width: 100, + height: 100, + top: 0, + left: 0, + bottom: 0, + right: 0, + }); + + const stepElement = document.createElement("div"); + const tooltipLayer = document.createElement("div"); + const arrowLayer = document.createElement("div"); + + // Act + placeTooltip( + tooltipLayer, + arrowLayer, + stepElement, + "left", + ["top", "bottom", "left", "right"], + false, + true + ); + + // Assert + expect(tooltipLayer.className).toBe("introjs-tooltip introjs-floating"); + }); + + test("should use bottom middle aligned when there is enough vertical space", () => { + // Arrange + jest.spyOn(getOffset, "default").mockReturnValue({ + height: 100, + width: 100, + top: 0, + left: 0, + }); + + jest.spyOn(getWindowSize, "default").mockReturnValue({ + height: 500, + width: 100, + }); + + jest.spyOn(Element.prototype, "getBoundingClientRect").mockReturnValue({ + x: 0, + y: 0, + toJSON: jest.fn, + width: 100, + height: 100, + top: 0, + left: 0, + bottom: 0, + right: 0, + }); + + const stepElement = document.createElement("div"); + const tooltipLayer = document.createElement("div"); + const arrowLayer = document.createElement("div"); + + // Act + placeTooltip( + tooltipLayer, + arrowLayer, + stepElement, + "left", + ["top", "bottom", "left", "right"], + false, + true + ); + + // Assert + expect(tooltipLayer.className).toBe( + "introjs-tooltip introjs-bottom-middle-aligned" + ); + }); + + test("should attach the global custom tooltip css class", () => { + // Arrange + const stepElement = document.createElement("div"); + const tooltipLayer = document.createElement("div"); + const arrowLayer = document.createElement("div"); + + // Act + placeTooltip( + tooltipLayer, + arrowLayer, + stepElement, + "left", + ["top", "bottom", "left", "right"], + false, + true, + "newClass" + ); + + // Assert + expect(tooltipLayer.className).toBe( + "introjs-tooltip newClass introjs-bottom-middle-aligned" + ); + }); +}); diff --git a/src/core/placeTooltip.ts b/src/packages/tooltip/placeTooltip.ts similarity index 82% rename from src/core/placeTooltip.ts rename to src/packages/tooltip/placeTooltip.ts index f7cc91580..a3b7a11bd 100644 --- a/src/core/placeTooltip.ts +++ b/src/packages/tooltip/placeTooltip.ts @@ -1,11 +1,10 @@ -import getOffset from "../util/getOffset"; -import getWindowSize from "../util/getWindowSize"; -import addClass from "../util/addClass"; -import checkRight from "../util/checkRight"; -import checkLeft from "../util/checkLeft"; -import removeEntry from "../util/removeEntry"; -import { HintStep, IntroStep, TooltipPosition } from "./steps"; -import { IntroJs } from "../intro"; +import getOffset from "../../util/getOffset"; +import getWindowSize from "../../util/getWindowSize"; +import { addClass, setClass } from "../../util/className"; +import checkRight from "../../util/checkRight"; +import checkLeft from "../../util/checkLeft"; +import removeEntry from "../../util/removeEntry"; +import { TooltipPosition } from "./tooltipPosition"; /** * auto-determine alignment @@ -158,16 +157,17 @@ function _determineAutoPosition( * * @api private */ -export default function placeTooltip( - intro: IntroJs, - currentStep: IntroStep | HintStep, +export const placeTooltip = ( tooltipLayer: HTMLElement, arrowLayer: HTMLElement, + targetElement: HTMLElement, + position: TooltipPosition, + positionPrecedence: TooltipPosition[], + showStepNumbers = false, + autoPosition = true, + tooltipClassName = "", hintMode: boolean = false -) { - if (!currentStep) return; - - let tooltipCssClass = ""; +) => { let tooltipOffset: { top: number; left: number; @@ -181,7 +181,6 @@ export default function placeTooltip( height: number; }; let windowSize: { width: number; height: number }; - let currentTooltipPosition: TooltipPosition; //reset the old style tooltipLayer.style.top = ""; @@ -193,44 +192,33 @@ export default function placeTooltip( arrowLayer.style.display = "inherit"; - //if we have a custom css class for each step - if (typeof currentStep.tooltipClass === "string") { - tooltipCssClass = currentStep.tooltipClass; - } else { - tooltipCssClass = intro._options.tooltipClass; - } - - tooltipLayer.className = ["introjs-tooltip", tooltipCssClass] - .filter(Boolean) - .join(" "); + setClass(tooltipLayer, "introjs-tooltip", tooltipClassName); tooltipLayer.setAttribute("role", "dialog"); - currentTooltipPosition = currentStep.position; - // Floating is always valid, no point in calculating - if (currentTooltipPosition !== "floating" && intro._options.autoPosition) { - currentTooltipPosition = _determineAutoPosition( - intro._options.positionPrecedence, - currentStep.element as HTMLElement, + if (position !== "floating" && autoPosition) { + position = _determineAutoPosition( + positionPrecedence, + targetElement, tooltipLayer, - currentTooltipPosition + position ); } let tooltipLayerStyleLeft: number; - targetOffset = getOffset(currentStep.element as HTMLElement); + targetOffset = getOffset(targetElement as HTMLElement); tooltipOffset = getOffset(tooltipLayer); windowSize = getWindowSize(); - addClass(tooltipLayer, `introjs-${currentTooltipPosition}`); + addClass(tooltipLayer, `introjs-${position}`); let tooltipLayerStyleLeftRight = targetOffset.width / 2 - tooltipOffset.width / 2; - switch (currentTooltipPosition) { + switch (position) { case "top-right-aligned": - arrowLayer.className = "introjs-arrow bottom-right"; + setClass(arrowLayer, "introjs-arrow bottom-right"); let tooltipLayerStyleRight = 0; checkLeft( @@ -243,7 +231,7 @@ export default function placeTooltip( break; case "top-middle-aligned": - arrowLayer.className = "introjs-arrow bottom-middle"; + setClass(arrowLayer, "introjs-arrow bottom-middle"); // a fix for middle aligned hints if (hintMode) { @@ -273,7 +261,7 @@ export default function placeTooltip( case "top-left-aligned": // top-left-aligned is the same as the default top case "top": - arrowLayer.className = "introjs-arrow bottom"; + setClass(arrowLayer, "introjs-arrow bottom"); tooltipLayerStyleLeft = hintMode ? 0 : 15; @@ -291,16 +279,16 @@ export default function placeTooltip( if (targetOffset.top + tooltipOffset.height > windowSize.height) { // In this case, right would have fallen below the bottom of the screen. // Modify so that the bottom of the tooltip connects with the target - arrowLayer.className = "introjs-arrow left-bottom"; + setClass(arrowLayer, "introjs-arrow left-bottom"); tooltipLayer.style.top = `-${ tooltipOffset.height - targetOffset.height - 20 }px`; } else { - arrowLayer.className = "introjs-arrow left"; + setClass(arrowLayer, "introjs-arrow left"); } break; case "left": - if (!hintMode && intro._options.showStepNumbers === true) { + if (!hintMode && showStepNumbers === true) { tooltipLayer.style.top = "15px"; } @@ -310,9 +298,9 @@ export default function placeTooltip( tooltipLayer.style.top = `-${ tooltipOffset.height - targetOffset.height - 20 }px`; - arrowLayer.className = "introjs-arrow right-bottom"; + setClass(arrowLayer, "introjs-arrow right-bottom"); } else { - arrowLayer.className = "introjs-arrow right"; + setClass(arrowLayer, "introjs-arrow right"); } tooltipLayer.style.right = `${targetOffset.width + 20}px`; @@ -328,7 +316,7 @@ export default function placeTooltip( break; case "bottom-right-aligned": - arrowLayer.className = "introjs-arrow top-right"; + setClass(arrowLayer, "introjs-arrow top-right"); tooltipLayerStyleRight = 0; checkLeft( @@ -341,7 +329,7 @@ export default function placeTooltip( break; case "bottom-middle-aligned": - arrowLayer.className = "introjs-arrow top-middle"; + setClass(arrowLayer, "introjs-arrow top-middle"); // a fix for middle aligned hints if (hintMode) { @@ -373,7 +361,7 @@ export default function placeTooltip( // case 'bottom': // Bottom going to follow the default behavior default: - arrowLayer.className = "introjs-arrow top"; + setClass(arrowLayer, "introjs-arrow top"); tooltipLayerStyleLeft = 0; checkRight( @@ -385,4 +373,4 @@ export default function placeTooltip( ); tooltipLayer.style.top = `${targetOffset.height + 20}px`; } -} +}; diff --git a/src/packages/tooltip/tooltipPosition.ts b/src/packages/tooltip/tooltipPosition.ts new file mode 100644 index 000000000..a42c6add7 --- /dev/null +++ b/src/packages/tooltip/tooltipPosition.ts @@ -0,0 +1,12 @@ +export type TooltipPosition = + | "floating" + | "top" + | "bottom" + | "left" + | "right" + | "top-right-aligned" + | "top-left-aligned" + | "top-middle-aligned" + | "bottom-right-aligned" + | "bottom-left-aligned" + | "bottom-middle-aligned"; diff --git a/src/packages/tour/addOverlayLayer.ts b/src/packages/tour/addOverlayLayer.ts new file mode 100644 index 000000000..bebac97a7 --- /dev/null +++ b/src/packages/tour/addOverlayLayer.ts @@ -0,0 +1,37 @@ +import createElement from "../../util/createElement"; +import setStyle from "../../util/setStyle"; +import { Tour } from "./tour"; +import { overlayClassName } from "./classNames"; + +/** + * Add overlay layer to the page + * + * @api private + */ +export default function addOverlayLayer(tour: Tour) { + const overlayLayer = createElement("div", { + className: overlayClassName, + }); + + setStyle(overlayLayer, { + top: 0, + bottom: 0, + left: 0, + right: 0, + position: "fixed", + }); + + tour.getTargetElement().appendChild(overlayLayer); + + if (tour.getOption("exitOnOverlayClick") === true) { + setStyle(overlayLayer, { + cursor: "pointer", + }); + + overlayLayer.onclick = async () => { + await tour.exit(); + }; + } + + return true; +} diff --git a/src/packages/tour/callback.ts b/src/packages/tour/callback.ts new file mode 100644 index 000000000..a6578ee27 --- /dev/null +++ b/src/packages/tour/callback.ts @@ -0,0 +1,43 @@ +import { Tour } from "./tour"; + +export type introBeforeChangeCallback = ( + this: Tour, + targetElement: HTMLElement, + currentStep: number, + direction: "backward" | "forward" +) => Promise | boolean; + +export type introChangeCallback = ( + this: Tour, + targetElement: HTMLElement +) => void | Promise; + +export type introAfterChangeCallback = ( + this: Tour, + targetElement: HTMLElement +) => void | Promise; + +export type introCompleteCallback = ( + this: Tour, + currentStep: number, + reason: "skip" | "end" | "done" +) => void | Promise; + +export type introStartCallback = ( + this: Tour, + targetElement: HTMLElement +) => void | Promise; + +export type introExitCallback = (this: Tour) => void | Promise; + +export type introSkipCallback = ( + this: Tour, + currentStep: number +) => void | Promise; + +export type introBeforeExitCallback = ( + this: Tour, + targetElement: HTMLElement +) => boolean | Promise; + +export type hintsAddedCallback = (this: Tour) => void | Promise; diff --git a/src/packages/tour/classNames.ts b/src/packages/tour/classNames.ts new file mode 100644 index 000000000..08515c1db --- /dev/null +++ b/src/packages/tour/classNames.ts @@ -0,0 +1,26 @@ +export const overlayClassName = "introjs-overlay"; +export const disableInteractionClassName = "introjs-disableInteraction"; +export const bulletsClassName = "introjs-bullets"; +export const progressClassName = "introjs-progress"; +export const progressBarClassName = "introjs-progressbar"; +export const helperLayerClassName = "introjs-helperLayer"; +export const tooltipReferenceLayerClassName = "introjs-tooltipReferenceLayer"; +export const helperNumberLayerClassName = "introjs-helperNumberLayer"; +export const tooltipClassName = "introjs-tooltip"; +export const tooltipHeaderClassName = "introjs-tooltip-header"; +export const tooltipTextClassName = "introjs-tooltiptext"; +export const tooltipTitleClassName = "introjs-tooltip-title"; +export const tooltipButtonsClassName = "introjs-tooltipbuttons"; +export const arrowClassName = "introjs-arrow"; +export const skipButtonClassName = "introjs-skipbutton"; +export const previousButtonClassName = "introjs-prevbutton"; +export const nextButtonClassName = "introjs-nextbutton"; +export const doneButtonClassName = "introjs-donebutton"; +export const dontShowAgainClassName = "introjs-dontShowAgain"; +export const hiddenButtonClassName = "introjs-hidden"; +export const disabledButtonClassName = "introjs-disabled"; +export const fullButtonClassName = "introjs-fullbutton"; +export const activeClassName = "active"; +export const fixedTooltipClassName = "introjs-fixedTooltip"; +export const floatingElementClassName = "introjsFloatingElement"; +export const showElementClassName = "introjs-showElement"; diff --git a/src/packages/tour/dataAttributes.ts b/src/packages/tour/dataAttributes.ts new file mode 100644 index 000000000..b8d49ef0f --- /dev/null +++ b/src/packages/tour/dataAttributes.ts @@ -0,0 +1,10 @@ +export const dataStepNumberAttribute = "data-step-number"; +export const dataIntroAttribute = "data-intro"; +export const dataStepAttribute = "data-step"; +export const dataIntroGroupAttribute = "data-intro-group"; +export const dataDisableInteraction = "data-disable-interaction"; +export const dataTitleAttribute = "data-title"; +export const dataTooltipClass = "data-tooltip-class"; +export const dataHighlightClass = "data-highlight-class"; +export const dataPosition = "data-position"; +export const dataScrollTo = "data-scroll-to"; diff --git a/tests/cypress/e2e/tour/dont-show-again.cy.js b/src/packages/tour/dont-show-again.cy.ts similarity index 94% rename from tests/cypress/e2e/tour/dont-show-again.cy.js rename to src/packages/tour/dont-show-again.cy.ts index c6516f418..f6677a33b 100644 --- a/tests/cypress/e2e/tour/dont-show-again.cy.js +++ b/src/packages/tour/dont-show-again.cy.ts @@ -6,8 +6,8 @@ context("Don't show again checkbox", () => { it("should render the 'Dont show Again' checkbox", () => { cy.window().then((window) => { - window - .introJs() + window.introJs + .tour() .setOptions({ dontShowAgain: true, steps: [ @@ -36,7 +36,7 @@ context("Don't show again checkbox", () => { it("should not display the tour if checkbox is clicked", () => { cy.window().then((window) => { - const instance = window.introJs().setOptions({ + const instance = window.introJs.tour().setOptions({ dontShowAgain: true, steps: [ { diff --git a/tests/jest/core/dontShowAgain.test.ts b/src/packages/tour/dontShowAgain.test.ts similarity index 50% rename from tests/jest/core/dontShowAgain.test.ts rename to src/packages/tour/dontShowAgain.test.ts index 08c4d6ed6..e8019e776 100644 --- a/tests/jest/core/dontShowAgain.test.ts +++ b/src/packages/tour/dontShowAgain.test.ts @@ -1,23 +1,11 @@ import * as cookie from "../../../src/util/cookie"; -import { - setDontShowAgain, - getDontShowAgain, -} from "../../../src/core/dontShowAgain"; -import { IntroJs } from "../../../src/intro"; +import { setDontShowAgain, getDontShowAgain } from "./dontShowAgain"; describe("dontShowAgain", () => { test("should call set cookie", () => { const setCookieMock = jest.spyOn(cookie, "setCookie"); - setDontShowAgain( - { - _options: { - dontShowAgainCookie: "cookie-name", - dontShowAgainCookieDays: 7, - }, - } as IntroJs, - true - ); + setDontShowAgain(true, "cookie-name", 7); expect(setCookieMock).toBeCalledTimes(1); expect(setCookieMock).toBeCalledWith("cookie-name", "true", 7); @@ -27,15 +15,7 @@ describe("dontShowAgain", () => { const setCookieMock = jest.spyOn(cookie, "setCookie"); const deleteCookieMock = jest.spyOn(cookie, "deleteCookie"); - setDontShowAgain( - { - _options: { - dontShowAgainCookie: "cookie-name", - dontShowAgainCookieDays: 7, - }, - } as IntroJs, - false - ); + setDontShowAgain(false, "cookie-name", 7); expect(setCookieMock).toBeCalledTimes(0); expect(deleteCookieMock).toBeCalledTimes(1); @@ -45,26 +25,12 @@ describe("dontShowAgain", () => { test("should return true when cookie is valid", () => { jest.spyOn(cookie, "getCookie").mockReturnValue("true"); - expect( - getDontShowAgain({ - _options: { - dontShowAgainCookie: "cookie-name", - dontShowAgainCookieDays: 7, - }, - } as IntroJs) - ).toBe(true); + expect(getDontShowAgain("cookie-name")).toBe(true); }); test("should return false when cookie is invalid", () => { jest.spyOn(cookie, "getCookie").mockReturnValue("invalid-state"); - expect( - getDontShowAgain({ - _options: { - dontShowAgainCookie: "cookie-name", - dontShowAgainCookieDays: 7, - }, - } as IntroJs) - ).toBe(false); + expect(getDontShowAgain("cookie-name")).toBe(false); }); }); diff --git a/src/packages/tour/dontShowAgain.ts b/src/packages/tour/dontShowAgain.ts new file mode 100644 index 000000000..1cedc7cb5 --- /dev/null +++ b/src/packages/tour/dontShowAgain.ts @@ -0,0 +1,30 @@ +import { deleteCookie, getCookie, setCookie } from "../../util/cookie"; + +const dontShowAgainCookieValue = "true"; + +/** + * Set the "Don't show again" state + * + * @api private + */ +export function setDontShowAgain( + dontShowAgain: boolean, + cookieName: string, + cookieDays: number +) { + if (dontShowAgain) { + setCookie(cookieName, dontShowAgainCookieValue, cookieDays); + } else { + deleteCookie(cookieName); + } +} + +/** + * Get the "Don't show again" state from cookies + * + * @api private + */ +export function getDontShowAgain(cookieName: string): boolean { + const dontShowCookie = getCookie(cookieName); + return dontShowCookie !== "" && dontShowCookie === dontShowAgainCookieValue; +} diff --git a/tests/cypress/e2e/tour/exit.cy.js b/src/packages/tour/exit.cy.ts similarity index 89% rename from tests/cypress/e2e/tour/exit.cy.js rename to src/packages/tour/exit.cy.ts index 4c06a230f..1a0cc9e9c 100644 --- a/tests/cypress/e2e/tour/exit.cy.js +++ b/src/packages/tour/exit.cy.ts @@ -5,7 +5,7 @@ context("Exit", () => { it("should remove leftovers from the DOM", () => { cy.window().then((window) => { - const instance = window.introJs().setOptions({ + const instance = window.introJs.tour().setOptions({ steps: [ { intro: "step one", @@ -32,7 +32,7 @@ context("Exit", () => { it("should exit the tour after clicking on the skip icon", () => { cy.window().then((window) => { - const instance = window.introJs().setOptions({ + const instance = window.introJs.tour().setOptions({ steps: [ { intro: "step one", @@ -57,7 +57,7 @@ context("Exit", () => { it("should exit the tour after clicking on the overlay layer", () => { cy.window().then((window) => { - const instance = window.introJs().setOptions({ + const instance = window.introJs.tour().setOptions({ steps: [ { intro: "step one", @@ -82,7 +82,7 @@ context("Exit", () => { it("should not exit the tour after clicking on the tooltip layer", () => { cy.window().then((window) => { - const instance = window.introJs().setOptions({ + const instance = window.introJs.tour().setOptions({ steps: [ { intro: "step one", diff --git a/src/packages/tour/exitIntro.test.ts b/src/packages/tour/exitIntro.test.ts new file mode 100644 index 000000000..ab88b62e5 --- /dev/null +++ b/src/packages/tour/exitIntro.test.ts @@ -0,0 +1,92 @@ +import { getMockTour } from "./mock"; + +describe("exitIntro", () => { + test("should reset the _currentStep", async () => { + const mockTour = getMockTour(); + mockTour.addStep({ element: document.querySelector("h1") }); + await mockTour.start(); + + await mockTour.exit(false); + + expect(mockTour.getCurrentStep()).toBe(-1); + }); + + test("should call the onexit and onbeforeexit callbacks", async () => { + const fnOnExit = jest.fn(); + const fnOnBeforeExit = jest.fn(); + fnOnBeforeExit.mockReturnValue(true); + + const mockTour = getMockTour(); + mockTour.addStep({ element: document.querySelector("h1") }); + mockTour.onExit(fnOnExit); + mockTour.onBeforeExit(fnOnBeforeExit); + + await mockTour.start(); + await mockTour.exit(false); + + expect(fnOnExit).toBeCalledTimes(1); + + expect(fnOnBeforeExit).toBeCalledTimes(1); + expect(fnOnBeforeExit).toHaveBeenCalledWith(document.body); + expect(fnOnBeforeExit).toHaveBeenCalledBefore(fnOnExit); + }); + + test("should not continue when onbeforeexit returns false", async () => { + const fnOnExit = jest.fn(); + const fnOnBeforeExit = jest.fn(); + fnOnBeforeExit.mockReturnValue(false); + + const mockTour = getMockTour(); + mockTour.addStep({ element: document.querySelector("h1") }); + mockTour.onExit(fnOnExit); + mockTour.onBeforeExit(fnOnBeforeExit); + + await mockTour.start(); + await mockTour.exit(false); + + expect(fnOnExit).toBeCalledTimes(0); + + expect(fnOnBeforeExit).toBeCalledTimes(1); + expect(fnOnBeforeExit).toHaveBeenCalledWith(document.body); + + // test cleanup + document.body.innerHTML = ""; + }); + + test("should not continue when exit force is true", async () => { + const fnOnExit = jest.fn(); + const fnOnBeforeExit = jest.fn(); + fnOnBeforeExit.mockReturnValue(false); + + const mockTour = getMockTour(); + mockTour.addStep({ element: document.querySelector("h1") }); + mockTour.onExit(fnOnExit); + mockTour.onBeforeExit(fnOnBeforeExit); + + await mockTour.start(); + await mockTour.exit(false); + + expect(fnOnExit).toBeCalledTimes(0); + expect(fnOnBeforeExit).toBeCalledTimes(1); + + // test cleanup + document.body.innerHTML = ""; + }); + + test("should continue when exit force is true and beforeExit callback returns false", async () => { + const fnOnExit = jest.fn(); + const fnOnBeforeExit = jest.fn(); + fnOnBeforeExit.mockReturnValue(false); + + const mockTour = getMockTour(); + mockTour.addStep({ element: document.querySelector("h1") }); + mockTour.onExit(fnOnExit); + mockTour.onBeforeExit(fnOnBeforeExit); + + await mockTour.start(); + await mockTour.exit(true); + + expect(fnOnExit).toBeCalledTimes(1); + expect(fnOnBeforeExit).toBeCalledTimes(1); + }); +}); diff --git a/src/packages/tour/exitIntro.ts b/src/packages/tour/exitIntro.ts new file mode 100644 index 000000000..e4a1aa070 --- /dev/null +++ b/src/packages/tour/exitIntro.ts @@ -0,0 +1,85 @@ +import removeShowElement from "./removeShowElement"; +import { removeChild, removeAnimatedChild } from "../../util/removeChild"; +import { Tour } from "./tour"; +import { + disableInteractionClassName, + floatingElementClassName, + helperLayerClassName, + overlayClassName, + tooltipReferenceLayerClassName, +} from "./classNames"; +import { + queryElementByClassName, + queryElementsByClassName, +} from "../../util/queryElement"; + +/** + * Exit from intro + * + * @api private + * @param {Boolean} force - Setting to `true` will skip the result of beforeExit callback + */ +export default async function exitIntro( + tour: Tour, + force: boolean = false +): Promise { + const targetElement = tour.getTargetElement(); + let continueExit: boolean | undefined = true; + + // calling onbeforeexit callback + // + // If this callback return `false`, it would halt the process + continueExit = await tour.callback("beforeExit")?.call(tour, targetElement); + + // skip this check if `force` parameter is `true` + // otherwise, if `onbeforeexit` returned `false`, don't exit the intro + if (!force && continueExit === false) return false; + + // remove overlay layers from the page + const overlayLayers = Array.from( + queryElementsByClassName(overlayClassName, targetElement) + ); + + if (overlayLayers && overlayLayers.length) { + for (const overlayLayer of overlayLayers) { + removeChild(overlayLayer); + } + } + + const referenceLayer = queryElementByClassName( + tooltipReferenceLayerClassName, + targetElement + ); + removeChild(referenceLayer); + + //remove disableInteractionLayer + const disableInteractionLayer = queryElementByClassName( + disableInteractionClassName, + targetElement + ); + removeChild(disableInteractionLayer); + + //remove intro floating element + const floatingElement = queryElementByClassName( + floatingElementClassName, + targetElement + ); + removeChild(floatingElement); + + removeShowElement(); + + //remove all helper layers + const helperLayer = queryElementByClassName( + helperLayerClassName, + targetElement + ); + await removeAnimatedChild(helperLayer); + + //check if any callback is defined + await tour.callback("exit")?.call(tour); + + // set the step to default + tour.setCurrentStep(-1); + + return true; +} diff --git a/tests/cypress/e2e/tour/highlight.cy.js b/src/packages/tour/highlight.cy.ts similarity index 94% rename from tests/cypress/e2e/tour/highlight.cy.js rename to src/packages/tour/highlight.cy.ts index 6fefff0d5..eae7d71d8 100644 --- a/tests/cypress/e2e/tour/highlight.cy.js +++ b/src/packages/tour/highlight.cy.ts @@ -5,8 +5,8 @@ context("Highlight", () => { it("should highlight the target element", () => { cy.window().then((window) => { - window - .introJs() + window.introJs + .tour() .setOptions({ steps: [ { @@ -34,8 +34,8 @@ context("Highlight", () => { it("should let user interact with the target element", () => { cy.window().then((window) => { - window - .introJs() + window.introJs + .tour() .setOptions({ steps: [ { @@ -65,8 +65,8 @@ context("Highlight", () => { it("interaction with the target element should be disabled when disabledInteraction is true", () => { cy.window().then((window) => { - window - .introJs() + window.introJs + .tour() .setOptions({ disableInteraction: true, steps: [ @@ -91,8 +91,8 @@ context("Highlight", () => { it("should interaction with the target element the parent element has positive: relative", () => { cy.window().then((window) => { - window - .introJs() + window.introJs + .tour() .setOptions({ steps: [ { @@ -123,8 +123,8 @@ context("Highlight", () => { "z-index: 1;position: relative;" ); - window - .introJs() + window.introJs + .tour() .setOptions({ steps: [ { @@ -148,8 +148,8 @@ context("Highlight", () => { it("should interaction with the target element the parent element has positive: absolute", () => { cy.window().then((window) => { - window - .introJs() + window.introJs + .tour() .setOptions({ steps: [ { @@ -180,8 +180,8 @@ context("Highlight", () => { "z-index: 1;position: absolute;" ); - window - .introJs() + window.introJs + .tour() .setOptions({ steps: [ { @@ -207,8 +207,8 @@ context("Highlight", () => { cy.viewport(550, 750); cy.window().then((window) => { - window - .introJs() + window.introJs + .tour() .setOptions({ steps: [ { @@ -236,8 +236,8 @@ context("Highlight", () => { top: 200, }); - window - .introJs() + window.introJs + .tour() .setOptions({ steps: [ { @@ -265,8 +265,8 @@ context("Highlight", () => { top: 200, }); - window - .introJs() + window.introJs + .tour() .setOptions({ steps: [ { diff --git a/src/packages/tour/index.ts b/src/packages/tour/index.ts new file mode 100644 index 000000000..38ecdf38c --- /dev/null +++ b/src/packages/tour/index.ts @@ -0,0 +1 @@ +export { Tour } from "./tour"; diff --git a/src/packages/tour/mock.ts b/src/packages/tour/mock.ts new file mode 100644 index 000000000..3590a63da --- /dev/null +++ b/src/packages/tour/mock.ts @@ -0,0 +1,111 @@ +import createElement from "../../util/createElement"; +import { TourStep } from "./steps"; +import { Tour } from "./tour"; +import { + dataIntroAttribute, + dataPosition, + dataStepAttribute, +} from "./dataAttributes"; + +export const appendMockSteps = (targetElement: HTMLElement = document.body) => { + const mockElementOne = createElement("div"); + mockElementOne.setAttribute(dataIntroAttribute, "Mock element"); + + const mockElementTwo = createElement("b"); + mockElementTwo.setAttribute(dataIntroAttribute, "Mock element left position"); + mockElementTwo.setAttribute(dataPosition, "left"); + + const mockElementThree = createElement("h1"); + mockElementThree.setAttribute( + dataIntroAttribute, + "Mock element second to last" + ); + mockElementThree.setAttribute(dataStepAttribute, "10"); + + const mockElementFour = createElement("a"); + mockElementFour.setAttribute(dataIntroAttribute, "Mock element last"); + mockElementFour.setAttribute(dataStepAttribute, "20"); + + targetElement.appendChild(mockElementOne); + targetElement.appendChild(mockElementTwo); + targetElement.appendChild(mockElementThree); + targetElement.appendChild(mockElementFour); + + return [mockElementOne, mockElementTwo, mockElementThree, mockElementFour]; +}; + +export const getMockPartialSteps = (): Partial[] => { + return [ + { + title: "Floating title 1", + intro: "Step One of the tour", + }, + { + title: "Floating title 2", + intro: "Step Two of the tour", + }, + { + title: "First title", + intro: "Step Three of the tour", + position: "top", + scrollTo: "tooltip", + element: "h1", + }, + { + intro: "Step Four of the tour", + position: "right", + scrollTo: "off", + element: document.createElement("div"), + }, + { + element: ".not-found", + intro: "Element not found", + }, + ]; +}; + +export const getMockSteps = (): TourStep[] => { + return [ + { + step: 1, + scrollTo: "tooltip", + position: "bottom", + title: "Floating title 1", + intro: "Step One of the tour", + }, + { + step: 2, + scrollTo: "tooltip", + position: "bottom", + title: "Floating title 2", + intro: "Step Two of the tour", + }, + { + step: 3, + position: "top", + scrollTo: "tooltip", + title: "First title", + intro: "Step Three of the tour", + }, + { + step: 4, + position: "right", + scrollTo: "off", + title: "", + intro: "Step Four of the tour", + element: document.createElement("div"), + }, + { + step: 5, + position: "right", + scrollTo: "off", + title: "", + intro: "Element not found", + element: ".not-found", + }, + ]; +}; + +export const getMockTour = (targetElement: HTMLElement = document.body) => { + return new Tour(targetElement); +}; diff --git a/tests/cypress/e2e/tour/modal.cy.js b/src/packages/tour/modal.cy.ts similarity index 95% rename from tests/cypress/e2e/tour/modal.cy.js rename to src/packages/tour/modal.cy.ts index 104bace1d..d57b024f1 100644 --- a/tests/cypress/e2e/tour/modal.cy.js +++ b/src/packages/tour/modal.cy.ts @@ -5,8 +5,8 @@ context("Modal", () => { it("should match the popup", () => { cy.window().then((win) => { - win - .introJs() + win.introJs + .tour() .setOptions({ steps: [ { @@ -39,8 +39,8 @@ context("Modal", () => { cy.window().then((win) => { cy.viewport("macbook-13"); - win - .introJs() + win.introJs + .tour() .setOptions({ steps: [ { @@ -77,7 +77,7 @@ context("Modal", () => { it("should update the modal after refresh(true)", () => { cy.window().then((win) => { - const instance = win.introJs().setOptions({ + const instance = win.introJs.tour().setOptions({ showProgress: true, showBullets: true, steps: [ diff --git a/tests/cypress/e2e/tour/navigation.cy.js b/src/packages/tour/navigation.cy.ts similarity index 98% rename from tests/cypress/e2e/tour/navigation.cy.js rename to src/packages/tour/navigation.cy.ts index cc146831a..6987160c2 100644 --- a/tests/cypress/e2e/tour/navigation.cy.js +++ b/src/packages/tour/navigation.cy.ts @@ -1,8 +1,8 @@ context("Navigation", () => { beforeEach(() => { cy.visit("./cypress/setup/index.html").then((window) => { - window - .introJs() + window.introJs + .tour() .setOptions({ steps: [ { diff --git a/src/core/onKeyDown.ts b/src/packages/tour/onKeyDown.ts similarity index 60% rename from src/core/onKeyDown.ts rename to src/packages/tour/onKeyDown.ts index fd5e187e7..f896da936 100644 --- a/src/core/onKeyDown.ts +++ b/src/packages/tour/onKeyDown.ts @@ -1,7 +1,7 @@ import { nextStep, previousStep } from "./steps"; -import exitIntro from "./exitIntro"; -import { IntroJs } from "../intro"; -import isFunction from "../util/isFunction"; +import { Tour } from "./tour"; +import { previousButtonClassName, skipButtonClassName } from "./classNames"; +import { dataStepNumberAttribute } from "./dataAttributes"; /** * on keyCode: @@ -18,7 +18,7 @@ import isFunction from "../util/isFunction"; * (3) e.keyCode * https://github.com/jquery/jquery/blob/a6b0705294d336ae2f63f7276de0da1195495363/src/event.js#L638 */ -export default async function onKeyDown(intro: IntroJs, e: KeyboardEvent) { +export default async function onKeyDown(tour: Tour, e: KeyboardEvent) { let code = e.code === undefined ? e.which : e.code; // if e.which is null @@ -26,42 +26,40 @@ export default async function onKeyDown(intro: IntroJs, e: KeyboardEvent) { code = e.charCode === null ? e.keyCode : e.charCode; } - if ((code === "Escape" || code === 27) && intro._options.exitOnEsc === true) { + if ( + (code === "Escape" || code === 27) && + tour.getOption("exitOnEsc") === true + ) { //escape key pressed, exit the intro //check if exit callback is defined - await exitIntro(intro, intro._targetElement); + await tour.exit(); } else if (code === "ArrowLeft" || code === 37) { //left arrow - await previousStep(intro); + await previousStep(tour); } else if (code === "ArrowRight" || code === 39) { //right arrow - await nextStep(intro); + await nextStep(tour); } else if (code === "Enter" || code === "NumpadEnter" || code === 13) { //srcElement === ie const target = (e.target || e.srcElement) as HTMLElement; - if (target && target.className.match("introjs-prevbutton")) { + if (target && target.className.match(previousButtonClassName)) { //user hit enter while focusing on previous button - await previousStep(intro); - } else if (target && target.className.match("introjs-skipbutton")) { - //user hit enter while focusing on skip button - if ( - intro._introItems.length - 1 === intro._currentStep && - isFunction(intro._introCompleteCallback) - ) { - await intro._introCompleteCallback.call( - intro, - intro._currentStep, - "skip" - ); + await previousStep(tour); + } else if (target && target.className.match(skipButtonClassName)) { + // user hit enter while focusing on skip button + if (tour.isEnd()) { + await tour + .callback("complete") + ?.call(tour, tour.getCurrentStep(), "skip"); } - await exitIntro(intro, intro._targetElement); - } else if (target && target.getAttribute("data-step-number")) { + await tour.exit(); + } else if (target && target.getAttribute(dataStepNumberAttribute)) { // user hit enter while focusing on step bullet target.click(); } else { //default behavior for responding to enter - await nextStep(intro); + await nextStep(tour); } //prevent default behaviour on hitting Enter, to prevent steps being skipped in some browsers diff --git a/src/packages/tour/onResize.ts b/src/packages/tour/onResize.ts new file mode 100644 index 000000000..31e011dcd --- /dev/null +++ b/src/packages/tour/onResize.ts @@ -0,0 +1,6 @@ +import refresh from "./refresh"; +import { Tour } from "./tour"; + +export default function onResize(tour: Tour) { + refresh(tour); +} diff --git a/src/packages/tour/option.ts b/src/packages/tour/option.ts new file mode 100644 index 000000000..913212511 --- /dev/null +++ b/src/packages/tour/option.ts @@ -0,0 +1,117 @@ +import { TooltipPosition } from "../../packages/tooltip"; +import { TourStep, ScrollTo } from "./steps"; + +export interface TourOptions { + steps: Partial[]; + /* Is this tour instance active? Don't show the tour again if this flag is set to false */ + isActive: boolean; + /* Next button label in tooltip box */ + nextLabel: string; + /* Previous button label in tooltip box */ + prevLabel: string; + /* Skip button label in tooltip box */ + skipLabel: string; + /* Done button label in tooltip box */ + doneLabel: string; + /* Hide previous button in the first step? Otherwise, it will be disabled button. */ + hidePrev: boolean; + /* Hide next button in the last step? Otherwise, it will be disabled button (note: this will also hide the "Done" button) */ + hideNext: boolean; + /* Change the Next button to Done in the last step of the intro? otherwise, it will render a disabled button */ + nextToDone: boolean; + /* Default tooltip box position */ + tooltipPosition: TooltipPosition; + /* Next CSS class for tooltip boxes */ + tooltipClass: string; + /* Start intro for a group of elements */ + group: string; + /* CSS class that is added to the helperLayer */ + highlightClass: string; + /* Close introduction when pressing Escape button? */ + exitOnEsc: boolean; + /* Close introduction when clicking on overlay layer? */ + exitOnOverlayClick: boolean; + /* Display the pagination detail */ + showStepNumbers: boolean; + /* Pagination "of" label */ + stepNumbersOfLabel: string; + /* Let user use keyboard to navigate the tour? */ + keyboardNavigation: boolean; + /* Show tour control buttons? */ + showButtons: boolean; + /* Show tour bullets? */ + showBullets: boolean; + /* Show tour progress? */ + showProgress: boolean; + /* Scroll to highlighted element? */ + scrollToElement: boolean; + /* + * Should we scroll the tooltip or target element? + * Options are: 'element', 'tooltip' or 'off' + */ + scrollTo: ScrollTo; + /* Padding to add after scrolling when element is not in the viewport (in pixels) */ + scrollPadding: number; + /* Set the overlay opacity */ + overlayOpacity: number; + /* To determine the tooltip position automatically based on the window.width/height */ + autoPosition: boolean; + /* Precedence of positions, when auto is enabled */ + positionPrecedence: TooltipPosition[]; + /* Disable an interaction with element? */ + disableInteraction: boolean; + /* To display the "Don't show again" checkbox in the tour */ + dontShowAgain: boolean; + dontShowAgainLabel: string; + /* "Don't show again" cookie name and expiry (in days) */ + dontShowAgainCookie: string; + dontShowAgainCookieDays: number; + /* Set how much padding to be used around helper element */ + helperElementPadding: number; + /* additional classes to put on the buttons */ + buttonClass: string; + /* additional classes to put on progress bar */ + progressBarAdditionalClass: string; +} + +export function getDefaultTourOptions(): TourOptions { + return { + steps: [], + isActive: true, + nextLabel: "Next", + prevLabel: "Back", + skipLabel: "×", + doneLabel: "Done", + hidePrev: false, + hideNext: false, + nextToDone: true, + tooltipPosition: "bottom", + tooltipClass: "", + group: "", + highlightClass: "", + exitOnEsc: true, + exitOnOverlayClick: true, + showStepNumbers: false, + stepNumbersOfLabel: "of", + keyboardNavigation: true, + showButtons: true, + showBullets: true, + showProgress: false, + scrollToElement: true, + scrollTo: "element", + scrollPadding: 30, + overlayOpacity: 0.5, + autoPosition: true, + positionPrecedence: ["bottom", "top", "right", "left"], + disableInteraction: false, + + dontShowAgain: false, + dontShowAgainLabel: "Don't show this again", + dontShowAgainCookie: "introjs-dontShowAgain", + dontShowAgainCookieDays: 365, + helperElementPadding: 10, + + buttonClass: "introjs-button", + progressBarAdditionalClass: "", + }; +} diff --git a/src/packages/tour/position.ts b/src/packages/tour/position.ts new file mode 100644 index 000000000..2c1aba139 --- /dev/null +++ b/src/packages/tour/position.ts @@ -0,0 +1,20 @@ +import { setPositionRelativeTo } from "../../util/setPositionRelativeTo"; +import { TourStep } from "./steps"; + +/** + * Sets the position of the element relative to the TourStep + * @api private + */ +export const setPositionRelativeToStep = ( + relativeElement: HTMLElement, + element: HTMLElement, + step: TourStep, + padding: number +) => { + setPositionRelativeTo( + relativeElement, + element, + step.element as HTMLElement, + step.position === "floating" ? 0 : padding + ); +}; diff --git a/tests/cypress/e2e/tour/progressbar.cy.js b/src/packages/tour/progressbar.cy.ts similarity index 95% rename from tests/cypress/e2e/tour/progressbar.cy.js rename to src/packages/tour/progressbar.cy.ts index 0b3025055..c4adb7a1a 100644 --- a/tests/cypress/e2e/tour/progressbar.cy.js +++ b/src/packages/tour/progressbar.cy.ts @@ -5,8 +5,8 @@ context("ProgressBar", () => { it("should match the popup", () => { cy.window().then((win) => { - win - .introJs() + win.introJs + .tour() .setOptions({ showProgress: true, steps: [ diff --git a/src/packages/tour/refresh.test.ts b/src/packages/tour/refresh.test.ts new file mode 100644 index 000000000..7170008cb --- /dev/null +++ b/src/packages/tour/refresh.test.ts @@ -0,0 +1,77 @@ +import * as tooltip from "../../packages/tooltip"; +import { getMockTour } from "./mock"; + +describe("refresh", () => { + test("should not refetch the steps when refreshStep is false", async () => { + // Arrange + jest.spyOn(tooltip, "placeTooltip"); + + const targetElement = document.createElement("div"); + document.body.appendChild(targetElement); + + const mockTour = getMockTour(); + + mockTour.addStep({ + intro: "first", + }); + + await mockTour.start(); + + // Act + mockTour.setOptions({ + steps: [ + { + intro: "first", + }, + { + intro: "second", + }, + ], + }); + + mockTour.refresh(); + + // Assert + expect(mockTour.getSteps()).toHaveLength(1); + expect(mockTour.getStep(0).intro).toBe("first"); + expect(document.querySelectorAll(".introjs-bullets ul li").length).toBe(1); + + // cleanup + await mockTour.exit(); + }); + + test("should fetch the steps when refreshStep is true", async () => { + // Arrange + jest.spyOn(tooltip, "placeTooltip"); + + const targetElement = document.createElement("div"); + document.body.appendChild(targetElement); + + const mockTour = getMockTour(); + + mockTour.addStep({ + intro: "first", + }); + + await mockTour.start(); + + // Act + mockTour.setOptions({ + steps: [ + { + intro: "first", + }, + { + intro: "second", + }, + ], + }); + + mockTour.refresh(true); + + // Assert + expect(mockTour.getSteps()).toHaveLength(2); + expect(mockTour.getStep(1).intro).toBe("second"); + expect(document.querySelectorAll(".introjs-bullets ul li").length).toBe(2); + }); +}); diff --git a/src/packages/tour/refresh.ts b/src/packages/tour/refresh.ts new file mode 100644 index 000000000..ead7ce800 --- /dev/null +++ b/src/packages/tour/refresh.ts @@ -0,0 +1,86 @@ +import { placeTooltip } from "../../packages/tooltip"; +import { _recreateBullets, _updateProgressBar } from "./showElement"; +import { Tour } from "./tour"; +import { + getElementByClassName, + queryElementByClassName, +} from "../../util/queryElement"; +import { + disableInteractionClassName, + helperLayerClassName, + tooltipReferenceLayerClassName, +} from "./classNames"; +import { setPositionRelativeToStep } from "./position"; +import { fetchSteps } from "./steps"; + +/** + * Update placement of the intro objects on the screen + * @api private + */ +export default function refresh(tour: Tour, refreshSteps?: boolean) { + const currentStep = tour.getCurrentStep(); + + if (currentStep === undefined || currentStep === null || currentStep == -1) { + return; + } + + const step = tour.getStep(currentStep); + + const referenceLayer = getElementByClassName(tooltipReferenceLayerClassName); + const helperLayer = getElementByClassName(helperLayerClassName); + const disableInteractionLayer = queryElementByClassName( + disableInteractionClassName + ); + + // re-align intros + const targetElement = tour.getTargetElement(); + const helperLayerPadding = tour.getOption("helperElementPadding"); + setPositionRelativeToStep( + targetElement, + helperLayer, + step, + helperLayerPadding + ); + setPositionRelativeToStep( + targetElement, + referenceLayer, + step, + helperLayerPadding + ); + + // not all steps have a disableInteractionLayer + if (disableInteractionLayer) { + setPositionRelativeToStep( + targetElement, + disableInteractionLayer, + step, + helperLayerPadding + ); + } + + if (refreshSteps) { + tour.setSteps(fetchSteps(tour)); + _recreateBullets(tour, step); + _updateProgressBar(referenceLayer, currentStep, tour.getSteps().length); + } + + // re-align tooltip + const oldArrowLayer = document.querySelector(".introjs-arrow"); + const oldTooltipContainer = + document.querySelector(".introjs-tooltip"); + + if (oldTooltipContainer && oldArrowLayer) { + placeTooltip( + oldTooltipContainer, + oldArrowLayer, + step.element as HTMLElement, + step.position, + tour.getOption("positionPrecedence"), + tour.getOption("showStepNumbers"), + tour.getOption("autoPosition"), + step.tooltipClass ?? tour.getOption("tooltipClass") + ); + } + + return tour; +} diff --git a/src/packages/tour/removeShowElement.ts b/src/packages/tour/removeShowElement.ts new file mode 100644 index 000000000..829299190 --- /dev/null +++ b/src/packages/tour/removeShowElement.ts @@ -0,0 +1,16 @@ +import { queryElementsByClassName } from "../../util/queryElement"; +import { removeClass } from "../../util/className"; +import { showElementClassName } from "./classNames"; + +/** + * To remove all show element(s) + * + * @api private + */ +export default function removeShowElement() { + const elms = Array.from(queryElementsByClassName(showElementClassName)); + + for (const elm of elms) { + removeClass(elm, /introjs-[a-zA-Z]+/g); + } +} diff --git a/src/packages/tour/showElement.ts b/src/packages/tour/showElement.ts new file mode 100644 index 000000000..b3ab4b32c --- /dev/null +++ b/src/packages/tour/showElement.ts @@ -0,0 +1,743 @@ +import scrollParentToElement from "../../util/scrollParentToElement"; +import scrollTo from "../../util/scrollTo"; +import { addClass, setClass } from "../../util/className"; +import setAnchorAsButton from "../../util/setAnchorAsButton"; +import { TourStep, nextStep, previousStep } from "./steps"; +import { placeTooltip } from "../../packages/tooltip"; +import removeShowElement from "./removeShowElement"; +import createElement from "../../util/createElement"; +import setStyle from "../../util/setStyle"; +import appendChild from "../../util/appendChild"; +import { + activeClassName, + arrowClassName, + bulletsClassName, + disabledButtonClassName, + disableInteractionClassName, + doneButtonClassName, + dontShowAgainClassName, + fullButtonClassName, + helperLayerClassName, + helperNumberLayerClassName, + hiddenButtonClassName, + nextButtonClassName, + previousButtonClassName, + progressBarClassName, + progressClassName, + skipButtonClassName, + tooltipButtonsClassName, + tooltipClassName, + tooltipHeaderClassName, + tooltipReferenceLayerClassName, + tooltipTextClassName, + tooltipTitleClassName, +} from "./classNames"; +import { Tour } from "./tour"; +import { dataStepNumberAttribute } from "./dataAttributes"; +import { + getElementByClassName, + queryElement, + queryElementByClassName, +} from "../../util/queryElement"; +import { setPositionRelativeToStep } from "./position"; +import getPropValue from "../../util/getPropValue"; + +/** + * Gets the current progress percentage + * + * @api private + * @returns current progress percentage + */ +export const _getProgress = (currentStep: number, introItemsLength: number) => { + // Steps are 0 indexed + return ((currentStep + 1) / introItemsLength) * 100; +}; + +/** + * Add disableinteraction layer and adjust the size and position of the layer + * + * @api private + */ +export const _disableInteraction = (tour: Tour, step: TourStep) => { + let disableInteractionLayer = queryElementByClassName( + disableInteractionClassName + ); + + if (disableInteractionLayer === null) { + disableInteractionLayer = createElement("div", { + className: disableInteractionClassName, + }); + + tour.getTargetElement().appendChild(disableInteractionLayer); + } + + setPositionRelativeToStep( + tour.getTargetElement(), + disableInteractionLayer, + step, + tour.getOption("helperElementPadding") + ); +}; + +/** + * Creates the bullets layer + * @private + */ +function _createBullets(tour: Tour, step: TourStep): HTMLElement { + const bulletsLayer = createElement("div", { + className: bulletsClassName, + }); + + if (tour.getOption("showBullets") === false) { + bulletsLayer.style.display = "none"; + } + + const ulContainer = createElement("ul"); + ulContainer.setAttribute("role", "tablist"); + + const anchorClick = function (this: HTMLElement) { + const stepNumber = this.getAttribute(dataStepNumberAttribute); + if (stepNumber == null) return; + + tour.goToStep(parseInt(stepNumber, 10)); + }; + + const steps = tour.getSteps(); + for (let i = 0; i < steps.length; i++) { + const { step: stepNumber } = steps[i]; + + const innerLi = createElement("li"); + const anchorLink = createElement("a"); + + innerLi.setAttribute("role", "presentation"); + anchorLink.setAttribute("role", "tab"); + + anchorLink.onclick = anchorClick; + + if (i === step.step - 1) { + setClass(anchorLink, activeClassName); + } + + setAnchorAsButton(anchorLink); + anchorLink.innerHTML = " "; + anchorLink.setAttribute(dataStepNumberAttribute, stepNumber.toString()); + + innerLi.appendChild(anchorLink); + ulContainer.appendChild(innerLi); + } + + bulletsLayer.appendChild(ulContainer); + + return bulletsLayer; +} + +/** + * Deletes and recreates the bullets layer + * @private + */ +export function _recreateBullets(tour: Tour, step: TourStep) { + if (tour.getOption("showBullets")) { + const existing = queryElementByClassName(bulletsClassName); + + if (existing && existing.parentNode) { + existing.parentNode.replaceChild(_createBullets(tour, step), existing); + } + } +} + +/** + * Updates the bullets + */ +function _updateBullets( + showBullets: boolean, + oldReferenceLayer: HTMLElement, + step: TourStep +) { + if (showBullets) { + const oldRefActiveBullet = queryElement( + `.${bulletsClassName} li > a.${activeClassName}`, + oldReferenceLayer + ); + + const oldRefBulletStepNumber = queryElement( + `.${bulletsClassName} li > a[${dataStepNumberAttribute}="${step.step}"]`, + oldReferenceLayer + ); + + if (oldRefActiveBullet && oldRefBulletStepNumber) { + oldRefActiveBullet.className = ""; + setClass(oldRefBulletStepNumber, activeClassName); + } + } +} + +/** + * Creates the progress-bar layer and elements + * @private + */ +function _createProgressBar(tour: Tour) { + const progressLayer = createElement("div"); + + setClass(progressLayer, progressClassName); + + if (tour.getOption("showProgress") === false) { + progressLayer.style.display = "none"; + } + + const progressBar = createElement("div", { + className: progressBarClassName, + }); + + if (tour.getOption("progressBarAdditionalClass")) { + addClass(progressBar, tour.getOption("progressBarAdditionalClass")); + } + + const progress = _getProgress(tour.getCurrentStep(), tour.getSteps().length); + progressBar.setAttribute("role", "progress"); + progressBar.setAttribute("aria-valuemin", "0"); + progressBar.setAttribute("aria-valuemax", "100"); + progressBar.setAttribute("aria-valuenow", progress.toString()); + progressBar.style.cssText = `width:${progress}%;`; + + progressLayer.appendChild(progressBar); + + return progressLayer; +} + +/** + * Updates an existing progress bar variables + * @private + */ +export function _updateProgressBar( + oldReferenceLayer: HTMLElement, + currentStep: number, + introItemsLength: number +) { + const progressBar = queryElement( + `.${progressClassName} .${progressBarClassName}`, + oldReferenceLayer + ); + + if (!progressBar) return; + + const progress = _getProgress(currentStep, introItemsLength); + + progressBar.style.cssText = `width:${progress}%;`; + progressBar.setAttribute("aria-valuenow", progress.toString()); +} + +/** + * To set the show element + * This function set a relative (in most cases) position and changes the z-index + * + * @api private + */ +function setShowElement(targetElement: HTMLElement) { + addClass(targetElement, "introjs-showElement"); + + const currentElementPosition = getPropValue(targetElement, "position"); + if ( + currentElementPosition !== "absolute" && + currentElementPosition !== "relative" && + currentElementPosition !== "sticky" && + currentElementPosition !== "fixed" + ) { + //change to new intro item + addClass(targetElement, "introjs-relativePosition"); + } +} + +let _lastShowElementTimer: number; + +/** + * Show an element on the page + * + * @api private + */ +export default async function _showElement(tour: Tour, step: TourStep) { + tour.callback("change")?.call(tour, step.element); + + const oldHelperLayer = queryElementByClassName(helperLayerClassName); + const oldReferenceLayer = queryElementByClassName( + tooltipReferenceLayerClassName + ); + + let highlightClass = helperLayerClassName; + let nextTooltipButton: HTMLElement; + let prevTooltipButton: HTMLElement; + let skipTooltipButton: HTMLElement; + + //check for a current step highlight class + if (typeof step.highlightClass === "string") { + highlightClass += ` ${step.highlightClass}`; + } + + //check for options highlight class + if (typeof tour.getOption("highlightClass") === "string") { + highlightClass += ` ${tour.getOption("highlightClass")}`; + } + + if (oldHelperLayer !== null && oldReferenceLayer !== null) { + const oldTooltipLayer = getElementByClassName( + tooltipTextClassName, + oldReferenceLayer + ); + const oldTooltipTitleLayer = getElementByClassName( + tooltipTitleClassName, + oldReferenceLayer + ); + const oldArrowLayer = getElementByClassName( + arrowClassName, + oldReferenceLayer + ); + const oldTooltipContainer = getElementByClassName( + tooltipClassName, + oldReferenceLayer + ); + + skipTooltipButton = getElementByClassName( + skipButtonClassName, + oldReferenceLayer + ); + + prevTooltipButton = getElementByClassName( + previousButtonClassName, + oldReferenceLayer + ); + + nextTooltipButton = getElementByClassName( + nextButtonClassName, + oldReferenceLayer + ); + + //update or reset the helper highlight class + setClass(oldHelperLayer, highlightClass); + + //hide the tooltip + oldTooltipContainer.style.opacity = "0"; + oldTooltipContainer.style.display = "none"; + + // if the target element is within a scrollable element + scrollParentToElement( + tour.getOption("scrollToElement"), + step.element as HTMLElement + ); + + // set new position to helper layer + const helperLayerPadding = tour.getOption("helperElementPadding"); + setPositionRelativeToStep( + tour.getTargetElement(), + oldHelperLayer, + step, + helperLayerPadding + ); + setPositionRelativeToStep( + tour.getTargetElement(), + oldReferenceLayer, + step, + helperLayerPadding + ); + + //remove old classes if the element still exist + removeShowElement(); + + //we should wait until the CSS3 transition is competed (it's 0.3 sec) to prevent incorrect `height` and `width` calculation + if (_lastShowElementTimer) { + window.clearTimeout(_lastShowElementTimer); + } + + const oldHelperNumberLayer = queryElementByClassName( + helperNumberLayerClassName, + oldReferenceLayer + ); + + _lastShowElementTimer = window.setTimeout(() => { + // set current step to the label + if (oldHelperNumberLayer !== null) { + oldHelperNumberLayer.innerHTML = `${step.step} ${tour.getOption( + "stepNumbersOfLabel" + )} ${tour.getSteps().length}`; + } + + // set current tooltip text + oldTooltipLayer.innerHTML = step.intro || ""; + + // set current tooltip title + oldTooltipTitleLayer.innerHTML = step.title || ""; + + //set the tooltip position + oldTooltipContainer.style.display = "block"; + placeTooltip( + oldTooltipContainer, + oldArrowLayer, + step.element as HTMLElement, + step.position, + tour.getOption("positionPrecedence"), + tour.getOption("showStepNumbers"), + tour.getOption("autoPosition"), + step.tooltipClass ?? tour.getOption("tooltipClass") + ); + + //change active bullet + _updateBullets(tour.getOption("showBullets"), oldReferenceLayer, step); + + _updateProgressBar( + oldReferenceLayer, + tour.getCurrentStep(), + tour.getSteps().length + ); + + //show the tooltip + oldTooltipContainer.style.opacity = "1"; + + //reset button focus + if ( + nextTooltipButton && + new RegExp(doneButtonClassName, "gi").test(nextTooltipButton.className) + ) { + // skip button is now "done" button + nextTooltipButton.focus(); + } else if (nextTooltipButton) { + //still in the tour, focus on next + nextTooltipButton.focus(); + } + + // change the scroll of the window, if needed + scrollTo( + tour.getOption("scrollToElement"), + step.scrollTo, + tour.getOption("scrollPadding"), + step.element as HTMLElement, + oldTooltipLayer + ); + }, 350); + + // end of old element if-else condition + } else { + const helperLayer = createElement("div", { + className: highlightClass, + }); + const referenceLayer = createElement("div", { + className: tooltipReferenceLayerClassName, + }); + const arrowLayer = createElement("div", { + className: arrowClassName, + }); + const tooltipLayer = createElement("div", { + className: tooltipClassName, + }); + const tooltipTextLayer = createElement("div", { + className: tooltipTextClassName, + }); + const tooltipHeaderLayer = createElement("div", { + className: tooltipHeaderClassName, + }); + const tooltipTitleLayer = createElement("h1", { + className: tooltipTitleClassName, + }); + + const buttonsLayer = createElement("div"); + + setStyle(helperLayer, { + // the inner box shadow is the border for the highlighted element + // the outer box shadow is the overlay effect + "box-shadow": `0 0 1px 2px rgba(33, 33, 33, 0.8), rgba(33, 33, 33, ${tour + .getOption("overlayOpacity") + .toString()}) 0 0 0 5000px`, + }); + + // target is within a scrollable element + scrollParentToElement( + tour.getOption("scrollToElement"), + step.element as HTMLElement + ); + + //set new position to helper layer + const helperLayerPadding = tour.getOption("helperElementPadding"); + setPositionRelativeToStep( + tour.getTargetElement(), + helperLayer, + step, + helperLayerPadding + ); + setPositionRelativeToStep( + tour.getTargetElement(), + referenceLayer, + step, + helperLayerPadding + ); + + //add helper layer to target element + appendChild(tour.getTargetElement(), helperLayer, true); + appendChild(tour.getTargetElement(), referenceLayer); + + tooltipTextLayer.innerHTML = step.intro; + tooltipTitleLayer.innerHTML = step.title; + + setClass(buttonsLayer, tooltipButtonsClassName); + + if (tour.getOption("showButtons") === false) { + buttonsLayer.style.display = "none"; + } + + tooltipHeaderLayer.appendChild(tooltipTitleLayer); + tooltipLayer.appendChild(tooltipHeaderLayer); + tooltipLayer.appendChild(tooltipTextLayer); + + // "Do not show again" checkbox + if (tour.getOption("dontShowAgain")) { + const dontShowAgainWrapper = createElement("div", { + className: dontShowAgainClassName, + }); + const dontShowAgainCheckbox = createElement("input", { + type: "checkbox", + id: dontShowAgainClassName, + name: dontShowAgainClassName, + }); + dontShowAgainCheckbox.onchange = (e) => { + tour.setDontShowAgain((e.target).checked); + }; + const dontShowAgainCheckboxLabel = createElement("label", { + htmlFor: dontShowAgainClassName, + }); + dontShowAgainCheckboxLabel.innerText = + tour.getOption("dontShowAgainLabel"); + dontShowAgainWrapper.appendChild(dontShowAgainCheckbox); + dontShowAgainWrapper.appendChild(dontShowAgainCheckboxLabel); + + tooltipLayer.appendChild(dontShowAgainWrapper); + } + + tooltipLayer.appendChild(_createBullets(tour, step)); + tooltipLayer.appendChild(_createProgressBar(tour)); + + // add helper layer number + const helperNumberLayer = createElement("div"); + + if (tour.getOption("showStepNumbers") === true) { + setClass(helperNumberLayer, helperNumberLayerClassName); + + helperNumberLayer.innerHTML = `${step.step} ${tour.getOption( + "stepNumbersOfLabel" + )} ${tour.getSteps().length}`; + tooltipLayer.appendChild(helperNumberLayer); + } + + tooltipLayer.appendChild(arrowLayer); + referenceLayer.appendChild(tooltipLayer); + + //next button + nextTooltipButton = createElement("a"); + + nextTooltipButton.onclick = async () => { + if (!tour.isLastStep()) { + await nextStep(tour); + } else if ( + new RegExp(doneButtonClassName, "gi").test(nextTooltipButton.className) + ) { + await tour + .callback("complete") + ?.call(tour, tour.getCurrentStep(), "done"); + + await tour.exit(); + } + }; + + setAnchorAsButton(nextTooltipButton); + nextTooltipButton.innerHTML = tour.getOption("nextLabel"); + + //previous button + prevTooltipButton = createElement("a"); + + prevTooltipButton.onclick = async () => { + if (tour.getCurrentStep() > 0) { + await previousStep(tour); + } + }; + + setAnchorAsButton(prevTooltipButton); + prevTooltipButton.innerHTML = tour.getOption("prevLabel"); + + //skip button + skipTooltipButton = createElement("a", { + className: skipButtonClassName, + }); + + setAnchorAsButton(skipTooltipButton); + skipTooltipButton.innerHTML = tour.getOption("skipLabel"); + + skipTooltipButton.onclick = async () => { + if (tour.isLastStep()) { + await tour + .callback("complete") + ?.call(tour, tour.getCurrentStep(), "skip"); + } + + await tour.callback("skip")?.call(tour, tour.getCurrentStep()); + + await tour.exit(); + }; + + tooltipHeaderLayer.appendChild(skipTooltipButton); + + // in order to prevent displaying previous button always + if (tour.getSteps().length > 1) { + buttonsLayer.appendChild(prevTooltipButton); + } + + // we always need the next button because this + // button changes to "Done" in the last step of the tour + buttonsLayer.appendChild(nextTooltipButton); + tooltipLayer.appendChild(buttonsLayer); + + // set proper position + placeTooltip( + tooltipLayer, + arrowLayer, + step.element as HTMLElement, + step.position, + tour.getOption("positionPrecedence"), + tour.getOption("showStepNumbers"), + tour.getOption("autoPosition"), + step.tooltipClass ?? tour.getOption("tooltipClass") + ); + + // change the scroll of the window, if needed + scrollTo( + tour.getOption("scrollToElement"), + step.scrollTo, + tour.getOption("scrollPadding"), + step.element as HTMLElement, + tooltipLayer + ); + + //end of new element if-else condition + } + + // removing previous disable interaction layer + const disableInteractionLayer = queryElementByClassName( + disableInteractionClassName, + tour.getTargetElement() + ); + if (disableInteractionLayer && disableInteractionLayer.parentNode) { + disableInteractionLayer.parentNode.removeChild(disableInteractionLayer); + } + + //disable interaction + if (step.disableInteraction) { + _disableInteraction(tour, step); + } + + // when it's the first step of tour + if (tour.getCurrentStep() === 0 && tour.getSteps().length > 1) { + if (nextTooltipButton) { + setClass( + nextTooltipButton, + tour.getOption("buttonClass"), + nextButtonClassName + ); + nextTooltipButton.innerHTML = tour.getOption("nextLabel"); + } + + if (tour.getOption("hidePrev") === true) { + if (prevTooltipButton) { + setClass( + prevTooltipButton, + tour.getOption("buttonClass"), + previousButtonClassName, + hiddenButtonClassName + ); + } + if (nextTooltipButton) { + addClass(nextTooltipButton, fullButtonClassName); + } + } else { + if (prevTooltipButton) { + setClass( + prevTooltipButton, + tour.getOption("buttonClass"), + previousButtonClassName, + disabledButtonClassName + ); + } + } + } else if (tour.isLastStep() || tour.getSteps().length === 1) { + // last step of tour + if (prevTooltipButton) { + setClass( + prevTooltipButton, + tour.getOption("buttonClass"), + previousButtonClassName + ); + } + + if (tour.getOption("hideNext") === true) { + if (nextTooltipButton) { + setClass( + nextTooltipButton, + tour.getOption("buttonClass"), + nextButtonClassName, + hiddenButtonClassName + ); + } + if (prevTooltipButton) { + addClass(prevTooltipButton, fullButtonClassName); + } + } else { + if (nextTooltipButton) { + if (tour.getOption("nextToDone") === true) { + nextTooltipButton.innerHTML = tour.getOption("doneLabel"); + addClass( + nextTooltipButton, + tour.getOption("buttonClass"), + nextButtonClassName, + doneButtonClassName + ); + } else { + setClass( + nextTooltipButton, + tour.getOption("buttonClass"), + nextButtonClassName, + disabledButtonClassName + ); + } + } + } + } else { + // steps between start and end + if (prevTooltipButton) { + setClass( + prevTooltipButton, + tour.getOption("buttonClass"), + previousButtonClassName + ); + } + if (nextTooltipButton) { + setClass( + nextTooltipButton, + tour.getOption("buttonClass"), + nextButtonClassName + ); + nextTooltipButton.innerHTML = tour.getOption("nextLabel"); + } + } + + if (prevTooltipButton) { + prevTooltipButton.setAttribute("role", "button"); + } + if (nextTooltipButton) { + nextTooltipButton.setAttribute("role", "button"); + } + if (skipTooltipButton) { + skipTooltipButton.setAttribute("role", "button"); + } + + //Set focus on "next" button, so that hitting Enter always moves you onto the next step + if (nextTooltipButton) { + nextTooltipButton.focus(); + } + + setShowElement(step.element as HTMLElement); + + await tour.callback("afterChange")?.call(tour, step.element); +} diff --git a/tests/cypress/e2e/tour/start.cy.js b/src/packages/tour/start.cy.ts similarity index 89% rename from tests/cypress/e2e/tour/start.cy.js rename to src/packages/tour/start.cy.ts index 0fcb3a2d8..119c1c5c2 100644 --- a/tests/cypress/e2e/tour/start.cy.js +++ b/src/packages/tour/start.cy.ts @@ -1,7 +1,7 @@ context("Start", () => { it("should start the tour with data-intro attributes", () => { cy.visit("./cypress/setup/index.html").then((window) => { - const instance = window.introJs(); + const instance = window.introJs.tour(); instance.start(); cy.get(".introjs-tooltiptext").contains("first header step"); @@ -18,7 +18,7 @@ context("Start", () => { it("should prefer tour configs over data-intro attributes", () => { cy.visit("./cypress/setup/index.html").then((window) => { - const instance = window.introJs().setOptions({ + const instance = window.introJs.tour().setOptions({ steps: [ { intro: "step one", @@ -37,7 +37,7 @@ context("Start", () => { it("should not throw an exception after calling start mulitple times", () => { cy.visit("./cypress/setup/index.html").then((window) => { - const instance = window.introJs().setOptions({ + const instance = window.introJs.tour().setOptions({ steps: [ { intro: "step one", diff --git a/src/packages/tour/start.test.ts b/src/packages/tour/start.test.ts new file mode 100644 index 000000000..580a7a12d --- /dev/null +++ b/src/packages/tour/start.test.ts @@ -0,0 +1,62 @@ +import { start } from "./start"; +import * as steps from "./steps"; +import * as addOverlayLayer from "./addOverlayLayer"; +import * as nextStep from "./steps"; +import { getMockTour } from "./mock"; + +describe("start", () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + test("should call the onstart callback", () => { + jest.spyOn(steps, "fetchSteps").mockReturnValue([]); + jest.spyOn(addOverlayLayer, "default").mockReturnValue(true); + jest.spyOn(nextStep, "nextStep").mockReturnValue(Promise.resolve(true)); + + const onstartCallback = jest.fn(); + + const mockTour = getMockTour(); + mockTour.onStart(onstartCallback); + + start(mockTour); + + expect(onstartCallback).toBeCalledTimes(1); + expect(onstartCallback).toBeCalledWith(document.body); + }); + + test("should not start the tour if isActive is false", () => { + const fetchIntroStepsMock = jest + .spyOn(steps, "fetchSteps") + .mockReturnValue([]); + const addOverlayLayerMock = jest.spyOn(addOverlayLayer, "default"); + const nextStepMock = jest.spyOn(nextStep, "nextStep"); + + const mockTour = getMockTour(); + mockTour.setOption("isActive", false); + + start(mockTour); + + expect(fetchIntroStepsMock).toBeCalledTimes(0); + expect(addOverlayLayerMock).toBeCalledTimes(0); + expect(nextStepMock).toBeCalledTimes(0); + }); + + test("should fetch the steps", async () => { + // Arrange + const targetElement = document.createElement("div"); + document.body.appendChild(targetElement); + + const mockTour = getMockTour(); + mockTour.addStep({ + intro: "first", + }); + + // Act + await mockTour.start(); + + // Assert + expect(mockTour.getSteps()).toHaveLength(1); + expect(document.querySelectorAll(".introjs-bullets ul li").length).toBe(1); + }); +}); diff --git a/src/packages/tour/start.ts b/src/packages/tour/start.ts new file mode 100644 index 000000000..074d83421 --- /dev/null +++ b/src/packages/tour/start.ts @@ -0,0 +1,42 @@ +import addOverlayLayer from "./addOverlayLayer"; +import { nextStep } from "./steps"; +import { fetchSteps } from "./steps"; +import { Tour } from "./tour"; + +/** + * Initiate a new tour the page + * + * @api private + */ +export const start = async (tour: Tour): Promise => { + // don't start the tour if the instance is not active + if (!tour.isActive()) { + return false; + } + + // don't start the tour if it's already started + if (tour.hasStarted()) { + return false; + } + + await tour.callback("start")?.call(tour, tour.getTargetElement()); + + //set it to the introJs object + const steps = fetchSteps(tour); + + if (steps.length === 0) { + return false; + } + + tour.setSteps(steps); + + //add overlay layer to the page + if (addOverlayLayer(tour)) { + //then, start the show + await nextStep(tour); + + return true; + } + + return false; +}; diff --git a/src/packages/tour/steps.test.ts b/src/packages/tour/steps.test.ts new file mode 100644 index 000000000..a9d65066a --- /dev/null +++ b/src/packages/tour/steps.test.ts @@ -0,0 +1,325 @@ +import { fetchSteps, nextStep, previousStep } from "./steps"; +import _showElement from "./showElement"; +import { + appendMockSteps, + getMockPartialSteps, + getMockSteps, + getMockTour, +} from "./mock"; +import createElement from "../../util/createElement"; + +jest.mock("./showElement"); +jest.mock("./exitIntro"); + +describe("steps", () => { + describe("previousStep", () => { + test("should decrement the step counter", async () => { + // Arrange + const mockTour = getMockTour(); + mockTour.setCurrentStep(1); + + // Act + await previousStep(mockTour); + + // Assert + expect(mockTour.getCurrentStep()).toBe(0); + }); + + test("should not decrement when step is 0", async () => { + // Arrange + const mockTour = getMockTour(); + mockTour.setCurrentStep(0); + + // Act + await previousStep(mockTour); + + // Assert + expect(mockTour.getCurrentStep()).toBe(0); + }); + }); + + describe("nextStep", () => { + test("should increment the step counter", async () => { + // Arrange + const mockTour = getMockTour(); + mockTour.setCurrentStep(0); + + // Act + await nextStep(mockTour); + + // Assert + expect(mockTour.getCurrentStep()).toBe(1); + }); + + test("should call ShowElement", async () => { + // Arrange + const showElementMock = jest.fn(); + (_showElement as jest.Mock).mockImplementation(showElementMock); + const mockTour = getMockTour(); + mockTour.setSteps(getMockSteps()); + + // Act + await nextStep(mockTour); + + // Assert + expect(showElementMock).toHaveBeenCalledTimes(1); + }); + + test("should call the onBeforeChange callback", async () => { + // Arrange + const mockTour = getMockTour(); + mockTour.setSteps(getMockSteps()); + const fnBeforeChangeCallback = jest.fn(); + mockTour.onBeforeChange(fnBeforeChangeCallback); + + // Act + await nextStep(mockTour); + + // Assert + expect(fnBeforeChangeCallback).toHaveBeenCalledTimes(1); + expect(fnBeforeChangeCallback).toHaveBeenCalledWith( + undefined, + 0, + "forward" + ); + }); + + test("should not continue when onBeforeChange return false", async () => { + // Arrange + const mockTour = getMockTour(); + const showElementMock = jest.fn(); + (_showElement as jest.Mock).mockImplementation(showElementMock); + const fnBeforeChangeCallback = jest.fn(); + fnBeforeChangeCallback.mockReturnValue(false); + + mockTour.onBeforeChange(fnBeforeChangeCallback); + + // Act + await nextStep(mockTour); + + // Assert + expect(fnBeforeChangeCallback).toHaveBeenCalledTimes(1); + expect(showElementMock).toHaveBeenCalledTimes(0); + }); + + test("should wait for the onBeforeChange promise object", async () => { + // Arrange + const mockTour = getMockTour(); + mockTour.setSteps(getMockSteps()); + const showElementMock = jest.fn(); + (_showElement as jest.Mock).mockImplementation(showElementMock); + + const onBeforeChangeMock = jest.fn(); + const sideEffect: number[] = []; + + mockTour.onBeforeChange(async () => { + return new Promise((res) => { + setTimeout(() => { + sideEffect.push(1); + onBeforeChangeMock(); + res(true); + }, 50); + }); + }); + + expect(sideEffect).toHaveLength(0); + + // Act + await nextStep(mockTour); + + // Assert + expect(sideEffect).toHaveLength(1); + expect(onBeforeChangeMock).toHaveBeenCalledBefore(showElementMock); + }); + + test("should call the complete callback", async () => { + // Arrange + const mockTour = getMockTour(); + mockTour.setSteps(getMockSteps().slice(0, 2)); + const fnCompleteCallback = jest.fn(); + mockTour.onComplete(fnCompleteCallback); + + // Act + await nextStep(mockTour); + await nextStep(mockTour); + await nextStep(mockTour); + + // Assert + expect(fnCompleteCallback).toBeCalledTimes(1); + expect(fnCompleteCallback).toHaveBeenCalledWith(2, "end"); + }); + + test("should be able to add steps using addStep()", async () => { + // Arrange + const mockTour = getMockTour(); + mockTour.addStep({ + element: createElement("div"), + intro: "test step", + }); + + // Act + await mockTour.start(); + + // Assert + expect(mockTour.getSteps()).toHaveLength(1); + expect(mockTour.getStep(0).intro).toBe("test step"); + }); + + test("should be able to add steps using addSteps()", async () => { + // Arrange + const mockTour = getMockTour(); + + mockTour.addSteps([ + { + intro: "first step", + }, + { + element: createElement("div"), + intro: "second step", + }, + ]); + + // Act + await mockTour.start(); + + // Assert + expect(mockTour.getSteps()).toHaveLength(2); + expect(mockTour.getStep(0).intro).toBe("first step"); + expect(mockTour.getStep(1).intro).toBe("second step"); + }); + }); + + describe("fetchSteps", () => { + test("should add floating element from options.steps to the list", () => { + // Arrange + const mockTour = getMockTour(); + mockTour.setOption("steps", getMockSteps()); + + // Act + const steps = fetchSteps(mockTour); + + // Assert + expect(steps.length).toBe(5); + + expect(steps[0].position).toBe("floating"); + expect(steps[0].title).toBe("Floating title 1"); + expect(steps[0].intro).toBe("Step One of the tour"); + expect(steps[0].step).toBe(1); + + expect(steps[1].position).toBe("floating"); + expect(steps[1].title).toBe("Floating title 2"); + expect(steps[1].intro).toBe("Step Two of the tour"); + expect(steps[1].step).toBe(2); + }); + + test("should find and add elements from options.steps to the list", () => { + // Arrange + document.body.appendChild(createElement("h1")); + + const mockTour = getMockTour(); + mockTour.addSteps(getMockPartialSteps()); + + // Act + const steps = fetchSteps(mockTour); + + // Assert + expect(steps.length).toBe(5); + + expect(steps[0].position).toBe("floating"); + expect(steps[0].title).toBe("Floating title 1"); + expect(steps[0].intro).toBe("Step One of the tour"); + expect(steps[0].step).toBe(1); + + expect(steps[1].position).toBe("floating"); + expect(steps[1].title).toBe("Floating title 2"); + expect(steps[1].intro).toBe("Step Two of the tour"); + expect(steps[1].step).toBe(2); + + expect(steps[2].position).toBe("top"); + expect(steps[2].title).toBe("First title"); + expect(steps[2].intro).toBe("Step Three of the tour"); + expect(steps[2].step).toBe(3); + + expect(steps[3].element).toStrictEqual(getMockPartialSteps()[3].element); + expect(steps[3].position).toBe("right"); + expect(steps[3].intro).toBe("Step Four of the tour"); + expect(steps[3].step).toBe(4); + + expect(steps[4].position).toBe("floating"); + expect(steps[4].intro).toBe("Element not found"); + expect(steps[4].step).toBe(5); + }); + + test("should find the data-* elements from the DOM with the correct order", () => { + // Arrange + const targetElement = createElement("div"); + const [ + mockElementOne, + mockElementTwo, + mockElementThree, + mockElementFour, + ] = appendMockSteps(targetElement); + const mockTour = getMockTour(targetElement); + + // Act + const steps = fetchSteps(mockTour); + + // Assert + expect(steps.length).toBe(4); + + expect(steps[0].position).toBe("bottom"); + expect(steps[0].intro).toBe("Mock element"); + expect(steps[0].element).toBe(mockElementOne); + expect(steps[0].step).toBe(1); + + expect(steps[1].position).toBe("left"); + expect(steps[1].intro).toBe("Mock element left position"); + expect(steps[1].element).toBe(mockElementTwo); + expect(steps[1].step).toBe(2); + + expect(steps[2].position).toBe("bottom"); + expect(steps[2].intro).toBe("Mock element second to last"); + expect(steps[2].element).toBe(mockElementThree); + expect(steps[2].step).toBe(10); + + expect(steps[3].position).toBe("bottom"); + expect(steps[3].intro).toBe("Mock element last"); + expect(steps[3].element).toBe(mockElementFour); + expect(steps[3].step).toBe(20); + }); + + test("should respect the custom step attribute (DOM)", () => { + // Arrange + appendMockSteps(); + + const mockTour = getMockTour(); + + // Act + const steps = fetchSteps(mockTour); + + // Assert + expect(steps.length).toBe(4); + + expect(steps[2].intro).toBe("Mock element second to last"); + expect(steps[2].step).toBe(10); + + expect(steps[3].intro).toBe("Mock element last"); + expect(steps[3].step).toBe(20); + }); + + test("should ignore DOM elements when options.steps is available", () => { + // Arrange + appendMockSteps(); + var mockTour = getMockTour(); + mockTour.setOption("steps", getMockSteps()); + + // Act + const steps = fetchSteps(mockTour); + + // Assert + expect(steps.length).toBe(5); + expect(steps[0].intro).toBe("Step One of the tour"); + expect(steps[1].intro).toBe("Step Two of the tour"); + }); + }); +}); diff --git a/src/packages/tour/steps.ts b/src/packages/tour/steps.ts new file mode 100644 index 000000000..978eda828 --- /dev/null +++ b/src/packages/tour/steps.ts @@ -0,0 +1,244 @@ +import { TooltipPosition } from "../../packages/tooltip"; +import showElement from "./showElement"; +import { + queryElement, + queryElementByClassName, + queryElements, +} from "../../util/queryElement"; +import cloneObject from "../../util/cloneObject"; +import createElement from "../../util/createElement"; +import { Tour } from "./tour"; +import { floatingElementClassName } from "./classNames"; +import { + dataDisableInteraction, + dataHighlightClass, + dataIntroAttribute, + dataIntroGroupAttribute, + dataPosition, + dataScrollTo, + dataStepAttribute, + dataTitleAttribute, + dataTooltipClass, +} from "./dataAttributes"; + +export type ScrollTo = "off" | "element" | "tooltip"; + +export type TourStep = { + step: number; + title: string; + intro: string; + tooltipClass?: string; + highlightClass?: string; + element?: Element | HTMLElement | string | null; + position: TooltipPosition; + scrollTo: ScrollTo; + disableInteraction?: boolean; +}; + +/** + * Go to next step on intro + * + * @api private + */ +export async function nextStep(tour: Tour) { + tour.incrementCurrentStep(); + + const nextStep = tour.getStep(tour.getCurrentStep()); + let continueStep: boolean | undefined = true; + + continueStep = await tour + .callback("beforeChange") + ?.call( + tour, + nextStep && (nextStep.element as HTMLElement), + tour.getCurrentStep(), + tour.getDirection() + ); + + // if `onbeforechange` returned `false`, stop displaying the element + if (continueStep === false) { + tour.decrementCurrentStep(); + return false; + } + + if (tour.isEnd()) { + // check if any callback is defined + await tour.callback("complete")?.call(tour, tour.getCurrentStep(), "end"); + await tour.exit(); + + return false; + } + + await showElement(tour, nextStep); + + return true; +} + +/** + * Go to previous step on intro + * + * @api private + */ +export async function previousStep(tour: Tour) { + if (tour.getCurrentStep() <= 0) { + return false; + } + + tour.decrementCurrentStep(); + + const nextStep = tour.getStep(tour.getCurrentStep()); + let continueStep: boolean | undefined = true; + + continueStep = await tour + .callback("beforeChange") + ?.call( + tour, + nextStep && (nextStep.element as HTMLElement), + tour.getCurrentStep(), + tour.getDirection() + ); + + // if `onbeforechange` returned `false`, stop displaying the element + if (continueStep === false) { + tour.incrementCurrentStep(); + return false; + } + + await showElement(tour, nextStep); + + return true; +} + +/** + * Finds all Intro steps from the data-* attributes and the options.steps array + * + * @api private + */ +export const fetchSteps = (tour: Tour) => { + let steps: TourStep[] = []; + + if (tour.getOption("steps")?.length) { + //use steps passed programmatically + for (const _step of tour.getOption("steps")) { + const step = cloneObject(_step); + + //set the step + step.step = steps.length + 1; + + step.title = step.title || ""; + + //use querySelector function only when developer used CSS selector + if (typeof step.element === "string") { + //grab the element with given selector from the page + step.element = queryElement(step.element) || undefined; + } + + // tour without element + if (!step.element) { + let floatingElementQuery = queryElementByClassName( + floatingElementClassName + ); + + if (!floatingElementQuery) { + floatingElementQuery = createElement("div", { + className: floatingElementClassName, + }); + + document.body.appendChild(floatingElementQuery); + } + + step.element = floatingElementQuery; + step.position = "floating"; + } + + step.position = step.position || tour.getOption("tooltipPosition"); + step.scrollTo = step.scrollTo || tour.getOption("scrollTo"); + + if (typeof step.disableInteraction === "undefined") { + step.disableInteraction = tour.getOption("disableInteraction"); + } + + if (step.element !== null) { + steps.push(step as TourStep); + } + } + } else { + const elements = Array.from( + queryElements(`*[${dataIntroAttribute}]`, tour.getTargetElement()) + ); + + // if there's no element to intro + if (elements.length < 1) { + return []; + } + + const itemsWithoutStep: TourStep[] = []; + + for (const element of elements) { + // start intro for groups of elements + if ( + tour.getOption("group") && + element.getAttribute(dataIntroGroupAttribute) !== + tour.getOption("group") + ) { + continue; + } + + // skip hidden elements + if (element.style.display === "none") { + continue; + } + + // get the step for the current element or set as 0 if is not present + const stepIndex = parseInt( + element.getAttribute(dataStepAttribute) || "0", + 10 + ); + + let disableInteraction = tour.getOption("disableInteraction"); + if (element.hasAttribute(dataDisableInteraction)) { + disableInteraction = !!element.getAttribute(dataDisableInteraction); + } + + const newIntroStep: TourStep = { + step: stepIndex, + element, + title: element.getAttribute(dataTitleAttribute) || "", + intro: element.getAttribute(dataIntroAttribute) || "", + tooltipClass: element.getAttribute(dataTooltipClass) || undefined, + highlightClass: element.getAttribute(dataHighlightClass) || undefined, + position: (element.getAttribute(dataPosition) || + tour.getOption("tooltipPosition")) as TooltipPosition, + scrollTo: + (element.getAttribute(dataScrollTo) as ScrollTo) || + tour.getOption("scrollTo"), + disableInteraction, + }; + + if (stepIndex > 0) { + steps[stepIndex - 1] = newIntroStep; + } else { + itemsWithoutStep.push(newIntroStep); + } + } + + // fill items without step in blanks and update their step + for (let i = 0; itemsWithoutStep.length > 0; i++) { + if (typeof steps[i] === "undefined") { + const newStep = itemsWithoutStep.shift(); + if (!newStep) break; + + newStep.step = i + 1; + steps[i] = newStep; + } + } + } + + // removing undefined/null elements + steps = steps.filter((n) => n); + + // Sort all items with given steps + steps.sort((a, b) => a.step - b.step); + + return steps; +}; diff --git a/src/packages/tour/tour.test.ts b/src/packages/tour/tour.test.ts new file mode 100644 index 000000000..a8a4bcdd2 --- /dev/null +++ b/src/packages/tour/tour.test.ts @@ -0,0 +1,608 @@ +import { queryElementByClassName } from "../../util/queryElement"; +import { + className, + content, + doneButton, + find, + nextButton, + prevButton, + skipButton, + tooltipText, + waitFor, +} from "../../../tests/jest/helper"; +import * as dontShowAgain from "./dontShowAgain"; +import { getMockPartialSteps, getMockTour } from "./mock"; +import { Tour } from "./tour"; +import { helperLayerClassName, overlayClassName } from "./classNames"; + +describe("Tour", () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe("constructor", () => { + test("should set the targetElement to document.body", () => { + // Arrange & Act + const tour = new Tour(); + + // Assert + expect(tour.getTargetElement()).toBe(document.body); + }); + + test("should set the correct targetElement", () => { + // Arrange + const stubTargetElement = document.createElement("div"); + + // Act + const tour = new Tour(stubTargetElement); + + // Assert + expect(tour.getTargetElement()).toBe(stubTargetElement); + }); + }); + + describe("start", () => { + let mockTour: Tour; + + beforeEach(() => { + mockTour = getMockTour(); + + document.body.innerHTML = `
+

Title

+

Paragraph

+
Position Absolute
+
Position Fixed
+
`; + }); + + afterEach(async () => { + await mockTour.exit(); + }); + + test("should not start the tour twice", async () => { + // Arrange + mockTour.addSteps(getMockPartialSteps()); + const onStartMock = jest.fn(); + mockTour.onStart(onStartMock); + + // Act + await mockTour.start(); + await mockTour.start(); + + // Assert + expect(onStartMock).toBeCalledTimes(1); + }); + + test("should start floating intro with one step", async () => { + // Arrange & Act + await mockTour + .setOptions({ + steps: [ + { + intro: "hello world", + }, + ], + }) + .start(); + + // Assert + expect(content(tooltipText())).toBe("hello world"); + expect(content(doneButton())).toBe("Done"); + expect(prevButton()).toBeNull(); + expect(className(".introjs-showElement")).toContain( + "introjsFloatingElement" + ); + expect(className(".introjs-showElement")).toContain( + "introjs-relativePosition" + ); + }); + + test("should start floating intro with two steps", async () => { + // Arrange + mockTour.setOptions({ + steps: [ + { + intro: "step one", + }, + { + intro: "step two", + }, + ], + }); + + // Act + await mockTour.start(); + + // Assert + expect(content(tooltipText())).toBe("step one"); + + expect(doneButton()).toBeNull(); + + expect(prevButton()).not.toBeNull(); + expect(className(prevButton())).toContain("introjs-disabled"); + + expect(nextButton()).not.toBeNull(); + expect(className(nextButton())).not.toContain("introjs-disabled"); + + expect(className(".introjs-showElement")).toContain( + "introjsFloatingElement" + ); + expect(className(".introjs-showElement")).toContain( + "introjs-relativePosition" + ); + }); + + test("should highlight the target element", async () => { + // Arrange + const mockElement = document.querySelector("#paragraph"); + mockTour.setOptions({ + steps: [ + { + intro: "step one", + element: mockElement, + }, + ], + }); + + // Act + await mockTour.start(); + + // Assert + expect(mockElement?.className).toContain("introjs-showElement"); + expect(mockElement?.className).toContain("introjs-relativePosition"); + }); + + test("should remove the container element after exit() is called", async () => { + // Arrange + const mockElement = document.querySelector("#paragraph"); + mockTour.setOptions({ + steps: [ + { + intro: "step one", + element: mockElement, + }, + ], + }); + + // Act + await mockTour.start(); + await mockTour.exit(); + + // Assert + expect(mockElement?.className).not.toContain("introjs-showElement"); + expect(queryElementByClassName(helperLayerClassName)).toBeNull(); + expect(queryElementByClassName(overlayClassName)).toBeNull(); + }); + + test("should not highlight the target element if queryString is incorrect", async () => { + // Arrange + const mockElement = document.querySelector("#non-existing-element"); + mockTour.setOptions({ + steps: [ + { + intro: "step one", + element: mockElement, + }, + ], + }); + + // Act + await mockTour.start(); + + // Assert + expect(className(".introjs-showElement")).toContain( + "introjsFloatingElement" + ); + }); + + test("should not add relativePosition if target element is fixed", async () => { + // Arrange + const fixedMockElement = document.querySelector("#position-fixed"); + mockTour.setOptions({ + steps: [ + { + intro: "step one", + element: fixedMockElement, + }, + ], + }); + + // Act + await mockTour.start(); + + // Assert + expect(fixedMockElement?.className).toContain("introjs-showElement"); + expect(fixedMockElement?.className).not.toContain( + "introjs-relativePosition" + ); + }); + + test("should not add relativePosition if target element is fixed or absolute", async () => { + // Arrange + const absoluteMockElement = document.querySelector("#position-absolute"); + mockTour.setOptions({ + steps: [ + { + intro: "step one", + element: absoluteMockElement, + }, + ], + }); + + // Act + await mockTour.start(); + + // Assert + expect(absoluteMockElement?.className).toContain("introjs-showElement"); + expect(absoluteMockElement?.className).not.toContain( + "introjs-relativePosition" + ); + }); + + test("should call the onstart callback", async () => { + // Arrange + const fn = jest.fn(); + mockTour + .setOptions({ + steps: [ + { + intro: "step one", + element: document.querySelector("h1"), + }, + ], + }) + .onStart(fn); + + // Act + await mockTour.start(); + + // Assert + expect(fn).toBeCalledTimes(1); + expect(fn).toBeCalledWith(window.document.body); + }); + + test("should call onexit and oncomplete when there is one step", async () => { + // Arrange + const onexitMock = jest.fn(); + const oncompleteMock = jest.fn(); + + mockTour + .setOptions({ + steps: [ + { + intro: "hello world", + }, + ], + }) + .onExit(onexitMock) + .onComplete(oncompleteMock); + + // Act + await mockTour.start(); + nextButton().click(); + await waitFor(1000); + + // Assert + expect(onexitMock).toBeCalledTimes(1); + expect(oncompleteMock).toBeCalledTimes(1); + }); + + test("should call onexit when skip is clicked", async () => { + // Arrange + const onexitMock = jest.fn(); + const oncompleteMock = jest.fn(); + + mockTour + .setOptions({ + steps: [ + { + intro: "hello world", + }, + ], + }) + .onExit(onexitMock) + .onComplete(oncompleteMock); + + // Act + await mockTour.start(); + skipButton().click(); + await waitFor(1000); + + // Assert + expect(onexitMock).toBeCalledTimes(1); + expect(oncompleteMock).toBeCalledTimes(1); + }); + + test("should call not oncomplete when skip is clicked and there are two steps", async () => { + // Arrange + const onexitMock = jest.fn(); + const oncompleteMMock = jest.fn(); + mockTour + .setOptions({ + steps: [ + { + intro: "first", + }, + { + intro: "second", + }, + ], + }) + .onExit(onexitMock) + .onComplete(oncompleteMMock); + + // Act + await mockTour.start(); + skipButton().click(); + await waitFor(1000); + + // Assert + expect(onexitMock).toBeCalledTimes(1); + expect(oncompleteMMock).toBeCalledTimes(0); + }); + + test("should not append the dontShowAgain checkbox when its inactive", async () => { + // Arrange + mockTour.setOptions({ + dontShowAgain: false, + steps: [ + { + intro: "hello world", + }, + ], + }); + + // Act + await mockTour.start(); + + // Assert + expect(find(".introjs-dontShowAgain")).toBeNull(); + }); + + test("should append the dontShowAgain checkbox", async () => { + // Arrange + mockTour.setOptions({ + dontShowAgain: true, + steps: [ + { + intro: "hello world", + }, + ], + }); + + // Act + await mockTour.start(); + + // Assert + expect(find(".introjs-dontShowAgain")).not.toBeNull(); + }); + + test("should call setDontShowAgain when then checkbox is clicked", async () => { + // Arrange + const setDontShowAgainSpy = jest.spyOn(dontShowAgain, "setDontShowAgain"); + + mockTour.setOptions({ + dontShowAgain: true, + steps: [ + { + intro: "hello world", + }, + ], + }); + + // Act + await mockTour.start(); + const checkbox = find(".introjs-dontShowAgain input"); + checkbox.click(); + + // Assert + expect(setDontShowAgainSpy).toBeCalledTimes(1); + expect(setDontShowAgainSpy).toBeCalledWith( + true, + mockTour.getOption("dontShowAgainCookie"), + mockTour.getOption("dontShowAgainCookieDays") + ); + }); + + it("should clean up all event listeners", async () => { + // Arrange + const tour = new Tour(); + tour.addSteps(getMockPartialSteps()); + const addEventListenerSpy = jest.spyOn(window, "addEventListener"); + const removeEventListenerSpy = jest.spyOn(window, "removeEventListener"); + + // Act + await tour.start(); + await tour.exit(); + + // Assert + expect(addEventListenerSpy).toBeCalledTimes(2); + expect(removeEventListenerSpy).toBeCalledTimes(2); + }); + + it("should not enable keyboard navigation and resize when start is false", async () => { + // Arrange + mockTour.enableKeyboardNavigation = jest.fn(); + mockTour.enableRefreshOnResize = jest.fn(); + + // Act + await mockTour.start(); + + // Assert + expect(mockTour.enableKeyboardNavigation).not.toBeCalled(); + expect(mockTour.enableRefreshOnResize).not.toBeCalled(); + }); + }); + + describe("enableRefreshOnResize", () => { + it("should add event listener for resize", () => { + // Arrange + const tour = new Tour(); + const addEventListenerSpy = jest.spyOn(window, "addEventListener"); + + // Act + tour.enableRefreshOnResize(); + + // Assert + expect(addEventListenerSpy).toBeCalledWith( + "resize", + expect.any(Function), + true + ); + }); + }); + + describe("disableRefreshOnResize", () => { + it('should remove event listener for "resize"', () => { + // Arrange + const tour = new Tour(); + const removeEventListenerSpy = jest.spyOn(window, "removeEventListener"); + + // Act + tour.enableRefreshOnResize(); + tour.disableRefreshOnResize(); + + // Assert + expect(removeEventListenerSpy).toBeCalledWith( + "resize", + expect.any(Function), + true + ); + }); + }); + + describe("enableKeyboardNavigation", () => { + it("should not add event listener when keyboard navigation is disabled", () => { + // Arrange + const tour = new Tour(); + tour.setOption("keyboardNavigation", false); + const addEventListenerSpy = jest.spyOn(window, "addEventListener"); + + // Act + tour.enableKeyboardNavigation(); + + // Assert + expect(addEventListenerSpy).not.toBeCalledWith( + "keydown", + expect.any(Function), + true + ); + }); + + it('should add event listener for "keydown"', () => { + // Arrange + const tour = new Tour(); + const addEventListenerSpy = jest.spyOn(window, "addEventListener"); + + // Act + tour.enableKeyboardNavigation(); + + // Assert + expect(addEventListenerSpy).toBeCalledWith( + "keydown", + expect.any(Function), + true + ); + }); + }); + + describe("disableKeyboardNavigation", () => { + it('should remove event listener for "keydown"', () => { + // Arrange + const tour = new Tour(); + const removeEventListenerSpy = jest.spyOn(window, "removeEventListener"); + + // Act + tour.enableKeyboardNavigation(); + tour.disableKeyboardNavigation(); + + // Assert + expect(removeEventListenerSpy).toBeCalledWith( + "keydown", + expect.any(Function), + true + ); + }); + }); + + describe("isActive", () => { + test("should be false if isActive flag is false", () => { + // Arrange + const tour = new Tour(); + + // Act + tour.setOptions({ + isActive: false, + }); + + // Assert + expect(tour.isActive()).toBeFalsy(); + }); + + test("should be true if dontShowAgain is active but cookie is missing", () => { + // Arrange + jest.spyOn(dontShowAgain, "getDontShowAgain").mockReturnValueOnce(false); + + const tour = new Tour(); + + // Act + tour.setOptions({ + isActive: true, + dontShowAgain: true, + }); + + // Assert + expect(tour.isActive()).toBeTruthy(); + }); + + test("should be false if dontShowAgain is active but isActive is true", () => { + // Arrange + jest.spyOn(dontShowAgain, "getDontShowAgain").mockReturnValueOnce(true); + const tour = new Tour(); + + // Act + tour.setOptions({ + isActive: true, + dontShowAgain: true, + }); + + // Assert + expect(tour.isActive()).toBeFalsy(); + }); + }); + + describe("hasStarted", () => { + test("should be false if the tour has not started", async () => { + const mockTour = getMockTour(); + mockTour.addSteps(getMockPartialSteps()); + + // Act + expect(mockTour.hasStarted()).toBeFalsy(); + }); + + test("it should be true if the tour has started", async () => { + // Arrange + const mockTour = getMockTour(); + mockTour.addSteps(getMockPartialSteps()); + + // Act + await mockTour.start(); + + // Act + expect(mockTour.hasStarted()).toBeTruthy(); + }); + + test("it should be false if the tour has started and exited", async () => { + // Arrange + const mockTour = getMockTour(); + mockTour.addSteps(getMockPartialSteps()); + + // Act + await mockTour.start(); + await mockTour.exit(); + + // Act + expect(mockTour.hasStarted()).toBeFalsy(); + }); + }); +}); diff --git a/src/packages/tour/tour.ts b/src/packages/tour/tour.ts new file mode 100644 index 000000000..9ccfc1cfe --- /dev/null +++ b/src/packages/tour/tour.ts @@ -0,0 +1,569 @@ +import { nextStep, previousStep, TourStep } from "./steps"; +import { Package } from "../package"; +import { + introAfterChangeCallback, + introBeforeChangeCallback, + introBeforeExitCallback, + introChangeCallback, + introCompleteCallback, + introExitCallback, + introSkipCallback, + introStartCallback, +} from "./callback"; +import { getDefaultTourOptions, TourOptions } from "./option"; +import { setOptions, setOption } from "../../option"; +import { start } from "./start"; +import exitIntro from "./exitIntro"; +import isFunction from "../../util/isFunction"; +import { getDontShowAgain, setDontShowAgain } from "./dontShowAgain"; +import refresh from "./refresh"; +import { getContainerElement } from "../../util/containerElement"; +import DOMEvent from "../../util/DOMEvent"; +import onKeyDown from "./onKeyDown"; +import onResize from "./onResize"; + +/** + * Intro.js Tour class + */ +export class Tour implements Package { + private _steps: TourStep[] = []; + private _currentStep: number = -1; + private _direction: "forward" | "backward"; + private readonly _targetElement: HTMLElement; + private _options: TourOptions; + + private readonly callbacks: { + beforeChange?: introBeforeChangeCallback; + change?: introChangeCallback; + afterChange?: introAfterChangeCallback; + complete?: introCompleteCallback; + start?: introStartCallback; + exit?: introExitCallback; + skip?: introSkipCallback; + beforeExit?: introBeforeExitCallback; + } = {}; + + // Event handlers + private _keyboardNavigationHandler?: (e: KeyboardEvent) => Promise; + private _refreshOnResizeHandler?: (e: Event) => void; + + /** + * Create a new Tour instance + * @param elementOrSelector Optional target element or CSS query to start the Tour on + * @param options Optional Tour options + */ + public constructor( + elementOrSelector?: string | HTMLElement, + options?: Partial + ) { + this._targetElement = getContainerElement(elementOrSelector); + this._options = options + ? setOptions(this._options, options) + : getDefaultTourOptions(); + } + + /** + * Get a specific callback function + * @param callbackName callback name + */ + callback( + callbackName: K + ): (typeof this.callbacks)[K] | undefined { + const callback = this.callbacks[callbackName]; + if (isFunction(callback)) { + return callback; + } + return undefined; + } + + /** + * Go to a specific step of the tour + * @param step step number + */ + async goToStep(step: number) { + // step - 2 because steps starts from zero index and nextStep() increments the step + this.setCurrentStep(step - 2); + await nextStep(this); + return this; + } + + /** + * Go to a specific step of the tour with the explicit [data-step] number + * @param stepNumber [data-step] value of the step + */ + async goToStepNumber(stepNumber: number) { + for (let i = 0; i < this._steps.length; i++) { + const item = this._steps[i]; + + if (item.step === stepNumber) { + // i - 1 because nextStep() increments the step + this.setCurrentStep(i - 1); + break; + } + } + + await nextStep(this); + + return this; + } + + /** + * Add a step to the tour options. + * This method should be used in conjunction with the `start()` method. + * @param step step to add + */ + addStep(step: Partial) { + if (!this._options.steps) { + this._options.steps = []; + } + + this._options.steps.push(step); + + return this; + } + + /** + * Add multiple steps to the tour options. + * This method should be used in conjunction with the `start()` method. + * @param steps steps to add + */ + addSteps(steps: Partial[]) { + if (!steps.length) return this; + + for (const step of steps) { + this.addStep(step); + } + + return this; + } + + /** + * Set the steps of the tour + * @param steps steps to set + */ + setSteps(steps: TourStep[]): this { + this._steps = steps; + return this; + } + + /** + * Get all available steps of the tour + */ + getSteps(): TourStep[] { + return this._steps; + } + + /** + * Get a specific step of the tour + * @param {number} step step number + */ + getStep(step: number): TourStep { + return this._steps[step]; + } + + /** + * Get the current step of the tour + */ + getCurrentStep(): number { + return this._currentStep; + } + + /** + * @deprecated `currentStep()` is deprecated, please use `getCurrentStep()` instead. + */ + currentStep(): number { + return this._currentStep; + } + + /** + * Set the current step of the tour and the direction of the tour + * @param step + */ + setCurrentStep(step: number): this { + if (step >= this._currentStep) { + this._direction = "forward"; + } else { + this._direction = "backward"; + } + + this._currentStep = step; + return this; + } + + /** + * Increment the current step of the tour (does not start the tour step, must be called in conjunction with `nextStep`) + */ + incrementCurrentStep(): this { + if (this.getCurrentStep() === -1) { + this.setCurrentStep(0); + } else { + this.setCurrentStep(this.getCurrentStep() + 1); + } + + return this; + } + + /** + * Decrement the current step of the tour (does not start the tour step, must be in conjunction with `previousStep`) + */ + decrementCurrentStep(): this { + if (this.getCurrentStep() > 0) { + this.setCurrentStep(this._currentStep - 1); + } + + return this; + } + + /** + * Get the direction of the tour (forward or backward) + */ + getDirection() { + return this._direction; + } + + /** + * Go to the next step of the tour + */ + async nextStep() { + await nextStep(this); + return this; + } + + /** + * Go to the previous step of the tour + */ + async previousStep() { + await previousStep(this); + return this; + } + + /** + * Check if the current step is the last step + */ + isEnd(): boolean { + return this.getCurrentStep() >= this._steps.length; + } + + /** + * Check if the current step is the last step of the tour + */ + isLastStep(): boolean { + return this.getCurrentStep() === this._steps.length - 1; + } + + /** + * Get the target element of the tour + */ + getTargetElement(): HTMLElement { + return this._targetElement; + } + + /** + * Set the options for the tour + * @param partialOptions key/value pair of options + */ + setOptions(partialOptions: Partial): this { + this._options = setOptions(this._options, partialOptions); + return this; + } + + /** + * Set a specific option for the tour + * @param key option key + * @param value option value + */ + setOption(key: K, value: TourOptions[K]): this { + this._options = setOption(this._options, key, value); + return this; + } + + /** + * Get a specific option for the tour + * @param key option key + */ + getOption(key: K): TourOptions[K] { + return this._options[key]; + } + + /** + * Clone the current tour instance + */ + clone(): ThisType { + return new Tour(this._targetElement, this._options); + } + + /** + * Returns true if the tour instance is active + */ + isActive(): boolean { + if ( + this.getOption("dontShowAgain") && + getDontShowAgain(this.getOption("dontShowAgainCookie")) + ) { + return false; + } + + return this.getOption("isActive"); + } + + /** + * Returns true if the tour has started + */ + hasStarted(): boolean { + return this.getCurrentStep() > -1; + } + + /** + * Set the `dontShowAgain` option for the tour so that the tour does not show twice to the same user + * This is a persistent option that is stored in the browser's cookies + * + * @param dontShowAgain boolean value to set the `dontShowAgain` option + */ + setDontShowAgain(dontShowAgain: boolean) { + setDontShowAgain( + dontShowAgain, + this.getOption("dontShowAgainCookie"), + this.getOption("dontShowAgainCookieDays") + ); + return this; + } + + /** + * Enable keyboard navigation for the tour + */ + enableKeyboardNavigation() { + if (this.getOption("keyboardNavigation")) { + this._keyboardNavigationHandler = (e: KeyboardEvent) => + onKeyDown(this, e); + DOMEvent.on(window, "keydown", this._keyboardNavigationHandler, true); + } + + return this; + } + + /** + * Disable keyboard navigation for the tour + */ + disableKeyboardNavigation() { + if (this._keyboardNavigationHandler) { + DOMEvent.off(window, "keydown", this._keyboardNavigationHandler, true); + this._keyboardNavigationHandler = undefined; + } + + return this; + } + + /** + * Enable refresh on window resize for the tour + */ + enableRefreshOnResize() { + this._refreshOnResizeHandler = (_: Event) => onResize(this); + DOMEvent.on(window, "resize", this._refreshOnResizeHandler, true); + } + + /** + * Disable refresh on window resize for the tour + */ + disableRefreshOnResize() { + if (this._refreshOnResizeHandler) { + DOMEvent.off(window, "resize", this._refreshOnResizeHandler, true); + this._refreshOnResizeHandler = undefined; + } + } + + /** + * Starts the tour and shows the first step + */ + async start() { + if (await start(this)) { + this.enableKeyboardNavigation(); + this.enableRefreshOnResize(); + } + + return this; + } + + /** + * Exit the tour + * @param {boolean} force whether to force exit the tour + */ + async exit(force?: boolean) { + if (await exitIntro(this, force ?? false)) { + this.disableKeyboardNavigation(); + this.disableRefreshOnResize(); + } + + return this; + } + + /** + * Refresh the tour + * @param {boolean} refreshSteps whether to refresh the tour steps + */ + refresh(refreshSteps?: boolean) { + refresh(this, refreshSteps); + return this; + } + + /** + * @deprecated onbeforechange is deprecated, please use onBeforeChange instead. + */ + onbeforechange(callback: introBeforeChangeCallback) { + return this.onBeforeChange(callback); + } + + /** + * Add a callback to be called before the tour changes steps + * @param {Function} callback callback function to be called + */ + onBeforeChange(callback: introBeforeChangeCallback) { + if (!isFunction(callback)) { + throw new Error( + "Provided callback for onBeforeChange was not a function" + ); + } + + this.callbacks.beforeChange = callback; + return this; + } + + /** + * @deprecated onchange is deprecated, please use onChange instead. + */ + onchange(callback: introChangeCallback) { + this.onChange(callback); + } + + /** + * Add a callback to be called when the tour changes steps + * @param {Function} callback callback function to be called + */ + onChange(callback: introChangeCallback) { + if (!isFunction(callback)) { + throw new Error("Provided callback for onChange was not a function."); + } + + this.callbacks.change = callback; + return this; + } + + /** + * @deprecated onafterchange is deprecated, please use onAfterChange instead. + */ + onafterchange(callback: introAfterChangeCallback) { + this.onAfterChange(callback); + } + + /** + * Add a callback to be called after the tour changes steps + * @param {Function} callback callback function to be called + */ + onAfterChange(callback: introAfterChangeCallback) { + if (!isFunction(callback)) { + throw new Error("Provided callback for onAfterChange was not a function"); + } + + this.callbacks.afterChange = callback; + return this; + } + + /** + * @deprecated oncomplete is deprecated, please use onComplete instead. + */ + oncomplete(callback: introCompleteCallback) { + return this.onComplete(callback); + } + + /** + * Add a callback to be called when the tour is completed + * @param {Function} callback callback function to be called + */ + onComplete(callback: introCompleteCallback) { + if (!isFunction(callback)) { + throw new Error("Provided callback for oncomplete was not a function."); + } + + this.callbacks.complete = callback; + return this; + } + + /** + * @deprecated onstart is deprecated, please use onStart instead. + */ + onstart(callback: introStartCallback) { + return this.onStart(callback); + } + + /** + * Add a callback to be called when the tour is started + * @param {Function} callback callback function to be called + */ + onStart(callback: introStartCallback) { + if (!isFunction(callback)) { + throw new Error("Provided callback for onstart was not a function."); + } + + this.callbacks.start = callback; + return this; + } + + /** + * @deprecated onexit is deprecated, please use onExit instead. + */ + onexit(callback: introExitCallback) { + return this.onExit(callback); + } + + /** + * Add a callback to be called when the tour is exited + * @param {Function} callback callback function to be called + */ + onExit(callback: introExitCallback) { + if (!isFunction(callback)) { + throw new Error("Provided callback for onexit was not a function."); + } + + this.callbacks.exit = callback; + return this; + } + + /** + * @deprecated onskip is deprecated, please use onSkip instead. + */ + onskip(callback: introSkipCallback) { + return this.onSkip(callback); + } + + /** + * Add a callback to be called when the tour is skipped + * @param {Function} callback callback function to be called + */ + onSkip(callback: introSkipCallback) { + if (!isFunction(callback)) { + throw new Error("Provided callback for onskip was not a function."); + } + + this.callbacks.skip = callback; + return this; + } + + /** + * @deprecated onbeforeexit is deprecated, please use onBeforeExit instead. + */ + onbeforeexit(callback: introBeforeExitCallback) { + return this.onBeforeExit(callback); + } + + /** + * Add a callback to be called before the tour is exited + * @param {Function} callback callback function to be called + */ + onBeforeExit(callback: introBeforeExitCallback) { + if (!isFunction(callback)) { + throw new Error("Provided callback for onbeforeexit was not a function."); + } + + this.callbacks.beforeExit = callback; + return this; + } +} diff --git a/src/util/DOMEvent.ts b/src/util/DOMEvent.ts new file mode 100644 index 000000000..3a58c6c39 --- /dev/null +++ b/src/util/DOMEvent.ts @@ -0,0 +1,53 @@ +/** + * DOMEvent Handles all DOM events + * + * methods: + * + * on - add event handler + * off - remove event + */ + +interface Events { + keydown: KeyboardEvent; + resize: Event; + scroll: Event; + click: MouseEvent; +} + +type Listener = (e: T) => void | undefined | string | Promise; + +class DOMEvent { + /** + * Adds event listener + */ + public on( + obj: EventTarget, + type: T, + listener: Listener, + useCapture: boolean + ) { + if ("addEventListener" in obj) { + obj.addEventListener(type, listener, useCapture); + } else if ("attachEvent" in obj) { + (obj as any).attachEvent(`on${type}`, listener); + } + } + + /** + * Removes event listener + */ + public off( + obj: EventTarget, + type: T, + listener: Listener, + useCapture: boolean + ) { + if ("removeEventListener" in obj) { + obj.removeEventListener(type, listener, useCapture); + } else if ("detachEvent" in obj) { + (obj as any).detachEvent(`on${type}`, listener); + } + } +} + +export default new DOMEvent(); diff --git a/src/util/addClass.ts b/src/util/addClass.ts deleted file mode 100644 index 3a7f044fc..000000000 --- a/src/util/addClass.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Append a class to an element - * @api private - */ -export default function addClass(element: HTMLElement, className: string) { - if (element instanceof SVGElement) { - // svg - const pre = element.getAttribute("class") || ""; - - if (!pre.match(className)) { - // check if element doesn't already have className - element.setAttribute("class", `${pre} ${className}`); - } - } else { - if (element.classList !== undefined) { - // check for modern classList property - const classes = className.split(" "); - for (const cls of classes) { - element.classList.add(cls); - } - } else if (!element.className.match(className)) { - // check if element doesn't already have className - element.className += ` ${className}`; - } - } -} diff --git a/src/util/className.ts b/src/util/className.ts new file mode 100644 index 000000000..db6d4ef1e --- /dev/null +++ b/src/util/className.ts @@ -0,0 +1,74 @@ +/** + * Append CSS classes to an element + * @api private + */ +export const addClass = ( + element: HTMLElement | SVGElement, + ...classNames: string[] +) => { + for (const className of classNames) { + if (element instanceof SVGElement) { + // svg + const pre = element.getAttribute("class") || ""; + + if (!pre.match(className)) { + // check if element doesn't already have className + setClass(element, pre, className); + } + } else { + if (element.classList !== undefined) { + // check for modern classList property + element.classList.add(className); + } else if (!element.className.match(className)) { + // check if element doesn't already have className + setClass(element, element.className, className); + } + } + } +}; + +/** + * Set CSS classes to an element + * @param element element to set class + * @param classNames list of class names + */ +export const setClass = ( + element: HTMLElement | SVGElement, + ...classNames: string[] +) => { + const className = classNames.filter(Boolean).join(" "); + + if (element instanceof SVGElement) { + element.setAttribute("class", className); + } else { + if (element.classList !== undefined) { + element.classList.value = className; + } else { + element.className = className; + } + } +}; + +/** + * Remove a class from an element + * + * @api private + */ +export const removeClass = ( + element: HTMLElement | SVGElement, + classNameRegex: RegExp | string +) => { + if (element instanceof SVGElement) { + const pre = element.getAttribute("class") || ""; + + element.setAttribute( + "class", + pre.replace(classNameRegex, "").replace(/\s\s+/g, " ").trim() + ); + } else { + element.className = element.className + .replace(classNameRegex, "") + .replace(/\s\s+/g, " ") + .trim(); + } +}; diff --git a/src/util/clssName.test.ts b/src/util/clssName.test.ts new file mode 100644 index 000000000..9b695f9d3 --- /dev/null +++ b/src/util/clssName.test.ts @@ -0,0 +1,196 @@ +import { removeClass, addClass, setClass } from "./className"; + +describe("className", () => { + describe("setClass", () => { + it("should set class name to an element", () => { + const el = document.createElement("div"); + el.className = "firstClass"; + + setClass(el, "secondClass"); + + expect(el.className).toBe("secondClass"); + }); + + it("should set class name to an SVG element", () => { + const el = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + el.setAttribute("class", "firstClass"); + + setClass(el, "secondClass"); + + expect(el.getAttribute("class")).toBe("secondClass"); + }); + }); + + describe("addClass", () => { + test("should append when className is empty", () => { + const el = document.createElement("div"); + addClass(el, "myClass"); + expect(el.className).toBe("myClass"); + }); + + test("should append when className is NOT empty", () => { + const el = document.createElement("div"); + el.className = "firstClass"; + + addClass(el, "secondClass"); + + expect(el.className).toBe("firstClass secondClass"); + }); + + test("should not append duplicate classNames to elements", () => { + const el = document.createElement("div"); + el.className = "firstClass"; + + addClass(el, "firstClass"); + + expect(el.className).toBe("firstClass"); + }); + + test("should not append duplicate list of classNames to elements", () => { + const el = document.createElement("div"); + el.className = "firstClass firstClass"; + + addClass(el, "firstClass", "firstClass", "firstClass"); + + expect(el.className).toBe("firstClass"); + }); + + test("should not append duplicate list of classNames to an empty className", () => { + const el = document.createElement("div"); + + addClass(el, "firstClass", "firstClass", "firstClass"); + + expect(el.className).toBe("firstClass"); + }); + + test("should append lassNames to an SVG", () => { + const el = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + el.setAttribute("class", "firstClass"); + + addClass(el, "secondClass", "thirdClass"); + + expect(el.getAttribute("class")).toBe( + "firstClass secondClass thirdClass" + ); + }); + + test("should not append duplicate list of classNames to an empty className of SVG", () => { + const el = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + el.setAttribute("class", "firstClass"); + + addClass(el, "firstClass", "firstClass", "firstClass"); + + expect(el.getAttribute("class")).toBe("firstClass"); + }); + }); + + describe("removeClass", () => { + it("should do nothing when the class name is not found", () => { + // Arrange + const el = document.createElement("div"); + el.className = "firstClass"; + + // Act + removeClass(el, "secondClass"); + + // Assert + expect(el.className).toBe("firstClass"); + }); + + it("should remove middle class name from an element", () => { + // Arrange + const el = document.createElement("div"); + el.className = "firstClass secondClass thirdClass"; + + // Act + removeClass(el, "secondClass"); + + // Assert + expect(el.className).toBe("firstClass thirdClass"); + }); + + it("should remove the first class name from an element", () => { + // Arrange + const el = document.createElement("div"); + el.className = "firstClass secondClass thirdClass"; + + // Act + removeClass(el, "firstClass"); + + // Assert + expect(el.className).toBe("secondClass thirdClass"); + }); + + it("should remove the last class name from an element", () => { + // Arrange + const el = document.createElement("div"); + el.className = "firstClass secondClass thirdClass"; + + // Act + removeClass(el, "thirdClass"); + + // Assert + expect(el.className).toBe("firstClass secondClass"); + }); + + it("should remove the only class name from an element", () => { + // Arrange + const el = document.createElement("div"); + el.className = "secondClass"; + + // Act + removeClass(el, "secondClass"); + + // Assert + expect(el.className).toBe(""); + }); + + it("should remove the first class name from an SVG element", () => { + // Arrange + const el = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + el.setAttribute("class", "firstClass secondClass thirdClass"); + + // Act + removeClass(el, "firstClass"); + + // Assert + expect(el.getAttribute("class")).toBe("secondClass thirdClass"); + }); + + it("should remove middle class name from an SVG element", () => { + // Arrange + const el = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + el.setAttribute("class", "firstClass secondClass thirdClass"); + + // Act + removeClass(el, "secondClass"); + + // Assert + expect(el.getAttribute("class")).toBe("firstClass thirdClass"); + }); + + it("should remove the last class name from an SVG element", () => { + // Arrange + const el = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + el.setAttribute("class", "firstClass secondClass"); + + // Act + removeClass(el, "secondClass"); + + // Assert + expect(el.getAttribute("class")).toBe("firstClass"); + }); + + it("should remove the only class name from an SVG element", () => { + // Arrange + const el = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + el.setAttribute("class", "secondClass"); + + // Act + removeClass(el, "secondClass"); + + // Assert + expect(el.getAttribute("class")).toBe(""); + }); + }); +}); diff --git a/src/util/containerElement.test.ts b/src/util/containerElement.test.ts new file mode 100644 index 000000000..049900901 --- /dev/null +++ b/src/util/containerElement.test.ts @@ -0,0 +1,49 @@ +import { getContainerElement } from "./containerElement"; +import * as queryElement from "./queryElement"; + +describe("containerElement", () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe("getContainerElement", () => { + it("should return document.body when arg is undefined", () => { + // Arrange, Act + const container = getContainerElement(undefined); + + // Assert + expect(container).toBe(document.body); + }); + + it("should return HTMLElement when arg is an element", () => { + // Arrange + const stubElement = document.createElement("div"); + + // Act + const container = getContainerElement(stubElement); + + // Assert + expect(container).toBe(stubElement); + }); + + it("should query element when arg is a string", () => { + // Arrange + const stubElement = document.createElement("div"); + jest.spyOn(queryElement, "getElement").mockReturnValue(stubElement); + + // Act + const container = getContainerElement("div"); + + // Assert + expect(container).toBe(stubElement); + }); + + it("should throw exception when selector is not found", () => { + // Arrange + jest.spyOn(queryElement, "queryElement").mockReturnValue(null); + + // Act, Assert + expect(() => getContainerElement("div")).toThrow(); + }); + }); +}); diff --git a/src/util/containerElement.ts b/src/util/containerElement.ts new file mode 100644 index 000000000..e67a2e0fb --- /dev/null +++ b/src/util/containerElement.ts @@ -0,0 +1,18 @@ +import { getElement } from "./queryElement"; + +/** + * Given an element or a selector, tries to find the appropriate container element. + */ +export const getContainerElement = ( + elementOrSelector?: string | HTMLElement +) => { + if (!elementOrSelector) { + return document.body; + } + + if (typeof elementOrSelector === "string") { + return getElement(elementOrSelector); + } + + return elementOrSelector; +}; diff --git a/tests/jest/util/cookie.test.ts b/src/util/cookie.test.ts similarity index 89% rename from tests/jest/util/cookie.test.ts rename to src/util/cookie.test.ts index 015b18ad0..9c33f8cc6 100644 --- a/tests/jest/util/cookie.test.ts +++ b/src/util/cookie.test.ts @@ -1,9 +1,4 @@ -import { - getAllCookies, - getCookie, - setCookie, - deleteCookie, -} from "../../../src/util/cookie"; +import { getAllCookies, getCookie, setCookie, deleteCookie } from "./cookie"; describe("cookie", () => { let _cookie = ""; diff --git a/tests/jest/util/createElement.test.ts b/src/util/createElement.test.ts similarity index 94% rename from tests/jest/util/createElement.test.ts rename to src/util/createElement.test.ts index 15f1f1f7e..b03cd5d0e 100644 --- a/tests/jest/util/createElement.test.ts +++ b/src/util/createElement.test.ts @@ -1,4 +1,4 @@ -import createElement from "../../../src/util/createElement"; +import createElement from "./createElement"; describe("createElement", () => { test("should create an element", () => { diff --git a/tests/jest/util/elementInViewport.test.ts b/src/util/elementInViewport.test.ts similarity index 95% rename from tests/jest/util/elementInViewport.test.ts rename to src/util/elementInViewport.test.ts index 13b5f3ad7..b2d26a406 100644 --- a/tests/jest/util/elementInViewport.test.ts +++ b/src/util/elementInViewport.test.ts @@ -1,5 +1,5 @@ -import _createElement from "../../../src/util/createElement"; -import elementInViewport from "../../../src/util/elementInViewport"; +import _createElement from "./createElement"; +import elementInViewport from "./elementInViewport"; describe("elementInViewport", () => { test("should return true when element is in viewport", () => { diff --git a/tests/jest/util/isFunction.test.ts b/src/util/isFunction.test.ts similarity index 91% rename from tests/jest/util/isFunction.test.ts rename to src/util/isFunction.test.ts index a0b28919b..d1ee1cbb1 100644 --- a/tests/jest/util/isFunction.test.ts +++ b/src/util/isFunction.test.ts @@ -1,4 +1,4 @@ -import isFunction from "../../../src/util/isFunction"; +import isFunction from "./isFunction"; describe("isFunction", () => { it("should return false when string is passed", () => { diff --git a/src/util/queryElement.ts b/src/util/queryElement.ts new file mode 100644 index 000000000..cdae51baa --- /dev/null +++ b/src/util/queryElement.ts @@ -0,0 +1,51 @@ +export const queryElement = ( + selector: string, + container?: HTMLElement | null +): HTMLElement | null => { + return (container ?? document).querySelector(selector); +}; + +export const queryElements = ( + selector: string, + container?: HTMLElement | null +): NodeListOf => { + return (container ?? document).querySelectorAll(selector); +}; + +export const queryElementByClassName = ( + className: string, + container?: HTMLElement | null +): HTMLElement | null => { + return queryElement(`.${className}`, container); +}; + +export const queryElementsByClassName = ( + className: string, + container?: HTMLElement | null +): NodeListOf => { + return queryElements(`.${className}`, container); +}; + +export const getElementByClassName = ( + className: string, + container?: HTMLElement | null +): HTMLElement => { + const element = queryElementByClassName(className, container); + if (!element) { + throw new Error(`Element with class name ${className} not found`); + } + return element; +}; + +export const getElement = ( + selector: string, + container?: HTMLElement | null +) => { + const element = queryElement(selector, container); + + if (!element) { + throw new Error(`Element with selector ${selector} not found`); + } + + return element; +}; diff --git a/src/util/removeChild.ts b/src/util/removeChild.ts index 582e75931..d9355551f 100644 --- a/src/util/removeChild.ts +++ b/src/util/removeChild.ts @@ -3,29 +3,31 @@ import setStyle from "./setStyle"; /** * Removes `element` from `parentElement` */ -export default function removeChild( - element: HTMLElement | null, - animate = false -) { +export const removeChild = (element: HTMLElement | null) => { if (!element || !element.parentElement) return; - const parentElement = element.parentElement; + element.parentElement.removeChild(element); +}; - if (animate) { - setStyle(element, { - opacity: "0", - }); +export const removeAnimatedChild = async (element: HTMLElement | null) => { + if (!element) return; - window.setTimeout(() => { + setStyle(element, { + opacity: "0", + }); + + return new Promise((resolve) => { + setTimeout(() => { try { // removeChild(..) throws an exception if the child has already been removed (https://developer.mozilla.org/en-US/docs/Web/API/Node/removeChild) // this try-catch is added to make sure this function doesn't throw an exception if the child has been removed // this scenario can happen when start()/exit() is called multiple times and the helperLayer is removed by the // previous exit() call (note: this is a timeout) - parentElement.removeChild(element); - } catch (e) {} + removeChild(element); + } catch (e) { + } finally { + resolve(); + } }, 500); - } else { - parentElement.removeChild(element); - } -} + }); +}; diff --git a/src/util/removeClass.ts b/src/util/removeClass.ts index 361b4183a..e69de29bb 100644 --- a/src/util/removeClass.ts +++ b/src/util/removeClass.ts @@ -1,22 +0,0 @@ -/** - * Remove a class from an element - * - * @api private - */ -export default function removeClass( - element: HTMLElement, - classNameRegex: RegExp | string -) { - if (element instanceof SVGElement) { - const pre = element.getAttribute("class") || ""; - - element.setAttribute( - "class", - pre.replace(classNameRegex, "").replace(/^\s+|\s+$/g, "") - ); - } else { - element.className = element.className - .replace(classNameRegex, "") - .replace(/^\s+|\s+$/g, ""); - } -} diff --git a/src/util/scrollTo.ts b/src/util/scrollTo.ts index 64a4a91e1..4b67864f7 100644 --- a/src/util/scrollTo.ts +++ b/src/util/scrollTo.ts @@ -1,6 +1,6 @@ import elementInViewport from "./elementInViewport"; import getWindowSize from "./getWindowSize"; -import { ScrollTo } from "../core/steps"; +import { ScrollTo } from "../packages/tour/steps"; /** * To change the scroll of `window` after highlighting an element diff --git a/src/util/setPositionRelativeTo.test.ts b/src/util/setPositionRelativeTo.test.ts new file mode 100644 index 000000000..2392f6fab --- /dev/null +++ b/src/util/setPositionRelativeTo.test.ts @@ -0,0 +1,81 @@ +import { setPositionRelativeTo } from "./setPositionRelativeTo"; +import createElement from "./createElement"; +import { getBoundingClientRectSpy } from "../../tests/jest/helper"; + +describe("setPositionRelativeTo", () => { + it("should return if helperLayer or currentStep is null", () => { + // Act + const result = setPositionRelativeTo( + null as unknown as HTMLElement, + null as unknown as HTMLElement, + null as unknown as HTMLElement, + 10 + ); + + // Assert + expect(result).toBeUndefined(); + }); + + it("should set the correct width, height, top, left", () => { + // Arrange + const stepElement = createElement("div"); + stepElement.getBoundingClientRect = getBoundingClientRectSpy( + 200, + 100, + 10, + 50, + 100, + 100 + ); + + const helperLayer = createElement("div"); + helperLayer.getBoundingClientRect = getBoundingClientRectSpy( + 500, + 500, + 5, + 10, + 15, + 20 + ); + + // Act + setPositionRelativeTo(document.body, helperLayer, stepElement, 10); + + // Assert + expect(helperLayer.style.width).toBe("210px"); + expect(helperLayer.style.height).toBe("110px"); + expect(helperLayer.style.top).toBe("5px"); + expect(helperLayer.style.left).toBe("45px"); + }); + + it("should add fixedTooltip if element is fixed", () => { + // Arrange + const stepElementParent = createElement("div"); + const stepElement = createElement("div"); + stepElement.style.position = "fixed"; + stepElementParent.appendChild(stepElement); + + const helperLayer = createElement("div"); + + // Act + setPositionRelativeTo(stepElementParent, helperLayer, stepElement, 10); + + // Assert + expect(helperLayer.className).toBe("introjs-fixedTooltip"); + }); + + it("should remove the fixedTooltip className if element is not fixed", () => { + // Arrange + const stepElement = createElement("div"); + stepElement.style.position = "absolute"; + + const helperLayer = createElement("div"); + helperLayer.className = "introjs-fixedTooltip"; + + // Act + setPositionRelativeTo(document.body, helperLayer, stepElement, 10); + + // Assert + expect(helperLayer.className).not.toBe("introjs-fixedTooltip"); + }); +}); diff --git a/src/util/setPositionRelativeTo.ts b/src/util/setPositionRelativeTo.ts new file mode 100644 index 000000000..2db5bda06 --- /dev/null +++ b/src/util/setPositionRelativeTo.ts @@ -0,0 +1,38 @@ +import getOffset from "./getOffset"; +import isFixed from "./isFixed"; +import { removeClass, addClass } from "./className"; +import setStyle from "./setStyle"; + +/** + * Sets the position of the element relative to the target element + * @api private + */ +export const setPositionRelativeTo = ( + relativeElement: HTMLElement, + element: HTMLElement, + targetElement: HTMLElement, + padding: number +) => { + if (!element || !relativeElement || !targetElement) { + return; + } + + // If the target element is fixed, the tooltip should be fixed as well. + // Otherwise, remove a fixed class that may be left over from the previous + // step. + if (targetElement instanceof Element && isFixed(targetElement)) { + addClass(element, "introjs-fixedTooltip"); + } else { + removeClass(element, "introjs-fixedTooltip"); + } + + const position = getOffset(targetElement, relativeElement); + + //set new position to helper layer + setStyle(element, { + width: `${position.width + padding}px`, + height: `${position.height + padding}px`, + top: `${position.top - padding / 2}px`, + left: `${position.left - padding / 2}px`, + }); +}; diff --git a/src/util/setShowElement.ts b/src/util/setShowElement.ts deleted file mode 100644 index d2ce7c73e..000000000 --- a/src/util/setShowElement.ts +++ /dev/null @@ -1,23 +0,0 @@ -import addClass from "./addClass"; -import getPropValue from "./getPropValue"; - -/** - * To set the show element - * This function set a relative (in most cases) position and changes the z-index - * - * @api private - */ -export default function setShowElement(targetElement: HTMLElement) { - addClass(targetElement, "introjs-showElement"); - - const currentElementPosition = getPropValue(targetElement, "position"); - if ( - currentElementPosition !== "absolute" && - currentElementPosition !== "relative" && - currentElementPosition !== "sticky" && - currentElementPosition !== "fixed" - ) { - //change to new intro item - addClass(targetElement, "introjs-relativePosition"); - } -} diff --git a/tests/jest/util/setStyle.test.ts b/src/util/setStyle.test.ts similarity index 96% rename from tests/jest/util/setStyle.test.ts rename to src/util/setStyle.test.ts index 1d62a651b..f143360d0 100644 --- a/tests/jest/util/setStyle.test.ts +++ b/src/util/setStyle.test.ts @@ -1,4 +1,4 @@ -import setStyle from "../../../src/util/setStyle"; +import setStyle from "./setStyle"; describe("setStyle", () => { test("should set style when the list is empty", () => { diff --git a/src/util/stamp.ts b/src/util/stamp.ts deleted file mode 100644 index c7900f90a..000000000 --- a/src/util/stamp.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Mark any object with an incrementing number - * used for keeping track of objects - * - * @param Object obj Any object or DOM Element - * @param String key - * @return Object - */ -const stamp = (() => { - const keys: { - [key: string]: number; - } = {}; - return function stamp(obj: T, key = "introjs-stamp"): number { - // each group increments from 0 - keys[key] = keys[key] || 0; - - // stamp only once per object - // @ts-ignore - if (obj[key] === undefined) { - // increment key for each new object - // @ts-ignore - obj[key] = keys[key]++; - } - - // @ts-ignore - return obj[key]; - }; -})(); - -export default stamp; diff --git a/tests/cypress.config.js b/tests/cypress.config.js deleted file mode 100644 index 4e81f9b4e..000000000 --- a/tests/cypress.config.js +++ /dev/null @@ -1,15 +0,0 @@ -const { defineConfig } = require("cypress"); -const getCompareSnapshotsPlugin = require("cypress-visual-regression/dist/plugin"); - -module.exports = defineConfig({ - screenshotsFolder: "./cypress/snapshots/actual", - trashAssetsBeforeRuns: true, - env: { - failSilently: false, - }, - e2e: { - setupNodeEvents(on, config) { - getCompareSnapshotsPlugin(on, config); - }, - }, -}); diff --git a/tests/cypress/fixtures/example.json b/tests/cypress/fixtures/example.json deleted file mode 100644 index 02e425437..000000000 --- a/tests/cypress/fixtures/example.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "Using fixtures to represent data", - "email": "hello@cypress.io", - "body": "Fixtures are a great way to mock data for responses to routes" -} diff --git a/tests/cypress/plugins/index.js b/tests/cypress/plugins/index.js deleted file mode 100644 index 21b138cc4..000000000 --- a/tests/cypress/plugins/index.js +++ /dev/null @@ -1,5 +0,0 @@ -const getCompareSnapshotsPlugin = require("cypress-visual-regression/dist/plugin"); - -module.exports = (on, config) => { - getCompareSnapshotsPlugin(on, config); -}; diff --git a/tests/cypress/support/commands.js b/tests/cypress/support/commands.js deleted file mode 100644 index 9c5400c21..000000000 --- a/tests/cypress/support/commands.js +++ /dev/null @@ -1,13 +0,0 @@ -const compareSnapshotCommand = require("cypress-visual-regression/dist/command"); - -compareSnapshotCommand({ - capture: "fullPage", -}); - -Cypress.Commands.add("nextStep", () => { - cy.get(".introjs-nextbutton").click(); -}); - -Cypress.Commands.add("prevStep", () => { - cy.get(".introjs-prevbutton").click(); -}); diff --git a/tests/cypress/support/e2e.js b/tests/cypress/support/e2e.js deleted file mode 100644 index 08a80aeb1..000000000 --- a/tests/cypress/support/e2e.js +++ /dev/null @@ -1,10 +0,0 @@ -import "./commands"; - -Cypress.on("window:before:load", (win) => { - const htmlNode = win.document.querySelector("html"); - const node = win.document.createElement("style"); - node.innerHTML = "html { scroll-behavior: inherit !important; }"; - htmlNode.appendChild(node); -}); - -import "cypress-real-events/support"; diff --git a/tests/jest/core/exitIntro.test.ts b/tests/jest/core/exitIntro.test.ts deleted file mode 100644 index 3bfa99c3e..000000000 --- a/tests/jest/core/exitIntro.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -import introJs from "../../../src/index"; - -describe("exitIntro", () => { - test("should reset the _currentStep", () => { - const intro = introJs(); - intro - .setOptions({ - steps: [ - { - intro: "step one", - element: document.querySelector("h1"), - }, - ], - }) - .start(); - - intro.exit(false); - - expect(intro._currentStep).toBe(-1); - }); - - test("should call the onexit and onbeforeexit callbacks", async () => { - const fnOnExit = jest.fn(); - const fnOnBeforeExit = jest.fn(); - fnOnBeforeExit.mockReturnValue(true); - - const intro = introJs(document.body); - intro - .setOptions({ - steps: [ - { - intro: "step one", - element: document.querySelector("h1") as HTMLElement, - }, - ], - }) - .onexit(fnOnExit) - .onbeforeexit(fnOnBeforeExit); - - await intro.start(); - await intro.exit(false); - - expect(fnOnExit).toBeCalledTimes(1); - - expect(fnOnBeforeExit).toBeCalledTimes(1); - expect(fnOnBeforeExit).toHaveBeenCalledWith(document.body); - expect(fnOnBeforeExit).toHaveBeenCalledBefore(fnOnExit); - }); - - test("should not continue when onbeforeexit returns false", async () => { - const fnOnExit = jest.fn(); - const fnOnBeforeExit = jest.fn(); - fnOnBeforeExit.mockReturnValue(false); - - const intro = introJs(document.body); - intro - .setOptions({ - steps: [ - { - intro: "step one", - element: document.querySelector("h1"), - }, - ], - }) - .onexit(fnOnExit) - .onbeforeexit(fnOnBeforeExit); - - await intro.start(); - await intro.exit(false); - - expect(fnOnExit).toBeCalledTimes(0); - - expect(fnOnBeforeExit).toBeCalledTimes(1); - expect(fnOnBeforeExit).toHaveBeenCalledWith(document.body); - }); - - test("should not continue when exit force is true", async () => { - const fnOnExit = jest.fn(); - const fnOnBeforeExit = jest.fn(); - fnOnBeforeExit.mockReturnValue(false); - - const intro = introJs(document.body); - intro - .setOptions({ - steps: [ - { - intro: "step one", - element: document.querySelector("h1"), - }, - ], - }) - .onexit(fnOnExit) - .onbeforeexit(fnOnBeforeExit); - - await intro.start(); - await intro.exit(false); - - expect(fnOnExit).toBeCalledTimes(0); - expect(fnOnBeforeExit).toBeCalledTimes(1); - }); - - test("should continue when exit force is true and beforeExit callback returns false", async () => { - const fnOnExit = jest.fn(); - const fnOnBeforeExit = jest.fn(); - fnOnBeforeExit.mockReturnValue(false); - - const intro = introJs(document.body); - intro - .setOptions({ - steps: [ - { - intro: "step one", - element: document.querySelector("h1") as HTMLElement, - }, - ], - }) - .onexit(fnOnExit) - .onbeforeexit(fnOnBeforeExit); - - await intro.start(); - await intro.exit(true); - - expect(fnOnExit).toBeCalledTimes(1); - expect(fnOnBeforeExit).toBeCalledTimes(1); - }); -}); diff --git a/tests/jest/core/fetchIntroSteps.test.ts b/tests/jest/core/fetchIntroSteps.test.ts deleted file mode 100644 index 691a1f4f7..000000000 --- a/tests/jest/core/fetchIntroSteps.test.ts +++ /dev/null @@ -1,247 +0,0 @@ -import { IntroJs } from "../../../src/intro"; -import fetchIntroSteps from "../../../src/core/fetchIntroSteps"; - -describe("fetchIntroSteps", () => { - test("should add floating element from options.steps to the list", () => { - // Arrange - const targetElement = document.createElement("div"); - - // Act - const steps = fetchIntroSteps( - { - _options: { - steps: [ - { - element: "#element_does_not_exist", - intro: "hello world", - }, - { - intro: "second", - }, - ], - }, - } as IntroJs, - targetElement - ); - - // Assert - expect(steps.length).toBe(2); - - expect(steps[0].position).toBe("floating"); - expect(steps[0].intro).toBe("hello world"); - expect(steps[0].step).toBe(1); - - expect(steps[1].position).toBe("floating"); - expect(steps[1].intro).toBe("second"); - expect(steps[1].step).toBe(2); - }); - - test("should find and add elements from options.steps to the list", () => { - // Arrange - const targetElement = document.createElement("div"); - - const stepOne = document.createElement("div"); - stepOne.setAttribute("id", "first"); - - const stepTwo = document.createElement("div"); - stepTwo.setAttribute("id", "second"); - - document.body.appendChild(stepOne); - document.body.appendChild(stepTwo); - - // Act - const steps = fetchIntroSteps( - { - _options: { - tooltipPosition: "bottom", - steps: [ - { - element: "#first", - intro: "first", - }, - { - element: "#second", - intro: "second", - position: "top", - }, - { - element: "#not_found", - intro: "third", - }, - ], - }, - } as IntroJs, - targetElement - ); - - // Assert - expect(steps.length).toBe(3); - - expect(steps[0].element).toBe(stepOne); - expect(steps[0].position).toBe("bottom"); - expect(steps[0].intro).toBe("first"); - expect(steps[0].step).toBe(1); - - expect(steps[1].element).toBe(stepTwo); - expect(steps[1].position).toBe("top"); - expect(steps[1].intro).toBe("second"); - expect(steps[1].step).toBe(2); - - expect(steps[2].position).toBe("floating"); - expect(steps[2].intro).toBe("third"); - expect(steps[2].step).toBe(3); - }); - - test("should find the data-* elements from the DOM", () => { - // Arrange - const targetElement = document.createElement("div"); - - const stepOne = document.createElement("div"); - stepOne.setAttribute("data-intro", "first"); - - const stepTwo = document.createElement("div"); - stepTwo.setAttribute("data-intro", "second"); - stepTwo.setAttribute("data-position", "left"); - - targetElement.appendChild(stepOne); - targetElement.appendChild(stepTwo); - - // Act - const steps = fetchIntroSteps( - { - _options: { - tooltipPosition: "bottom", - }, - } as IntroJs, - targetElement - ); - - // Assert - expect(steps.length).toBe(2); - - expect(steps[0].position).toBe("bottom"); - expect(steps[0].intro).toBe("first"); - expect(steps[0].step).toBe(1); - - expect(steps[1].position).toBe("left"); - expect(steps[1].intro).toBe("second"); - expect(steps[1].step).toBe(2); - }); - - test("should respect the custom step attribute (DOM)", () => { - // Arrange - const targetElement = document.createElement("div"); - - const stepOne = document.createElement("div"); - stepOne.setAttribute("data-intro", "second"); - stepOne.setAttribute("data-step", "5"); - - const stepTwo = document.createElement("div"); - stepTwo.setAttribute("data-intro", "first"); - - targetElement.appendChild(stepOne); - targetElement.appendChild(stepTwo); - - // Act - const steps = fetchIntroSteps( - { - _options: { - tooltipPosition: "bottom", - }, - } as IntroJs, - targetElement - ); - - // Assert - expect(steps.length).toBe(2); - - expect(steps[0].intro).toBe("first"); - expect(steps[0].step).toBe(1); - - expect(steps[1].intro).toBe("second"); - expect(steps[1].step).toBe(5); - }); - - test("should ignore DOM elements when options.steps is available", () => { - // Arrange - const targetElement = document.createElement("div"); - - const stepOne = document.createElement("div"); - stepOne.setAttribute("data-intro", "first"); - - const stepTwo = document.createElement("div"); - stepTwo.setAttribute("data-intro", "second"); - - targetElement.appendChild(stepOne); - targetElement.appendChild(stepTwo); - - // Act - const steps = fetchIntroSteps( - { - _options: { - steps: [ - { - intro: "steps-first", - }, - { - intro: "steps-second", - }, - ], - }, - } as IntroJs, - targetElement - ); - - // Assert - expect(steps.length).toBe(2); - expect(steps[0].intro).toBe("steps-first"); - expect(steps[1].intro).toBe("steps-second"); - }); - - it("should correctly sort based on data-step", () => { - // Arrange - const targetElement = document.createElement("div"); - - const stepOne = document.createElement("div"); - stepOne.setAttribute("data-intro", "one"); - - const stepTwo = document.createElement("div"); - stepTwo.setAttribute("data-intro", "two"); - - const stepThree = document.createElement("div"); - stepThree.setAttribute("data-intro", "three"); - stepThree.setAttribute("data-step", "3"); - - const stepFour = document.createElement("div"); - stepFour.setAttribute("data-intro", "four"); - stepFour.setAttribute("data-step", "5"); - - targetElement.appendChild(stepThree); - targetElement.appendChild(stepOne); - targetElement.appendChild(stepFour); - targetElement.appendChild(stepTwo); - - // Act - const steps = fetchIntroSteps( - { - _options: {}, - } as IntroJs, - targetElement - ); - - // Assert - expect(steps.length).toBe(4); - - expect(steps[0].intro).toBe("one"); - expect(steps[0].step).toBe(1); - - expect(steps[1].intro).toBe("two"); - expect(steps[1].step).toBe(2); - - expect(steps[2].intro).toBe("three"); - expect(steps[2].step).toBe(3); - - expect(steps[3].intro).toBe("four"); - expect(steps[3].step).toBe(5); - }); -}); diff --git a/tests/jest/core/introForElement.test.ts b/tests/jest/core/introForElement.test.ts deleted file mode 100644 index 83ff04b21..000000000 --- a/tests/jest/core/introForElement.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import introForElement from "../../../src/core/introForElement"; -import * as fetchIntroSteps from "../../../src/core/fetchIntroSteps"; -import * as addOverlayLayer from "../../../src/core/addOverlayLayer"; -import * as nextStep from "../../../src/core/steps"; -import introJs from "../../../src"; - -describe("introForElement", () => { - test("should call the onstart callback", () => { - jest.spyOn(fetchIntroSteps, "default").mockReturnValue([]); - jest.spyOn(addOverlayLayer, "default").mockReturnValue(true); - jest.spyOn(nextStep, "nextStep").mockReturnValue(Promise.resolve(true)); - - const onstartCallback = jest.fn(); - - const context = introJs().setOptions({ - isActive: true, - }); - - context._introStartCallback = onstartCallback; - - introForElement(context, document.body); - - expect(onstartCallback).toBeCalledTimes(1); - expect(onstartCallback).toBeCalledWith(document.body); - }); - - test("should not start the tour if isActive is false", () => { - const fetchIntroStepsMock = jest.spyOn(fetchIntroSteps, "default"); - const addOverlayLayerMock = jest.spyOn(addOverlayLayer, "default"); - const nextStepMock = jest.spyOn(nextStep, "nextStep"); - - const context = introJs().setOptions({ - isActive: false, - }); - - introForElement(context, document.body); - - expect(fetchIntroStepsMock).toBeCalledTimes(0); - expect(addOverlayLayerMock).toBeCalledTimes(0); - expect(nextStepMock).toBeCalledTimes(0); - }); -}); diff --git a/tests/jest/core/placeTooltip.test.ts b/tests/jest/core/placeTooltip.test.ts deleted file mode 100644 index 9c381f2a6..000000000 --- a/tests/jest/core/placeTooltip.test.ts +++ /dev/null @@ -1,274 +0,0 @@ -import * as getOffset from "../../../src/util/getOffset"; -import * as getWindowSize from "../../../src/util/getWindowSize"; -import placeTooltip from "../../../src/core/placeTooltip"; -import { IntroJs } from "../../../src/intro"; -import { IntroStep } from "../../../src/core/steps"; - -describe("placeTooltip", () => { - test("should automatically place the tooltip position when there is enough space", () => { - jest.spyOn(getOffset, "default").mockReturnValue({ - height: 100, - width: 100, - top: 0, - left: 0, - }); - - jest.spyOn(getWindowSize, "default").mockReturnValue({ - height: 1000, - width: 1000, - }); - - jest.spyOn(Element.prototype, "getBoundingClientRect").mockReturnValue({ - x: 0, - y: 0, - toJSON: jest.fn, - width: 100, - height: 100, - top: 200, - left: 200, - bottom: 300, - right: 300, - }); - - const currentStep: IntroStep = { - step: 0, - intro: "hello", - title: "hello", - position: "top", - element: document.createElement("div"), - scrollTo: "element", - }; - const tooltipLayer = document.createElement("div"); - const arrowLayer = document.createElement("div"); - - placeTooltip( - { - _currentStep: 0, - _introItems: [currentStep], - _options: { - positionPrecedence: ["top", "bottom", "left", "right"], - autoPosition: true, - }, - } as IntroJs, - currentStep, - tooltipLayer, - arrowLayer, - false - ); - - expect(tooltipLayer.className).toBe( - "introjs-tooltip introjs-top-right-aligned" - ); - }); - - test("should skip auto positioning when autoPosition is false", () => { - const currentStep: IntroStep = { - step: 0, - intro: "hello", - title: "hello", - position: "top", - element: document.createElement("div"), - scrollTo: "element", - }; - const tooltipLayer = document.createElement("div"); - const arrowLayer = document.createElement("div"); - - placeTooltip( - { - _currentStep: 0, - _introItems: [ - { - intro: "intro", - position: "top", - }, - ], - _options: { - positionPrecedence: ["top", "bottom"], - autoPosition: false, - }, - } as IntroJs, - currentStep, - tooltipLayer, - arrowLayer, - false - ); - - expect(tooltipLayer.className).toBe("introjs-tooltip introjs-top"); - }); - - test("should use floating tooltips when height/width is limited", () => { - jest.spyOn(getOffset, "default").mockReturnValue({ - height: 100, - width: 100, - top: 0, - left: 0, - }); - - jest.spyOn(getWindowSize, "default").mockReturnValue({ - height: 100, - width: 100, - }); - - jest.spyOn(Element.prototype, "getBoundingClientRect").mockReturnValue({ - x: 0, - y: 0, - toJSON: jest.fn, - width: 100, - height: 100, - top: 0, - left: 0, - bottom: 0, - right: 0, - }); - - const currentStep: IntroStep = { - step: 0, - intro: "hello", - title: "hello", - position: "left", - element: document.createElement("div"), - scrollTo: "element", - }; - const tooltipLayer = document.createElement("div"); - const arrowLayer = document.createElement("div"); - - placeTooltip( - { - _currentStep: 0, - _introItems: [currentStep], - _options: { - positionPrecedence: ["top", "bottom", "left", "right"], - autoPosition: true, - }, - } as IntroJs, - currentStep, - tooltipLayer, - arrowLayer, - false - ); - - expect(tooltipLayer.className).toBe("introjs-tooltip introjs-floating"); - }); - - test("should use bottom middle aligned when there is enough vertical space", () => { - jest.spyOn(getOffset, "default").mockReturnValue({ - height: 100, - width: 100, - top: 0, - left: 0, - }); - - jest.spyOn(getWindowSize, "default").mockReturnValue({ - height: 500, - width: 100, - }); - - jest.spyOn(Element.prototype, "getBoundingClientRect").mockReturnValue({ - x: 0, - y: 0, - toJSON: jest.fn, - width: 100, - height: 100, - top: 0, - left: 0, - bottom: 0, - right: 0, - }); - - const currentStep: IntroStep = { - step: 0, - intro: "hello", - title: "hello", - position: "left", - element: document.createElement("div"), - scrollTo: "element", - }; - const tooltipLayer = document.createElement("div"); - const arrowLayer = document.createElement("div"); - - placeTooltip( - { - _currentStep: 0, - _introItems: [currentStep], - _options: { - positionPrecedence: ["top", "bottom", "left", "right"], - autoPosition: true, - }, - } as IntroJs, - currentStep, - tooltipLayer, - arrowLayer, - false - ); - - expect(tooltipLayer.className).toBe( - "introjs-tooltip introjs-bottom-middle-aligned" - ); - }); - - test("should attach the global custom tooltip css class", () => { - const currentStep: IntroStep = { - step: 0, - intro: "hello", - title: "hello", - position: "left", - element: document.createElement("div"), - scrollTo: "element", - }; - const tooltipLayer = document.createElement("div"); - const arrowLayer = document.createElement("div"); - - placeTooltip( - { - _currentStep: 0, - _introItems: [currentStep], - _options: { - positionPrecedence: ["top", "bottom", "left", "right"], - autoPosition: true, - tooltipClass: "newclass", - }, - } as IntroJs, - currentStep, - tooltipLayer, - arrowLayer, - false - ); - - expect(tooltipLayer.className).toBe( - "introjs-tooltip newclass introjs-bottom-middle-aligned" - ); - }); - - test("should attach the step custom tooltip css class", () => { - const currentStep: IntroStep = { - step: 0, - intro: "hello", - title: "hello", - position: "left", - element: document.createElement("div"), - scrollTo: "element", - tooltipClass: "myclass", - }; - const tooltipLayer = document.createElement("div"); - const arrowLayer = document.createElement("div"); - - placeTooltip( - { - _currentStep: 0, - _introItems: [currentStep], - _options: { - positionPrecedence: ["top", "bottom", "left", "right"], - autoPosition: true, - }, - } as IntroJs, - currentStep, - tooltipLayer, - arrowLayer, - false - ); - - expect(tooltipLayer.className).toBe( - "introjs-tooltip myclass introjs-bottom-middle-aligned" - ); - }); -}); diff --git a/tests/jest/core/refresh.test.ts b/tests/jest/core/refresh.test.ts deleted file mode 100644 index df2dd5195..000000000 --- a/tests/jest/core/refresh.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import * as placeTooltip from "../../../src/core/placeTooltip"; -import introJs from "../../../src"; - -describe("refresh", () => { - test("should refresh the cached intro steps", () => { - jest.spyOn(placeTooltip, "default"); - - const targetElement = document.createElement("div"); - document.body.appendChild(targetElement); - - const instance = introJs(targetElement).setOptions({ - steps: [ - { - intro: "first", - }, - ], - }); - - instance.start(); - - expect(instance._introItems.length).toBe(1); - expect(document.querySelectorAll(".introjs-bullets ul li").length).toBe(1); - - instance - .setOptions({ - steps: [ - { - intro: "first", - }, - { - intro: "second", - }, - ], - }) - .refresh(); - - expect(instance._introItems.length).toBe(1); - expect(instance._introItems[0].intro).toBe("first"); - expect(document.querySelectorAll(".introjs-bullets ul li").length).toBe(1); - - instance - .setOptions({ - steps: [ - { - intro: "first", - }, - { - intro: "second", - }, - ], - }) - .refresh(true); - - expect(instance._introItems.length).toBe(2); - expect(instance._introItems[0].intro).toBe("first"); - expect(instance._introItems[1].intro).toBe("second"); - expect(document.querySelectorAll(".introjs-bullets ul li").length).toBe(2); - }); -}); diff --git a/tests/jest/core/setHelperLayerPosition.test.ts b/tests/jest/core/setHelperLayerPosition.test.ts deleted file mode 100644 index b89988f83..000000000 --- a/tests/jest/core/setHelperLayerPosition.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { IntroJs } from "../../../src/intro"; -import { IntroStep } from "../../../src/core/steps"; -import setHelperLayerPosition from "../../../src/core/setHelperLayerPosition"; -import _createElement from "../../../src/util/createElement"; -import { getBoundingClientRectSpy } from "../helper"; - -describe("setHelperLayerPosition", () => { - let context: IntroJs; - - beforeEach(() => { - context = { - _currentStep: 0, - _introItems: [ - { - intro: "hello", - position: "top", - }, - { - intro: "world", - position: "top", - }, - ], - _targetElement: document.body, - _introBeforeChangeCallback: undefined, - _options: { - helperElementPadding: 10, - }, - } as IntroJs; - }); - - it("should return if helperLayer or currentStep is null", () => { - // Act - const returned = setHelperLayerPosition( - context, - null as unknown as IntroStep, - null as unknown as HTMLElement - ); - - // Assert - expect(returned).toBeUndefined(); - }); - - it("should set the correct width, height, top, left", () => { - // Arrange - const elm = _createElement("div"); - elm.getBoundingClientRect = getBoundingClientRectSpy( - 200, - 100, - 10, - 50, - 100, - 100 - ); - - const step: IntroStep = { - step: 0, - title: "hi", - intro: "hi", - position: "bottom", - scrollTo: "element", - element: elm, - }; - const helperLayer = _createElement("div"); - helperLayer.getBoundingClientRect = getBoundingClientRectSpy( - 500, - 500, - 5, - 10, - 15, - 20 - ); - - // Act - setHelperLayerPosition(context, step, helperLayer); - - // Assert - expect(helperLayer.style.width).toBe("210px"); - expect(helperLayer.style.height).toBe("110px"); - expect(helperLayer.style.top).toBe("5px"); - expect(helperLayer.style.left).toBe("45px"); - }); - - it("should add fixedTooltip if element is fixed", () => { - // Arrange - const elmParent = _createElement("div"); - const elm = _createElement("div"); - elm.style.position = "fixed"; - elmParent.appendChild(elm); - - const step: IntroStep = { - step: 0, - title: "hi", - intro: "hi", - position: "bottom", - scrollTo: "element", - element: elm, - }; - const helperLayer = _createElement("div"); - - // Act - setHelperLayerPosition(context, step, helperLayer); - - // Assert - expect(helperLayer.className).toBe("introjs-fixedTooltip"); - }); - - it("should remove the fixedTooltip className if element is not fixed", () => { - // Arrange - const elm = _createElement("div"); - elm.style.position = "absolute"; - - const step: IntroStep = { - step: 0, - title: "hi", - intro: "hi", - position: "bottom", - scrollTo: "element", - element: elm, - }; - const helperLayer = _createElement("div"); - helperLayer.className = "introjs-fixedTooltip"; - - // Act - setHelperLayerPosition(context, step, helperLayer); - - // Assert - expect(helperLayer.className).not.toBe("introjs-fixedTooltip"); - }); -}); diff --git a/tests/jest/core/steps.test.ts b/tests/jest/core/steps.test.ts deleted file mode 100644 index 4a226b1f1..000000000 --- a/tests/jest/core/steps.test.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { nextStep, previousStep } from "../../../src/core/steps"; -import _showElement from "../../../src/core/showElement"; -import { IntroJs } from "../../../src/intro"; -import introJs from "../../../src"; - -jest.mock("../../../src/core/showElement"); -jest.mock("../../../src/core/exitIntro"); - -describe("steps", () => { - let context: IntroJs; - - beforeEach(() => { - context = { - _currentStep: 0, - _introItems: [ - { - intro: "hello", - position: "top", - }, - { - intro: "world", - position: "top", - }, - ], - _introBeforeChangeCallback: undefined, - } as IntroJs; - }); - - describe("previousStep", () => { - test("should decrement the step counter", async () => { - context._currentStep = 1; - - await previousStep(context); - - expect(context._currentStep).toBe(0); - }); - - test("should not decrement when step is 0", async () => { - expect(context._currentStep).toBe(0); - - await previousStep(context); - - expect(context._currentStep).toBe(0); - }); - }); - - describe("nextStep", () => { - test("should increment the step counter", async () => { - expect(context._currentStep).toBe(0); - - await nextStep(context); - - expect(context._currentStep).toBe(1); - }); - - test("should call ShowElement", async () => { - const showElementMock = jest.fn(); - (_showElement as jest.Mock).mockImplementation(showElementMock); - - await nextStep(context); - - expect(showElementMock).toHaveBeenCalledTimes(1); - }); - - test("should call the onBeforeChange callback", async () => { - const fnBeforeChangeCallback = jest.fn(); - context._introBeforeChangeCallback = fnBeforeChangeCallback; - - await nextStep(context); - - expect(fnBeforeChangeCallback).toHaveBeenCalledTimes(1); - expect(fnBeforeChangeCallback).toHaveBeenCalledWith( - undefined, - 1, - "forward" - ); - }); - - test("should not continue when onBeforeChange return false", async () => { - const showElementMock = jest.fn(); - (_showElement as jest.Mock).mockImplementation(showElementMock); - const fnBeforeChangeCallback = jest.fn(); - fnBeforeChangeCallback.mockReturnValue(false); - - context._introBeforeChangeCallback = fnBeforeChangeCallback; - - await nextStep(context); - - expect(fnBeforeChangeCallback).toHaveBeenCalledTimes(1); - expect(showElementMock).toHaveBeenCalledTimes(0); - }); - - test("should wait for the onBeforeChange promise object", async () => { - const showElementMock = jest.fn(); - (_showElement as jest.Mock).mockImplementation(showElementMock); - - const onBeforeChangeMock = jest.fn(); - const sideEffect: number[] = []; - - context._introBeforeChangeCallback = async () => { - return new Promise((res) => { - setTimeout(() => { - sideEffect.push(1); - onBeforeChangeMock(); - res(true); - }, 50); - }); - }; - - expect(sideEffect).toHaveLength(0); - - await nextStep(context); - - expect(sideEffect).toHaveLength(1); - expect(onBeforeChangeMock).toHaveBeenCalledBefore(showElementMock); - }); - - test("should call the complete callback", async () => { - const fnCompleteCallback = jest.fn(); - context._introCompleteCallback = fnCompleteCallback; - await nextStep(context); - await nextStep(context); - - expect(fnCompleteCallback).toBeCalledTimes(1); - expect(fnCompleteCallback).toHaveBeenCalledWith(2, "end"); - }); - - test("should be able to add steps using addStep()", async () => { - const intro = introJs(); - - intro.addStep({ - element: document.createElement("div"), - intro: "test step", - }); - - await intro.start(); - - expect(intro._introItems).toHaveLength(1); - expect(intro._introItems[0].intro).toBe("test step"); - }); - - test("should be able to add steps using addSteps()", async () => { - const intro = introJs(); - - intro.addSteps([ - { - intro: "first step", - }, - { - element: document.createElement("div"), - intro: "second step", - }, - ]); - - await intro.start(); - - expect(intro._introItems).toHaveLength(2); - expect(intro._introItems[0].intro).toBe("first step"); - expect(intro._introItems[1].intro).toBe("second step"); - }); - }); -}); diff --git a/tests/jest/helper.ts b/tests/jest/helper.ts index dd3f0601a..c996f659e 100644 --- a/tests/jest/helper.ts +++ b/tests/jest/helper.ts @@ -50,20 +50,6 @@ export function tooltipText() { return find(".introjs-tooltiptext"); } -export function appendDummyElement( - name?: string, - text?: string, - style?: string -): HTMLElement { - const el = document.createElement(name || "p"); - el.innerHTML = text || "hello world"; - el.setAttribute("style", style || ""); - - document.body.appendChild(el); - - return el; -} - export function getBoundingClientRectSpy( width: number, height: number, @@ -84,3 +70,6 @@ export function getBoundingClientRectSpy( } as DOMRect) ); } + +export const waitFor = (timeout: number) => + new Promise((resolve) => setTimeout(resolve, timeout)); diff --git a/tests/jest/index.test.ts b/tests/jest/index.test.ts deleted file mode 100644 index 3bffb5539..000000000 --- a/tests/jest/index.test.ts +++ /dev/null @@ -1,354 +0,0 @@ -import introJs from "../../src"; -import { - find, - content, - className, - skipButton, - nextButton, - prevButton, - doneButton, - tooltipText, - appendDummyElement, -} from "./helper"; - -jest.mock("../../src/core/dontShowAgain"); - -import { - getDontShowAgain, - setDontShowAgain, -} from "../../src/core/dontShowAgain"; - -describe("intro", () => { - beforeEach(() => { - document.getElementsByTagName("html")[0].innerHTML = ""; - }); - - test("should set the targetElement to document.body", () => { - const intro = introJs(); - - expect(intro._targetElement).toBe(document.body); - }); - - test("should set the correct targetElement", () => { - const stubTargetElement = document.createElement("div"); - - const intro = introJs(stubTargetElement); - - expect(intro._targetElement).toBe(stubTargetElement); - }); - - test("should start floating intro with one step", () => { - introJs() - .setOptions({ - steps: [ - { - intro: "hello world", - }, - ], - }) - .start(); - - expect(content(tooltipText())).toBe("hello world"); - - expect(content(doneButton())).toBe("Done"); - - expect(prevButton()).toBeNull(); - - expect(className(".introjs-showElement")).toContain( - "introjsFloatingElement" - ); - expect(className(".introjs-showElement")).toContain( - "introjs-relativePosition" - ); - }); - - test("should call onexit and oncomplete when there is one step", async () => { - const onexitMock = jest.fn(); - const oncompleteMMock = jest.fn(); - - introJs() - .setOptions({ - steps: [ - { - intro: "hello world", - }, - ], - }) - .onexit(onexitMock) - .oncomplete(oncompleteMMock) - .start(); - - await nextButton().click(); - - expect(onexitMock).toBeCalledTimes(1); - expect(oncompleteMMock).toBeCalledTimes(1); - }); - - test("should call onexit when skip is clicked", async () => { - const onexitMock = jest.fn(); - const oncompleteMMock = jest.fn(); - - introJs() - .setOptions({ - steps: [ - { - intro: "hello world", - }, - ], - }) - .onexit(onexitMock) - .oncomplete(oncompleteMMock) - .start(); - - await skipButton().click(); - - expect(onexitMock).toBeCalledTimes(1); - expect(oncompleteMMock).toBeCalledTimes(1); - }); - - test("should call not oncomplete when skip is clicked and there are two steps", () => { - const onexitMock = jest.fn(); - const oncompleteMMock = jest.fn(); - - introJs() - .setOptions({ - steps: [ - { - intro: "first", - }, - { - intro: "second", - }, - ], - }) - .onexit(onexitMock) - .oncomplete(oncompleteMMock) - .start(); - - skipButton().click(); - - expect(onexitMock).toBeCalledTimes(1); - expect(oncompleteMMock).toBeCalledTimes(0); - }); - - test("should start floating intro with two steps", () => { - introJs() - .setOptions({ - steps: [ - { - intro: "step one", - }, - { - intro: "step two", - }, - ], - }) - .start(); - - expect(content(tooltipText())).toBe("step one"); - - expect(doneButton()).toBeNull(); - - expect(prevButton()).not.toBeNull(); - expect(className(prevButton())).toContain("introjs-disabled"); - - expect(nextButton()).not.toBeNull(); - expect(className(nextButton())).not.toContain("introjs-disabled"); - - expect(className(".introjs-showElement")).toContain( - "introjsFloatingElement" - ); - expect(className(".introjs-showElement")).toContain( - "introjs-relativePosition" - ); - }); - - test("should highlight the target element", () => { - const p = appendDummyElement(); - - introJs() - .setOptions({ - steps: [ - { - intro: "step one", - element: document.querySelector("p"), - }, - ], - }) - .start(); - - expect(p.className).toContain("introjs-showElement"); - expect(p.className).toContain("introjs-relativePosition"); - }); - - test("should not highlight the target element if queryString is incorrect", () => { - const p = appendDummyElement(); - - introJs() - .setOptions({ - steps: [ - { - intro: "step one", - element: document.querySelector("div"), - }, - ], - }) - .start(); - - expect(p.className).not.toContain("introjs-showElement"); - expect(className(".introjs-showElement")).toContain( - "introjsFloatingElement" - ); - }); - - test("should not add relativePosition if target element is fixed or absolute", () => { - const absolute = appendDummyElement( - "h1", - "hello world", - "position: absolute" - ); - const fixed = appendDummyElement("h2", "hello world", "position: fixed"); - - const intro = introJs(); - intro - .setOptions({ - steps: [ - { - intro: "step one", - element: document.querySelector("h1"), - }, - { - intro: "step two", - element: document.querySelector("h2"), - }, - ], - }) - .start(); - - expect(absolute.className).toContain("introjs-showElement"); - expect(absolute.className).not.toContain("introjs-relativePosition"); - - intro.nextStep(); - - expect(fixed.className).toContain("introjs-showElement"); - expect(fixed.className).not.toContain("introjs-relativePosition"); - }); - - test("should call the onstart callback", () => { - const fn = jest.fn(); - - const intro = introJs(); - intro - .setOptions({ - steps: [ - { - intro: "step one", - element: document.querySelector("h1"), - }, - ], - }) - .onstart(fn) - .start(); - - expect(fn).toBeCalledTimes(1); - expect(fn).toBeCalledWith(window.document.body); - }); - - test("should set a unique stamp for each instance", () => { - const intro1 = introJs(); - const intro2 = introJs(); - const intro3 = introJs(); - - //@ts-ignore - expect(intro1["introjs-instance"]).toBeNumber(); - //@ts-ignore - expect(intro2["introjs-instance"]).toBeNumber(); - //@ts-ignore - expect(intro3["introjs-instance"]).toBeNumber(); - //@ts-ignore - expect(intro1["introjs-instance"]).not.toBe(intro2["introjs-instance"]); - //@ts-ignore - expect(intro2["introjs-instance"]).not.toBe(intro3["introjs-instance"]); - }); - - test("should not append the dontShowAgain checkbox when its inactive", () => { - introJs() - .setOptions({ - dontShowAgain: false, - steps: [ - { - intro: "hello world", - }, - ], - }) - .start(); - - expect(find(".introjs-dontShowAgain")).toBeNull(); - }); - - test("should append the dontShowAgain checkbox", () => { - introJs() - .setOptions({ - dontShowAgain: true, - steps: [ - { - intro: "hello world", - }, - ], - }) - .start(); - - expect(find(".introjs-dontShowAgain")).not.toBeNull(); - }); - - test("should call setDontShowAgain when then checkbox is clicked", () => { - const intro = introJs().setOptions({ - dontShowAgain: true, - steps: [ - { - intro: "hello world", - }, - ], - }); - - intro.start(); - - const checkbox = find(".introjs-dontShowAgain input"); - - checkbox.click(); - - expect(setDontShowAgain).toBeCalledTimes(1); - expect(setDontShowAgain).toBeCalledWith(intro, true); - }); - - describe("isActive", () => { - test("should be false if isActive flag is false", () => { - const intro = introJs().setOptions({ - isActive: false, - }); - - expect(intro.isActive()).toBeFalsy(); - }); - test("should be true if dontShowAgain is active but cookie is missing", () => { - (getDontShowAgain as jest.Mock).mockReturnValueOnce(false); - - const intro = introJs().setOptions({ - isActive: true, - dontShowAgain: true, - }); - - expect(intro.isActive()).toBeTruthy(); - }); - - test("should be false if dontShowAgain is active but isActive is true", () => { - (getDontShowAgain as jest.Mock).mockReturnValueOnce(true); - - const intro = introJs().setOptions({ - isActive: true, - dontShowAgain: true, - }); - - expect(intro.isActive()).toBeFalsy(); - }); - }); -}); diff --git a/tests/jest/option.test.ts b/tests/jest/option.test.ts deleted file mode 100644 index 1889e3076..000000000 --- a/tests/jest/option.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { getDefaultOptions, setOption, setOptions } from "../../src/option"; - -describe("option", () => { - test("should return the default options", () => { - const defaultOptions = getDefaultOptions(); - expect(defaultOptions).toBeObject(); - }); - - test("should return empty steps array", () => { - const defaultOptions = getDefaultOptions(); - expect(defaultOptions.steps).toBeEmpty(); - }); - - test("should set a single option", () => { - const defaultOptions = getDefaultOptions(); - - const prevNextLabel = defaultOptions.nextLabel; - - setOption(defaultOptions, "nextLabel", "Boo!"); - - expect(defaultOptions.nextLabel).toBe("Boo!"); - expect(defaultOptions.nextLabel).not.toEqual(prevNextLabel); - }); - - test("should return the correct updated options", () => { - const defaultOptions = getDefaultOptions(); - - const updatedOptions = setOption(defaultOptions, "nextLabel", "Boo!"); - - expect(updatedOptions.nextLabel).toBe("Boo!"); - }); - - test("should set a multiple options", () => { - const defaultOptions = getDefaultOptions(); - - const prevNextLabel = defaultOptions.nextLabel; - - setOptions(defaultOptions, { - nextLabel: "Boo!", - highlightClass: "HighlightClass", - }); - - expect(defaultOptions.nextLabel).toBe("Boo!"); - expect(defaultOptions.nextLabel).not.toEqual(prevNextLabel); - expect(defaultOptions.highlightClass).toBe("HighlightClass"); - }); -}); diff --git a/tests/jest/util/addClass.test.ts b/tests/jest/util/addClass.test.ts deleted file mode 100644 index 038facb3c..000000000 --- a/tests/jest/util/addClass.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import addClass from "../../../src/util/addClass"; - -describe("addClass", () => { - test("should append when className is empty", () => { - const el = document.createElement("div"); - addClass(el, "myClass"); - expect(el.className).toBe("myClass"); - }); - - test("should append when className is NOT empty", () => { - const el = document.createElement("div"); - el.className = "firstClass"; - - addClass(el, "secondClass"); - - expect(el.className).toBe("firstClass secondClass"); - }); - - test("should not append duplicate classNames to elements", () => { - const el = document.createElement("div"); - el.className = "firstClass"; - - addClass(el, "firstClass"); - - expect(el.className).toBe("firstClass"); - }); - - test("should not append duplicate list of classNames to elements", () => { - const el = document.createElement("div"); - el.className = "firstClass firstClass"; - - addClass(el, "firstClass firstClass firstClass"); - - expect(el.className).toBe("firstClass"); - }); - - test("should not append duplicate list of classNames to an empty className", () => { - const el = document.createElement("div"); - - addClass(el, "firstClass firstClass firstClass"); - - expect(el.className).toBe("firstClass"); - }); -}); diff --git a/tests/jest/util/stamp.test.ts b/tests/jest/util/stamp.test.ts deleted file mode 100644 index 26107ba9e..000000000 --- a/tests/jest/util/stamp.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { IntroJs } from "../../../src/intro"; -import stamp from "../../../src/util/stamp"; - -describe("stamp", () => { - test("should stamp an IntroJS object", () => { - const instance = new IntroJs(document.body); - const stamped = stamp(instance); - - expect(stamped).toBe(0); - }); - - test("should increase the stamp number", () => { - const instance = new IntroJs(document.body); - stamp(instance); - const stamped = stamp(instance); - - expect(stamped).toBe(1); - }); -}); diff --git a/tsconfig.json b/tsconfig.json index 6e4a2a6f6..7588f6b1f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,9 +21,12 @@ "baseUrl": "./", "outDir": "dist" }, - "include": [ - "src/**/*", - "src/index.ts", - "tests/jest/**/*" + "include": ["src/**/*", "src/index.ts", "tests/jest/**/*"], + "exclude": [ + "./cypress.config.ts", + "cypress", + "dist", + "node_modules", + "**/*.cy.ts" ] }