From 5136a3cdd2bcb05885c970b80d054788eb504e1e Mon Sep 17 00:00:00 2001 From: Brian Sipple Date: Wed, 19 Sep 2018 17:57:25 -0700 Subject: [PATCH] Implement SVG-based modal mode --- README.md | 16 +- addon/services/tour.js | 229 ++++---- addon/styles/addon.css | 15 +- addon/{utils.js => utils/dom.js} | 64 +-- addon/utils/modal.js | 152 ++++++ tests/acceptance/ember-shepherd-test.js | 636 ++++++++++------------ tests/data.js | 33 +- tests/dummy/app/data.js | 33 +- tests/dummy/app/templates/application.hbs | 23 +- tests/unit/services/tour-test.js | 45 -- 10 files changed, 598 insertions(+), 648 deletions(-) rename addon/{utils.js => utils/dom.js} (54%) create mode 100644 addon/utils/modal.js diff --git a/README.md b/README.md index 1d95a95f..de6144f0 100644 --- a/README.md +++ b/README.md @@ -92,12 +92,6 @@ Thanks to [jquery-disablescroll](https://github.com/ultrapasty/jquery-disablescr > **default value:** `false` -### modalContainer - -`modalContainer` configures where in the DOM the modal overlay element will be placed (only has effect if `modal` is set to `true`) - -> **default value:** `body` - ### requiredElements `requiredElements` is an array of objects that indicate DOM elements that are **REQUIRED** by your tour and must @@ -161,12 +155,11 @@ this.get('tour').set('steps', [ } ], classes: 'custom-class-name-1 custom-class-name-2', - copyStyles: false, highlightClass: 'highlight', scrollTo: false, showCancelLink: true, title: 'Welcome to Ember-Shepherd!', - text: ['Ember-Shepherd is a javascript library for guiding users through your Ember app.'], + text: ['Ember-Shepherd is a JavaScript library for guiding users through your Ember app.'], when: { show: () => { console.log('show step'); @@ -236,13 +229,6 @@ Whether or not the target element being attached to should be "clickable". If se > **default value:** `true` -##### copyStyles - -This is a boolean, and when set to `true` it will fully clone the element and styles, rather than just increasing the element's z-index. This should only be used if the element does not pop out and highlight like it should, when using modal functionality. - -> **default value:** `false` - - ##### highlightClass This is an extra class to apply to the attachTo element, when it is highlighted. It can be any string. Just style that class name in your css. diff --git a/addon/services/tour.js b/addon/services/tour.js index 9a990d47..7a1f1d9f 100644 --- a/addon/services/tour.js +++ b/addon/services/tour.js @@ -3,14 +3,20 @@ import { get, observer, set } from '@ember/object'; import { isEmpty, isPresent } from '@ember/utils'; import Service from '@ember/service'; import Evented from '@ember/object/evented'; -import { run } from '@ember/runloop'; +import { run, scheduleOnce } from '@ember/runloop'; import { elementIsHidden, getElementForStep, - removeElement, - setPositionForHighlightElement, - toggleShepherdModalClass -} from '../utils'; + toggleShepherdModalClass, +} from '../utils/dom'; + +import { + getModalMaskOpening, + createModalOverlay, + positionModalOpening, + closeModalOpening, +} from '../utils/modal'; + export default Service.extend(Evented, { // Configuration Options @@ -22,10 +28,12 @@ export default Service.extend(Evented, { isActive: false, messageForUser: null, modal: false, - modalContainer: 'body', requiredElements: [], steps: [], + _modalOverlayElem: null, + _onScreenChange() {}, + willDestroy() { this.cleanup(); }, @@ -71,15 +79,13 @@ export default Service.extend(Evented, { }, onTourStart() { - if (get(this, 'modal')) { - const shepherdOverlay = document.createElement('div'); - shepherdOverlay.id = 'shepherdOverlay'; - const parent = document.querySelector(get(this, 'modalContainer')); - parent.appendChild(shepherdOverlay); - } + this.initModalOverlay(); + this.addStepEventListeners(); + if (get(this, 'disableScroll')) { disableScroll.on(window); } + this.trigger('start'); }, @@ -104,67 +110,9 @@ export default Service.extend(Evented, { disableScroll.off(window); } - this._cleanupSteps(); - this._cleanupModal(); - }, - - /** - * Creates an overlay element clone of the element you want to highlight and copies all the styles. - * @param step The step object that points to the element to highlight - * @private - */ - createHighlightOverlay(step) { - removeElement('#highlightOverlay'); - - const currentElement = getElementForStep(step); - - if (currentElement) { - const highlightElement = currentElement.cloneNode(true); - - highlightElement.setAttribute('id', 'highlightOverlay'); - document.body.appendChild(highlightElement); - - this.setComputedStylesOnClonedElement(currentElement, highlightElement); - - // Style all internal elements as well - const { children } = currentElement; - - const clonedChildren = highlightElement.children; - - for (let i = 0; i < children.length; i++) { - this.setComputedStylesOnClonedElement(children[i], clonedChildren[i]); - } - - setPositionForHighlightElement({ - currentElement, - highlightElement - }); - - window.addEventListener('resize', () => { - run.debounce(this, setPositionForHighlightElement, { - currentElement, - highlightElement - }, 50); - }); - } - }, - - /** - * Set computed styles on the cloned element - * - * @method setComputedStylesOnClonedElement - * @param element element we want to copy - * @param clonedElement cloned element above the overlay - * @private - */ - setComputedStylesOnClonedElement(element, clonedElement) { - const computedStyle = window.getComputedStyle(element, null); - - for (let i = 0; i < computedStyle.length; i++) { - const propertyName = computedStyle[i]; - - clonedElement.style[propertyName] = computedStyle.getPropertyValue(propertyName); - } + this.cleanupStepEventListeners(); + this.cleanupSteps(); + this.cleanupModal(); }, initialize() { @@ -181,7 +129,9 @@ export default Service.extend(Evented, { tourObject.on('start', run.bind(this, 'onTourStart')); tourObject.on('complete', run.bind(this, 'onTourFinish', 'complete')); tourObject.on('cancel', run.bind(this, 'onTourFinish', 'cancel')); - set(this, 'tourObject', tourObject); + + this.tourObject = tourObject; + this.initModalOverlay(); }, /** @@ -231,6 +181,16 @@ export default Service.extend(Evented, { } }, + setupModalForStep(step) { + if (!this.modal) { + this.hideModal(); + + } else { + this.styleModalOpeningForStep(step); + this.showModal(); + } + }, + /** * Modulates the styles of the passed step's target element, based on the step's options and * the tour's `modal` option, to visually emphasize the element @@ -238,27 +198,21 @@ export default Service.extend(Evented, { * @param step The step object that attaches to the element * @private */ - styleTargetElement(step) { - const currentElement = getElementForStep(step); + styleTargetElementForStep(step) { + const targetElement = getElementForStep(step); - if (!currentElement) { + if (!targetElement) { return; } + toggleShepherdModalClass(targetElement); + if (step.options.highlightClass) { - currentElement.classList.add(step.options.highlightClass); + targetElement.classList.add(step.options.highlightClass); } if (step.options.canClickTarget === false) { - currentElement.style.pointerEvents = 'none'; - } - - if (this.modal) { - if (step.options.copyStyles) { - this.createHighlightOverlay(step); - } else { - toggleShepherdModalClass(currentElement); - } + targetElement.style.pointerEvents = 'none'; } }, @@ -308,7 +262,6 @@ export default Service.extend(Evented, { text: 'Exit', action: tour.cancel }], - copyStyles: false, title: get(this, 'errorTitle'), text: [get(this, 'messageForUser')] }); @@ -328,18 +281,20 @@ export default Service.extend(Evented, { const currentStep = tour.steps[index]; currentStep.on('before-show', () => { - this.styleTargetElement(currentStep); + this.setupModalForStep(currentStep); + this.styleTargetElementForStep(currentStep); }); + currentStep.on('hide', () => { // Remove element copy, if it was cloned - const currentElement = getElementForStep(currentStep); + const targetElement = getElementForStep(currentStep); - if (currentElement) { + if (targetElement) { if (currentStep.options.highlightClass) { - currentElement.classList.remove(currentStep.options.highlightClass); + targetElement.classList.remove(currentStep.options.highlightClass); } - removeElement('#highlightOverlay'); + closeModalOpening(this._modalOverlayOpening); } }); @@ -363,7 +318,72 @@ export default Service.extend(Evented, { }); }), - _cleanupSteps() { + initModalOverlay() { + if (!this._modalOverlayElem) { + this._modalOverlayElem = createModalOverlay(); + this._modalOverlayOpening = getModalMaskOpening(this._modalOverlayElem); + + this.hideModal(); + + document.body.appendChild(this._modalOverlayElem); + } + }, + + styleModalOpeningForStep(step) { + const modalOverlayOpening = this._modalOverlayOpening; + const targetElement = getElementForStep(step); + + if (targetElement) { + positionModalOpening(targetElement, modalOverlayOpening); + + this._onScreenChange = () => { + run.debounce( + this, + () => { positionModalOpening(targetElement, modalOverlayOpening) }, + 50 + ); + }; + + this.addStepEventListeners(); + + } else { + closeModalOpening(this._modalOverlayOpening); + } + }, + + showModal() { + if (this._modalOverlayElem) { + this._modalOverlayElem.style.display = 'block'; + } + }, + + hideModal() { + if (this._modalOverlayElem) { + this._modalOverlayElem.style.display = 'none'; + } + }, + + addStepEventListeners() { + if (typeof this._onScreenChange === 'function') { + window.removeEventListener('resize', this._onScreenChange, false); + window.removeEventListener('scroll', this._onScreenChange, false); + } + + window.addEventListener('resize', this._onScreenChange, false); + window.addEventListener('scroll', this._onScreenChange, false); + }, + + + cleanupStepEventListeners() { + if (typeof this._onScreenChange === 'function') { + window.removeEventListener('resize', this._onScreenChange, false); + window.removeEventListener('scroll', this._onScreenChange, false); + + this._onScreenChange = null; + } + }, + + cleanupSteps() { const tour = this.tourObject; if (tour) { @@ -381,18 +401,15 @@ export default Service.extend(Evented, { } }, - _cleanupModal() { - if (this.modal) { - run('afterRender', () => { - removeElement('#shepherdOverlay'); - removeElement('#highlightOverlay'); + cleanupModal() { + scheduleOnce('afterRender', this, () => { + const element = this._modalOverlayElem; - const shepherdModal = document.querySelector('.shepherd-modal'); + if (element && element instanceof SVGElement) { + element.parentNode.removeChild(element); + } - if (shepherdModal) { - shepherdModal.classList.remove('shepherd-modal'); - } - }); - } - } + this._modalOverlayElem = null; + }); + }, }); diff --git a/addon/styles/addon.css b/addon/styles/addon.css index 0867a794..64e10540 100644 --- a/addon/styles/addon.css +++ b/addon/styles/addon.css @@ -1,8 +1,4 @@ -#highlightOverlay { - z-index: 9998; -} - -#shepherdOverlay { +#shepherdModalOverlayContainer { position: fixed; top: 0; left: 0; @@ -12,13 +8,7 @@ -khtml-opacity: 0.5; opacity: 0.5; z-index: 9997; - background-color: #000; - background: -moz-radial-gradient(center,ellipse cover,rgba(0,0,0,0.4) 0,rgba(0,0,0,0.9) 100%); - background: -webkit-gradient(radial,center center,0px,center center,100%,color-stop(0%,rgba(0,0,0,0.4)),color-stop(100%,rgba(0,0,0,0.9))); - background: -webkit-radial-gradient(center,ellipse cover,rgba(0,0,0,0.4) 0,rgba(0,0,0,0.9) 100%); - background: -o-radial-gradient(center,ellipse cover,rgba(0,0,0,0.4) 0,rgba(0,0,0,0.9) 100%); - background: -ms-radial-gradient(center,ellipse cover,rgba(0,0,0,0.4) 0,rgba(0,0,0,0.9) 100%); - background: radial-gradient(center,ellipse cover,rgba(0,0,0,0.4) 0,rgba(0,0,0,0.9) 100%); + pointer-events: none; filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#66000000',endColorstr='#e6000000',GradientType=1); -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=50)"; filter: alpha(opacity=50); @@ -29,6 +19,7 @@ transition: all 0.3s ease-out; } + .shepherd-modal.shepherd-enabled { position: relative; z-index: 9998; diff --git a/addon/utils.js b/addon/utils/dom.js similarity index 54% rename from addon/utils.js rename to addon/utils/dom.js index 2cf7b2fa..7940fefe 100644 --- a/addon/utils.js +++ b/addon/utils/dom.js @@ -1,3 +1,5 @@ + + /** * Helper method to check if element is hidden, since we cannot use :visible without jQuery * @param {HTMLElement} element The element to check for visibility @@ -73,63 +75,6 @@ function getElementFromString(element) { return document.querySelector(selector); } -/** - * Taken from introjs https://github.com/usablica/intro.js/blob/master/intro.js#L1092-1124 - * Get an element position on the page - * Thanks to `meouw`: http://stackoverflow.com/a/442474/375966 - * @private - */ -function getElementPosition(element) { - const elementPosition = { - height: element.offsetHeight, - width: element.offsetWidth - }; - - // calculate element top and left - let x = 0; - let y = 0; - - while (element && !isNaN(element.offsetLeft) && !isNaN(element.offsetTop)) { - x += element.offsetLeft; - y += element.offsetTop; - element = element.offsetParent; - } - - elementPosition.top = y; - elementPosition.left = x; - return elementPosition; -} - -/** - * Helper method to remove an element without jQuery - * @param {string} selector The CSS selector for the element to remove - * @private - */ -function removeElement(selector) { - const element = document.querySelector(selector); - - if (element instanceof HTMLElement) { - element.parentNode.removeChild(element); - } -} - -/** - * Set position of the highlighted element - * - * @param currentElement The element that belongs to the step - * @param highlightElement The cloned element that is above the overlay - * @private - */ -function setPositionForHighlightElement({ currentElement, highlightElement }) { - const elementPosition = getElementPosition(currentElement); - - highlightElement.style.position = 'absolute'; - highlightElement.style.left = `${elementPosition.left}px`; - highlightElement.style.top = `${elementPosition.top}px`; - highlightElement.style.width = `${elementPosition.width}px`; - highlightElement.style.height = `${elementPosition.height}px`; - highlightElement.style['z-index'] = 10002; -} /** * Remove any leftover .shepherd-modal classes and add the .shepherd-modal class to the currentElement @@ -148,8 +93,5 @@ function toggleShepherdModalClass(currentElement) { export { elementIsHidden, getElementForStep, - getElementPosition, - removeElement, - setPositionForHighlightElement, - toggleShepherdModalClass + toggleShepherdModalClass, }; diff --git a/addon/utils/modal.js b/addon/utils/modal.js new file mode 100644 index 00000000..6f36c337 --- /dev/null +++ b/addon/utils/modal.js @@ -0,0 +1,152 @@ +const svgNS = 'http://www.w3.org/2000/svg'; + +const elementIds = { + modalOverlay: 'shepherdModalOverlayContainer', + modalOverlayMask: 'shepherdModalMask', + modalOverlayMaskOpening: 'shepherdModalMaskOpening', +}; + + +/** + * + */ +function _createModalContainer() { + const element = document.createElementNS(svgNS, 'svg'); + + element.setAttributeNS(null, 'id', elementIds.modalOverlay); + + return element; +} + + +/** + * + */ +function _createMaskContainer() { + const element = document.createElementNS(svgNS, 'mask'); + + element.setAttributeNS(null, 'id', elementIds.modalOverlayMask); + element.setAttributeNS(null, 'x', '0'); + element.setAttributeNS(null, 'y', '0'); + element.setAttributeNS(null, 'width', '100%'); + element.setAttributeNS(null, 'height', '100%'); + + return element; +} + + +/** + * + */ +function _createMaskRect() { + const element = document.createElementNS(svgNS, 'rect'); + + element.setAttributeNS(null, 'x', '0'); + element.setAttributeNS(null, 'y', '0'); + element.setAttributeNS(null, 'width', '100%'); + element.setAttributeNS(null, 'height', '100%'); + element.setAttributeNS(null, 'fill', '#FFFFFF'); + + return element; +} + + +/** + * + */ +function _createMaskOpening() { + const element = document.createElementNS(svgNS, 'rect'); + + element.setAttributeNS(null, 'id', elementIds.modalOverlayMaskOpening); + element.setAttributeNS(null, 'fill', '#000000'); + + return element; +} + + +/** + * + */ +function _createMaskConsumer() { + const element = document.createElementNS(svgNS, 'rect'); + + element.setAttributeNS(null, 'x', '0'); + element.setAttributeNS(null, 'y', '0'); + element.setAttributeNS(null, 'width', '100%'); + element.setAttributeNS(null, 'height', '100%'); + element.setAttributeNS(null, 'mask', `url(#${elementIds.modalOverlayMask})`); + + return element; +} + + +/** + * Generates an SVG with the following structure: + * ```html + * + + + + + + + + + + + + * ``` + */ +function createModalOverlay() { + const containerElement = _createModalContainer(); + const defsElement = document.createElementNS(svgNS, 'defs'); + const maskContainer = _createMaskContainer(); + const maskRect = _createMaskRect(); + const maskOpening = _createMaskOpening(); + const maskConsumer = _createMaskConsumer(); + + maskContainer.appendChild(maskRect); + maskContainer.appendChild(maskOpening); + + defsElement.appendChild(maskContainer); + + containerElement.appendChild(defsElement); + containerElement.appendChild(maskConsumer); + + return containerElement; +} + + +function positionModalOpening(targetElement, openingElement) { + if (targetElement.getBoundingClientRect && openingElement instanceof SVGElement) { + const { x, y, width, height } = targetElement.getBoundingClientRect(); + + openingElement.setAttributeNS(null, 'x', x); + openingElement.setAttributeNS(null, 'y', y); + openingElement.setAttributeNS(null, 'width', width); + openingElement.setAttributeNS(null, 'height', height); + } +} + +function closeModalOpening(openingElement) { + if (openingElement && openingElement instanceof SVGElement) { + openingElement.setAttributeNS(null, 'x', '0'); + openingElement.setAttributeNS(null, 'y', '0'); + openingElement.setAttributeNS(null, 'width', '0'); + openingElement.setAttributeNS(null, 'height', '0'); + } +} + + +function getModalMaskOpening(modalElement) { + return modalElement.querySelector(`#${elementIds.modalOverlayMaskOpening}`); +} + + +export { + createModalOverlay, + positionModalOpening, + closeModalOpening, + getModalMaskOpening, + elementIds, +} diff --git a/tests/acceptance/ember-shepherd-test.js b/tests/acceptance/ember-shepherd-test.js index b1e31f35..785c84cf 100644 --- a/tests/acceptance/ember-shepherd-test.js +++ b/tests/acceptance/ember-shepherd-test.js @@ -3,6 +3,7 @@ import { visit, click, find } from '@ember/test-helpers'; import { later } from '@ember/runloop'; import { setupApplicationTest } from 'ember-qunit'; import { builtInButtons } from '../data'; +import { elementIds } from 'ember-shepherd/utils/modal'; module('Acceptance | Tour functionality tests', function(hooks) { let tour; @@ -20,454 +21,417 @@ module('Acceptance | Tour functionality tests', function(hooks) { return await tour.cancel(); }); - test('Shows cancel link', async function(assert) { - await visit('/'); + module('Cancel link', function() { + test('Shows cancel link', async function(assert) { + await visit('/'); - await click('.toggleHelpModal'); + await click('.toggleHelpModal'); - const cancelLink = document.querySelector('.shepherd-cancel-link'); - assert.ok(cancelLink, 'Cancel link shown'); - }); + const cancelLink = document.querySelector('.shepherd-cancel-link'); + assert.ok(cancelLink, 'Cancel link shown'); + }); - test('Hides cancel link', async function(assert) { - const defaultStepOptions = { - classes: 'shepherd-theme-arrows test-defaults', - showCancelLink: false - }; - - const steps = [{ - id: 'step-without-cancel-link', - options: { - attachTo: '.first-element bottom', - buttons: [ - builtInButtons.cancel, - builtInButtons.next - ], + test('Hides cancel link', async function(assert) { + const defaultStepOptions = { + classes: 'shepherd-theme-arrows test-defaults', showCancelLink: false - } - }]; - - await visit('/'); - - tour.set('defaultStepOptions', defaultStepOptions); - tour.set('steps', steps); + }; - await click('.toggleHelpModal'); - - assert.notOk(document.querySelector('.shepherd-element a.shepherd-cancel-link')); - }); + const steps = [{ + id: 'step-without-cancel-link', + options: { + attachTo: '.first-element bottom', + buttons: [ + builtInButtons.cancel, + builtInButtons.next + ], + showCancelLink: false + } + }]; - test('Cancel link cancels the tour', async function(assert) { - await visit('/'); + await visit('/'); - await click('.toggleHelpModal'); + tour.set('defaultStepOptions', defaultStepOptions); + tour.set('steps', steps); - assert.ok(document.body.classList.contains('shepherd-active'), 'Body has class of shepherd-active, when shepherd becomes active'); + await click('.toggleHelpModal'); - await click(document.querySelector('.shepherd-content a.shepherd-cancel-link')); + assert.notOk(document.querySelector('.shepherd-element a.shepherd-cancel-link')); + }); - assert.notOk(document.body.classList.contains('shepherd-active'), 'Body does not have class of shepherd-active, when shepherd becomes inactive'); - }); + test('Cancel link cancels the tour', async function(assert) { + await visit('/'); - test('Modal page contents', async function(assert) { - assert.expect(3); + await click('.toggleHelpModal'); - await visit('/'); + assert.ok(document.body.classList.contains('shepherd-active'), 'Body has class of shepherd-active, when shepherd becomes active'); - await click('.toggleHelpModal'); + await click(document.querySelector('.shepherd-content a.shepherd-cancel-link')); - assert.ok(document.body.classList.contains('shepherd-active'), 'Body gets class of shepherd-active, when shepherd becomes active'); - assert.equal(document.querySelectorAll('.shepherd-enabled').length, 1, 'attachTo element has the shepherd-enabled class'); - assert.ok(document.querySelector('#shepherdOverlay'), '#shepherdOverlay exists, since modal'); + assert.notOk(document.body.classList.contains('shepherd-active'), 'Body does not have class of shepherd-active, when shepherd becomes inactive'); + }); }); - test('Non-modal page contents', async function(assert) { - assert.expect(3); + module('Modal mode', function () { + test('Displaying the modal during tours when modal mode is enabled', async function(assert) { + await visit('/'); - await visit('/'); + const modalOverlay = document.querySelector(`#${elementIds.modalOverlay}`); - await click('.toggleHelpNonmodal'); + assert.ok(modalOverlay, 'modal overlay is present in the DOM'); + assert.equal(getComputedStyle(modalOverlay).display, 'none', 'modal overlay is present but not displayed before the tour starts'); - assert.ok(document.body.classList.contains('shepherd-active'), 'Body gets class of shepherd-active, when shepherd becomes active'); - assert.equal(document.querySelectorAll('.shepherd-enabled').length, 1, 'attachTo element has the shepherd-enabled class'); - assert.notOk(document.querySelector('#shepherdOverlay'), '#shepherdOverlay should not exist, since non-modal'); - }); + await click('.toggleHelpModal'); - test('Highlight applied', async function(assert) { - assert.expect(2); + assert.equal(getComputedStyle(modalOverlay).display, 'block', 'modal overlay is present and displayed after the tour starts'); - const steps = [{ - id: 'test-highlight', - options: { - attachTo: '.first-element bottom', - buttons: [ - builtInButtons.cancel, - builtInButtons.next - ], - highlightClass: 'highlight', - text: ['Testing highlight'] - } - }]; + assert.ok(document.body.classList.contains('shepherd-active'), 'Body gets class of shepherd-active, when shepherd becomes active'); + assert.equal(document.querySelectorAll('.shepherd-enabled').length, 1, 'attachTo element has the shepherd-enabled class'); + }); - await visit('/'); + test('Hiding the modal during tours when modal mode is not enabled', async function(assert) { + await visit('/'); - tour.set('steps', steps); - tour.set('modal', true); + const modalOverlay = document.querySelector(`#${elementIds.modalOverlay}`); - await click('.toggleHelpModal'); + assert.ok(modalOverlay, 'modal overlay is present in the DOM'); + assert.equal(getComputedStyle(modalOverlay).display, 'none', 'modal overlay is present but not displayed before the tour starts'); - assert.ok(tour.get('tourObject').currentStep.target.classList.contains('highlight'), - 'currentElement has highlightClass applied'); + await click('.toggleHelpNonmodal'); - await click(document.querySelector('.cancel-button')); + assert.equal(getComputedStyle(modalOverlay).display, 'none', 'modal overlay is present but not displayed after the tour starts'); - assert.notOk(tour.get('tourObject').currentStep.target.classList.contains('highlight'), - 'highlightClass removed on cancel'); - }); + assert.ok(document.body.classList.contains('shepherd-active'), 'Body gets class of shepherd-active, when shepherd becomes active'); + assert.equal(document.querySelectorAll('.shepherd-enabled').length, 1, 'attachTo element has the shepherd-enabled class'); + }); - test('Highlight applied when `tour.modal == false`', async function(assert) { - assert.expect(2); + test('applying highlight classes to the target element', async function(assert) { + assert.expect(2); - const steps = [{ - id: 'test-highlight', - options: { - attachTo: '.first-element bottom', - buttons: [ - builtInButtons.cancel, - builtInButtons.next - ], - highlightClass: 'highlight', - text: ['Testing highlight'] - } - }]; + const steps = [{ + id: 'test-highlight', + options: { + attachTo: '.first-element bottom', + buttons: [ + builtInButtons.cancel, + builtInButtons.next + ], + highlightClass: 'highlight', + text: ['Testing highlight'] + } + }]; - await visit('/'); + await visit('/'); - tour.set('steps', steps); + tour.set('steps', steps); + tour.set('modal', true); - await click('.toggleHelpNonmodal'); + await click('.toggleHelpModal'); - assert.ok(tour.get('tourObject').currentStep.target.classList.contains('highlight'), - 'currentElement has highlightClass applied'); + assert.ok(tour.get('tourObject').currentStep.target.classList.contains('highlight'), + 'currentElement has highlightClass applied'); - await click(document.querySelector('.cancel-button')); + await click(document.querySelector('.cancel-button')); - assert.notOk(tour.get('tourObject').currentStep.target.classList.contains('highlight'), - 'highlightClass removed on cancel'); - }); + assert.notOk(tour.get('tourObject').currentStep.target.classList.contains('highlight'), + 'highlightClass removed on cancel'); + }); - test('Defaults applied', async function(assert) { - assert.expect(1); + test('Highlight applied when `tour.modal == false`', async function(assert) { + assert.expect(2); - const stepsWithoutClasses = [ - { + const steps = [{ id: 'test-highlight', options: { attachTo: '.first-element bottom', buttons: [ builtInButtons.cancel, builtInButtons.next - ] + ], + highlightClass: 'highlight', + text: ['Testing highlight'] } - } - ]; - - await visit('/'); + }]; - tour.set('steps', stepsWithoutClasses); + await visit('/'); - await click('.toggleHelpModal'); - - assert.ok(document.querySelector('.custom-default-class'), 'defaults class applied'); - }); - - test('configuration works with attachTo object when element is a simple string', async function(assert) { - assert.expect(1); - - const steps = [{ - id: 'test-attachTo-string', - options: { - attachTo: { - element: '.first-element', - on: 'bottom' - }, - buttons: [ - builtInButtons.cancel, - builtInButtons.next - ] - } - }]; + tour.set('steps', steps); - tour.set('steps', steps); + await click('.toggleHelpNonmodal'); - await visit('/'); + assert.ok(tour.get('tourObject').currentStep.target.classList.contains('highlight'), + 'currentElement has highlightClass applied'); - await click('.toggleHelpModal'); + await click(document.querySelector('.cancel-button')); - assert.ok(document.querySelector('.shepherd-element'), 'tour is visible'); + assert.notOk(tour.get('tourObject').currentStep.target.classList.contains('highlight'), + 'highlightClass removed on cancel'); + }); }); - test('configuration works with attachTo object when element is dom element', async function(assert) { - assert.expect(1); - - await visit('/'); - - const steps = [{ - id: 'test-attachTo-dom', - options: { - attachTo: { - element: find('.first-element'), - on: 'bottom' - }, - buttons: [ - builtInButtons.cancel, - builtInButtons.next - ] - } - }]; + module('Tour options', function() { + test('Defaults applied', async function(assert) { + assert.expect(1); + + const stepsWithoutClasses = [ + { + id: 'test-highlight', + options: { + attachTo: '.first-element bottom', + buttons: [ + builtInButtons.cancel, + builtInButtons.next + ] + } + } + ]; - tour.set('steps', steps); + await visit('/'); - await click('.toggleHelpModal'); + tour.set('steps', stepsWithoutClasses); - assert.ok(document.querySelector('.shepherd-element'), 'tour is visible'); - }); + await click('.toggleHelpModal'); - test('buttons work when type is not specified and passed action is triggered', async function(assert) { - assert.expect(4); + assert.ok(document.querySelector('.custom-default-class'), 'defaults class applied'); + }); - let buttonActionCalled = false; + test('configuration works with attachTo object when element is a simple string', async function(assert) { + assert.expect(1); - const steps = [{ - id: 'test-buttons-custom-action', - options: { - attachTo: { - element: '.first-element', - on: 'bottom' - }, - buttons: [ - { - classes: 'shepherd-button-secondary button-one', - text: 'button one' - }, - { - classes: 'shepherd-button-secondary button-two', - text: 'button two', - action() { - buttonActionCalled = true; - } + const steps = [{ + id: 'test-attachTo-string', + options: { + attachTo: { + element: '.first-element', + on: 'bottom' }, - { - classes: 'shepherd-button-secondary button-three', - text: 'button three' - } - ] - } - }]; + buttons: [ + builtInButtons.cancel, + builtInButtons.next + ] + } + }]; - await visit('/'); + tour.set('steps', steps); - tour.set('steps', steps); + await visit('/'); - await click('.toggleHelpModal'); + await click('.toggleHelpModal'); - assert.ok(document.querySelector('.button-one'), 'tour button one is visible'); - assert.ok(document.querySelector('.button-two'), 'tour button two is visible'); - assert.ok(document.querySelector('.button-three'), 'tour button three is visible'); + assert.ok(document.querySelector('.shepherd-element'), 'tour is visible'); + }); - await click(document.querySelector('.button-two')); + test('configuration works with attachTo object when element is dom element', async function(assert) { + assert.expect(1); - assert.ok(buttonActionCalled, 'button action triggered'); - }); + await visit('/'); - test('`pointer-events` is set to `auto` for any previously disabled `attachTo` targets', async function(assert) { - const steps = [ - { - id: 'step-1', + const steps = [{ + id: 'test-attachTo-dom', options: { - attachTo: '.shepherd-logo-link top', + attachTo: { + element: find('.first-element'), + on: 'bottom' + }, buttons: [ builtInButtons.cancel, builtInButtons.next - ], - title: 'Controlling Clickability', - text: 'By default, target elements should have their `pointerEvents` style unchanged' + ] } - }, - { - id: 'step-2', + }]; + + tour.set('steps', steps); + + await click('.toggleHelpModal'); + + assert.ok(document.querySelector('.shepherd-element'), 'tour is visible'); + }); + + test('buttons work when type is not specified and passed action is triggered', async function(assert) { + assert.expect(4); + + let buttonActionCalled = false; + + const steps = [{ + id: 'test-buttons-custom-action', options: { - attachTo: '.shepherd-logo-link top', - canClickTarget: false, + attachTo: { + element: '.first-element', + on: 'bottom' + }, buttons: [ - builtInButtons.cancel - ], - title: 'Controlling Clickability', - text: 'Clickability of target elements can be disabled by setting `canClickTarget` to false' + { + classes: 'shepherd-button-secondary button-one', + text: 'button one' + }, + { + classes: 'shepherd-button-secondary button-two', + text: 'button two', + action() { + buttonActionCalled = true; + } + }, + { + classes: 'shepherd-button-secondary button-three', + text: 'button three' + } + ] } - } - ]; + }]; - await visit('/'); + await visit('/'); - tour.set('steps', steps); - tour.set('modal', true); + tour.set('steps', steps); - await click('.toggleHelpModal'); + await click('.toggleHelpModal'); - // Get the target element - const targetElement = document.querySelector('.shepherd-target'); + assert.ok(document.querySelector('.button-one'), 'tour button one is visible'); + assert.ok(document.querySelector('.button-two'), 'tour button two is visible'); + assert.ok(document.querySelector('.button-three'), 'tour button three is visible'); - assert.equal(getComputedStyle(targetElement)['pointer-events'], 'auto'); + await click(document.querySelector('.button-two')); - // Exit the tour - await click(document.querySelector('[data-id="step-1"] .next-button')); + assert.ok(buttonActionCalled, 'button action triggered'); + }); - assert.equal(getComputedStyle(targetElement)['pointer-events'], 'none'); + test('`pointer-events` is set to `auto` for any previously disabled `attachTo` targets', async function(assert) { + const steps = [ + { + id: 'step-1', + options: { + attachTo: '.shepherd-logo-link top', + buttons: [ + builtInButtons.cancel, + builtInButtons.next + ], + title: 'Controlling Clickability', + text: 'By default, target elements should have their `pointerEvents` style unchanged' + } + }, + { + id: 'step-2', + options: { + attachTo: '.shepherd-logo-link top', + canClickTarget: false, + buttons: [ + builtInButtons.cancel + ], + title: 'Controlling Clickability', + text: 'Clickability of target elements can be disabled by setting `canClickTarget` to false' + } + } + ]; - // Exit the tour - await click(document.querySelector('[data-id="step-2"] .cancel-button')); + await visit('/'); - assert.equal(getComputedStyle(targetElement)['pointer-events'], 'auto'); - }); + tour.set('steps', steps); + tour.set('modal', true); - test('scrollTo works with disableScroll on', async function(assert) { - assert.expect(2); - // Setup controller tour settings - tour.set('disableScroll', true); - tour.set('scrollTo', true); + await click('.toggleHelpModal'); - // Visit route - await visit('/'); + // Get the target element + const targetElement = document.querySelector('.shepherd-target'); - document.querySelector('#ember-testing-container').scrollTop = 0; + assert.equal(getComputedStyle(targetElement)['pointer-events'], 'auto'); - assert.equal(document.querySelector('#ember-testing-container').scrollTop, 0, 'Scroll is initially 0'); + // Exit the tour + await click(document.querySelector('[data-id="step-1"] .next-button')); - await click('.toggleHelpModal'); + assert.equal(getComputedStyle(targetElement)['pointer-events'], 'none'); - await click(document.querySelector('.shepherd-content .next-button')); + // Exit the tour + await click(document.querySelector('[data-id="step-2"] .cancel-button')); - await click(document.querySelector('.shepherd-content .next-button')); + assert.equal(getComputedStyle(targetElement)['pointer-events'], 'auto'); + }); - assert.ok(document.querySelector('#ember-testing-container').scrollTop > 0, 'Scrolled down correctly'); - }); + test('scrollTo works with disableScroll on', async function(assert) { + assert.expect(2); + // Setup controller tour settings + tour.set('disableScroll', true); + tour.set('scrollTo', true); - test('scrollTo works with a custom scrollToHandler', async function(assert) { - assert.expect(2); - - const done = assert.async(); - - // Override default behavior - const steps = [{ - id: 'intro', - options: { - attachTo: '.first-element bottom', - buttons: [ - builtInButtons.cancel, - builtInButtons.next - ], - scrollTo: true, - scrollToHandler() { - document.querySelector('#ember-testing-container').scrollTop = 120; - return later(() => { - assert.equal(document.querySelector('#ember-testing-container').scrollTop, 120, 'Scrolled correctly'); - done(); - }, 50); - } - } - }]; + // Visit route + await visit('/'); - // Visit route - await visit('/'); + document.querySelector('#ember-testing-container').scrollTop = 0; - tour.set('steps', steps); + assert.equal(document.querySelector('#ember-testing-container').scrollTop, 0, 'Scroll is initially 0'); - document.querySelector('#ember-testing-container').scrollTop = 0; - assert.equal(document.querySelector('#ember-testing-container').scrollTop, 0, 'Scroll is initially 0'); + await click('.toggleHelpModal'); - await click('.toggleHelpModal'); - await click(document.querySelector('.shepherd-content .next-button')); - }); + await click(document.querySelector('.shepherd-content .next-button')); + + await click(document.querySelector('.shepherd-content .next-button')); - test('scrollTo works without a custom scrollToHandler', async function(assert) { - assert.expect(2); - // Setup controller tour settings - tour.set('scrollTo', true); + assert.ok(document.querySelector('#ember-testing-container').scrollTop > 0, 'Scrolled down correctly'); + }); - // Visit route - await visit('/'); + test('scrollTo works with a custom scrollToHandler', async function(assert) { + assert.expect(2); - document.querySelector('#ember-testing-container').scrollTop = 0; + const done = assert.async(); + + // Override default behavior + const steps = [{ + id: 'intro', + options: { + attachTo: '.first-element bottom', + buttons: [ + builtInButtons.cancel, + builtInButtons.next + ], + scrollTo: true, + scrollToHandler() { + document.querySelector('#ember-testing-container').scrollTop = 120; + return later(() => { + assert.equal(document.querySelector('#ember-testing-container').scrollTop, 120, 'Scrolled correctly'); + done(); + }, 50); + } + } + }]; - assert.equal(document.querySelector('#ember-testing-container').scrollTop, 0, 'Scroll is initially 0'); + // Visit route + await visit('/'); - await click('.toggleHelpModal'); - await click(document.querySelector('.shepherd-content .next-button')); + tour.set('steps', steps); - assert.ok(document.querySelector('#ember-testing-container').scrollTop > 0, 'Scrolled correctly'); - }); + document.querySelector('#ember-testing-container').scrollTop = 0; + assert.equal(document.querySelector('#ember-testing-container').scrollTop, 0, 'Scroll is initially 0'); - test('Show by id works', async function(assert) { - assert.expect(1); + await click('.toggleHelpModal'); + await click(document.querySelector('.shepherd-content .next-button')); + }); - await visit('/'); + test('scrollTo works without a custom scrollToHandler', async function(assert) { + assert.expect(2); + // Setup controller tour settings + tour.set('scrollTo', true); - tour.show('usage'); + // Visit route + await visit('/'); - assert.equal(tour.get('tourObject').currentStep.el.querySelector('.shepherd-text').textContent, - 'To use the tour service, simply inject it into your application and use it like this example.', - 'Usage step shown'); - }); + document.querySelector('#ember-testing-container').scrollTop = 0; - test('copyStyles copies the element correctly', async function(assert) { - assert.expect(1); + assert.equal(document.querySelector('#ember-testing-container').scrollTop, 0, 'Scroll is initially 0'); - const steps = [{ - id: 'intro', - options: { - attachTo: '.first-element bottom', - buttons: [ - builtInButtons.cancel, - builtInButtons.next - ], - copyStyles: true - } - }]; + await click('.toggleHelpModal'); + await click(document.querySelector('.shepherd-content .next-button')); - await visit('/'); + assert.ok(document.querySelector('#ember-testing-container').scrollTop > 0, 'Scrolled correctly'); + }); - tour.set('steps', steps); + test('Show by id works', async function(assert) { + assert.expect(1); - await click('.toggleHelpModal'); + await visit('/'); - assert.equal(document.querySelectorAll('.first-element').length, 2, 'First element is copied with copyStyles'); - }); + tour.show('usage'); - test('configuring the modal container works', async function(assert) { - await visit('/'); - await click('.toggleHelpModal'); - - assert.equal( - document.querySelector('#shepherdOverlay').parentNode.tagName, - 'BODY', - 'modal overlay gets placed in body element by default' - ); - await click(document.querySelector('.cancel-button')); - assert.notOk( - document.querySelector('#shepherdOverlay'), - 'overlay gets cleaned up after closing tour (default location)' - ); - - tour.set('modalContainer', '.test-modal-container'); - await visit('/'); - await click('.toggleHelpModal'); - - assert.ok( - document.querySelector('#shepherdOverlay').parentNode.classList.contains('test-modal-container'), - 'modal overlay gets placed in custom element' - ); - await click(document.querySelector('.cancel-button')); - assert.notOk(document.querySelector('#shepherdOverlay'), 'overlay gets cleaned up after closing tour (custom location)'); + assert.equal(tour.get('tourObject').currentStep.el.querySelector('.shepherd-text').textContent, + 'To use the tour service, simply inject it into your application and use it like this example.', + 'Usage step shown'); + }); }); }); diff --git a/tests/data.js b/tests/data.js index 93394a2b..9e2d9efd 100644 --- a/tests/data.js +++ b/tests/data.js @@ -26,10 +26,9 @@ export const steps = [ builtInButtons.next, ], classes: 'custom-class-name-1 custom-class-name-2', - copyStyles: false, title: 'Welcome to Ember Shepherd!', text: [ - `Ember Shepherd is a javascript library for guiding users through your Ember app. + `Ember Shepherd is a JavaScript library for guiding users through your Ember app. It is an Ember addon that wraps Shepherd and extends its functionality. Shepherd uses Popper.js, another open source library, to position all of its steps.`, @@ -48,7 +47,6 @@ export const steps = [ builtInButtons.next, ], classes: 'custom-class-name-1 custom-class-name-2', - copyStyles: false, text: ['Installation is simple, if you are using Ember-CLI, just install like any other addon.'] } }, @@ -62,7 +60,6 @@ export const steps = [ builtInButtons.next, ], classes: 'custom-class-name-1 custom-class-name-2', - copyStyles: false, text: ['To use the tour service, simply inject it into your application and use it like this example.'] } }, @@ -79,34 +76,12 @@ export const steps = [ builtInButtons.next, ], classes: 'custom-class-name-1 custom-class-name-2', - copyStyles: false, text: [ 'We implemented true modal functionality by disabling clicking of the rest of the page.', 'If you would like to enable modal, simply do this.get(\'tour\').set(\'modal\', true).' ] } }, - { - id: 'copyStyle', - options: { - attachTo: { - element: '.style-copy-element', - on: 'top' - }, - buttons: [ - builtInButtons.cancel, - builtInButtons.back, - builtInButtons.next, - ], - classes: 'custom-class-name-1 custom-class-name-2', - copyStyles: false, - text: [ - `When using a modal, most times just setting the z-index of your element to something high will - make it highlight. For complicated cases, where this does not work, we implemented a copyStyles option - that clones the element and copies its computed styles.` - ] - } - }, { id: 'buttons', options: { @@ -117,7 +92,6 @@ export const steps = [ builtInButtons.next, ], classes: 'custom-class-name-1 custom-class-name-2', - copyStyles: false, text: [ `For the common button types ("next", "back", "cancel", etc.), we implemented Ember actions that perform these actions on your tour automatically. To use them, simply include @@ -135,7 +109,6 @@ export const steps = [ builtInButtons.next, ], classes: 'custom-class-name-1 custom-class-name-2', - copyStyles: false, text: [ `When navigating the user through a tour, you may want to disable scrolling, so they cannot mess up your carefully planned out, amazing tour. This is now easily achieved @@ -151,7 +124,7 @@ export const steps = [ builtInButtons.back, ], classes: 'custom-class-name-1 custom-class-name-2', - copyStyles: false, text: ['If no attachTo is specified, the modal will appear in the center of the screen, as per the Shepherd docs.'] } - }]; + } +]; diff --git a/tests/dummy/app/data.js b/tests/dummy/app/data.js index 93394a2b..9e2d9efd 100644 --- a/tests/dummy/app/data.js +++ b/tests/dummy/app/data.js @@ -26,10 +26,9 @@ export const steps = [ builtInButtons.next, ], classes: 'custom-class-name-1 custom-class-name-2', - copyStyles: false, title: 'Welcome to Ember Shepherd!', text: [ - `Ember Shepherd is a javascript library for guiding users through your Ember app. + `Ember Shepherd is a JavaScript library for guiding users through your Ember app. It is an Ember addon that wraps Shepherd and extends its functionality. Shepherd uses Popper.js, another open source library, to position all of its steps.`, @@ -48,7 +47,6 @@ export const steps = [ builtInButtons.next, ], classes: 'custom-class-name-1 custom-class-name-2', - copyStyles: false, text: ['Installation is simple, if you are using Ember-CLI, just install like any other addon.'] } }, @@ -62,7 +60,6 @@ export const steps = [ builtInButtons.next, ], classes: 'custom-class-name-1 custom-class-name-2', - copyStyles: false, text: ['To use the tour service, simply inject it into your application and use it like this example.'] } }, @@ -79,34 +76,12 @@ export const steps = [ builtInButtons.next, ], classes: 'custom-class-name-1 custom-class-name-2', - copyStyles: false, text: [ 'We implemented true modal functionality by disabling clicking of the rest of the page.', 'If you would like to enable modal, simply do this.get(\'tour\').set(\'modal\', true).' ] } }, - { - id: 'copyStyle', - options: { - attachTo: { - element: '.style-copy-element', - on: 'top' - }, - buttons: [ - builtInButtons.cancel, - builtInButtons.back, - builtInButtons.next, - ], - classes: 'custom-class-name-1 custom-class-name-2', - copyStyles: false, - text: [ - `When using a modal, most times just setting the z-index of your element to something high will - make it highlight. For complicated cases, where this does not work, we implemented a copyStyles option - that clones the element and copies its computed styles.` - ] - } - }, { id: 'buttons', options: { @@ -117,7 +92,6 @@ export const steps = [ builtInButtons.next, ], classes: 'custom-class-name-1 custom-class-name-2', - copyStyles: false, text: [ `For the common button types ("next", "back", "cancel", etc.), we implemented Ember actions that perform these actions on your tour automatically. To use them, simply include @@ -135,7 +109,6 @@ export const steps = [ builtInButtons.next, ], classes: 'custom-class-name-1 custom-class-name-2', - copyStyles: false, text: [ `When navigating the user through a tour, you may want to disable scrolling, so they cannot mess up your carefully planned out, amazing tour. This is now easily achieved @@ -151,7 +124,7 @@ export const steps = [ builtInButtons.back, ], classes: 'custom-class-name-1 custom-class-name-2', - copyStyles: false, text: ['If no attachTo is specified, the modal will appear in the center of the screen, as per the Shepherd docs.'] } - }]; + } +]; diff --git a/tests/dummy/app/templates/application.hbs b/tests/dummy/app/templates/application.hbs index 47488fe7..1837cfc0 100644 --- a/tests/dummy/app/templates/application.hbs +++ b/tests/dummy/app/templates/application.hbs @@ -4,20 +4,20 @@

ember Shepherd

- -

Guide your users through a tour of your app.

+ +

Guide your users through a tour of your app.

Installation
- - {{#code-block language='bash'}}ember install ember-shepherd{{/code-block}} + + {{#code-block language='bash' class="install-element"}}ember install ember-shepherd{{/code-block}}
Usage
- - {{~#code-block language='javascript'~}} + + {{~#code-block language='javascript' class="usage-element"~}} //Inject the service: tour: Ember.inject.service() let tour = this.get('tour'); tour.set('defaultStepOptions', shepherdDefaults); @@ -36,17 +36,14 @@ tour.cancel();
Additional Features
- - + + Modal - - Style Copy - - + Built-in Buttons - + Disable Scroll diff --git a/tests/unit/services/tour-test.js b/tests/unit/services/tour-test.js index caae4a47..725e2b06 100644 --- a/tests/unit/services/tour-test.js +++ b/tests/unit/services/tour-test.js @@ -2,7 +2,6 @@ import { module, test } from 'qunit'; import { setupTest } from 'ember-qunit'; import EmberObject from '@ember/object'; import { run } from '@ember/runloop'; -import { getElementPosition, setPositionForHighlightElement } from 'ember-shepherd/utils'; import { builtInButtons } from '../../data'; const steps = [ @@ -89,48 +88,4 @@ module('Unit | Service | tour', function(hooks) { service.start(); }); }); - - test('it correctly calculates element position from getElementPosition', function(assert) { - assert.expect(2); - - const mockElement = { offsetHeight: 500, offsetLeft: 200, offsetTop: 100, offsetWidth: 250 }; - const position = getElementPosition(mockElement); - - assert.equal(position.top, '100', 'Top is correctly calculated'); - assert.equal(position.left, '200', 'Left is correctly calculated'); - }); - - test('it correctly sets the highlight element position', function(assert) { - assert.expect(4); - - const currentElement = { offsetHeight: 500, offsetLeft: 200, offsetTop: 100, offsetWidth: 250 }; - const highlightElement = { style: {} }; - - setPositionForHighlightElement({ - currentElement, - highlightElement - }); - - assert.ok(highlightElement.style.left.indexOf(currentElement.offsetLeft) > -1); - assert.ok(highlightElement.style.top.indexOf(currentElement.offsetTop) > -1); - assert.ok(highlightElement.style.width.indexOf(currentElement.offsetWidth) > -1); - assert.ok(highlightElement.style.height.indexOf(currentElement.offsetHeight) > -1); - }); - - test('it correctly sets the highlight element position format', function(assert) { - assert.expect(4); - - const currentElement = { offsetHeight: 500, offsetLeft: 200, offsetTop: 100, offsetWidth: 250 }; - const highlightElement = { style: {} }; - - setPositionForHighlightElement({ - currentElement, - highlightElement - }); - - assert.ok(highlightElement.style.left.indexOf('px') > -1); - assert.ok(highlightElement.style.top.indexOf('px') > -1); - assert.ok(highlightElement.style.width.indexOf('px') > -1); - assert.ok(highlightElement.style.height.indexOf('px') > -1); - }); });