diff --git a/src/lib/Browser.js b/src/lib/Browser.js index c1ca88891..cf20ffec5 100644 --- a/src/lib/Browser.js +++ b/src/lib/Browser.js @@ -264,6 +264,17 @@ class Browser { return document.implementation.hasFeature('http://www.w3.org/TR/SVG11/feature#BasicStructure', '1.1'); } + /** + * Returns true if the browser supports touch. + * taken from Modernizr: https://github.com/Modernizr/Modernizr/blob/5eea7e2a213edc9e83a47b6414d0250468d83471/feature-detects/touchevents.js#L40 + * + * @public + * @return {boolean} Is touch supported + */ + static hasTouch() { + return 'ontouchstart' in window || (window.DocumentTouch && document instanceof DocumentTouch); + } + /** * Returns whether the browser is a mobile browser. * diff --git a/src/lib/Controls.js b/src/lib/Controls.js index d3daf7a20..d00594b6e 100644 --- a/src/lib/Controls.js +++ b/src/lib/Controls.js @@ -1,10 +1,26 @@ import throttle from 'lodash.throttle'; +import Browser from './Browser'; import { CLASS_HIDDEN } from './constants'; const SHOW_PREVIEW_CONTROLS_CLASS = 'box-show-preview-controls'; +const CONTROLS_BUTTON_CLASS = 'bp-controls-btn'; const CONTROLS_AUTO_HIDE_TIMEOUT_IN_MILLIS = 1500; class Controls { + /** + * Indicates if the control bar should be hidden or not + * + * @property {boolean} + */ + shouldHide = true; + + /** + * Indicates if an element in the controls is focused + * + * @property {boolean} + */ + isFocused = false; + /** * [constructor] * @@ -29,6 +45,11 @@ class Controls { this.controlsEl.addEventListener('mouseleave', this.mouseleaveHandler); this.controlsEl.addEventListener('focusin', this.focusinHandler); this.controlsEl.addEventListener('focusout', this.focusoutHandler); + + if (Browser.hasTouch()) { + this.containerEl.addEventListener('touchstart', this.mousemoveHandler); + this.controlsEl.addEventListener('click', this.clickHandler); + } } /** @@ -42,6 +63,11 @@ class Controls { this.controlsEl.removeEventListener('focusin', this.focusinHandler); this.controlsEl.removeEventListener('focusout', this.focusoutHandler); + if (Browser.hasTouch()) { + this.containerEl.removeEventListener('touchstart', this.mousemoveHandler); + this.controlsEl.removeEventListener('click', this.clickHandler); + } + this.buttonRefs.forEach((ref) => { ref.button.removeEventListener('click', ref.handler); }); @@ -55,7 +81,11 @@ class Controls { * @return {boolean} true if element is a preview control button */ isPreviewControlButton(element) { - return !!element && element.classList.contains('bp-controls-btn'); + return ( + !!element && + (element.classList.contains(CONTROLS_BUTTON_CLASS) || + element.parentNode.classList.contains(CONTROLS_BUTTON_CLASS)) + ); } /** @@ -67,7 +97,7 @@ class Controls { this.controlDisplayTimeoutId = setTimeout(() => { clearTimeout(this.controlDisplayTimeoutId); - if (this.blockHiding) { + if (!this.shouldHide) { this.resetTimeout(); } else { this.containerEl.classList.remove(SHOW_PREVIEW_CONTROLS_CLASS); @@ -97,7 +127,7 @@ class Controls { * @return {void} */ mouseenterHandler = () => { - this.blockHiding = true; + this.shouldHide = false; }; /** @@ -107,7 +137,7 @@ class Controls { * @return {void} */ mouseleaveHandler = () => { - this.blockHiding = false; + this.shouldHide = true; }; /** @@ -120,6 +150,8 @@ class Controls { // When we focus onto a preview control button, show controls if (this.isPreviewControlButton(event.target)) { this.containerEl.classList.add(SHOW_PREVIEW_CONTROLS_CLASS); + this.isFocused = true; + this.shouldHide = false; } }; @@ -132,10 +164,23 @@ class Controls { focusoutHandler = (event) => { // When we focus out of a control button and aren't focusing onto another control button, hide the controls if (this.isPreviewControlButton(event.target) && !this.isPreviewControlButton(event.relatedTarget)) { - this.containerEl.classList.remove(SHOW_PREVIEW_CONTROLS_CLASS); + this.isFocused = false; + this.shouldHide = true; } }; + /** + * Handles click events for the control bar. + * + * @param {Event} event - A DOM-normalized event object. + * @return {void} + */ + clickHandler = (event) => { + event.preventDefault(); + // If we are not focused in on the page num input, allow hiding after timeout + this.shouldHide = !this.isFocused; + }; + /** * Adds buttons to controls * @@ -153,7 +198,7 @@ class Controls { const button = document.createElement('button'); button.setAttribute('aria-label', text); button.setAttribute('title', text); - button.className = `bp-controls-btn ${classList}`; + button.className = `${CONTROLS_BUTTON_CLASS} ${classList}`; button.addEventListener('click', handler); if (buttonContent) { diff --git a/src/lib/Controls.scss b/src/lib/Controls.scss index 5183def26..97849cdfb 100644 --- a/src/lib/Controls.scss +++ b/src/lib/Controls.scss @@ -18,9 +18,7 @@ transition: opacity .5s; } -.box-show-preview-controls .bp-controls, -.bp-controls:hover, -.bp-controls:focus { +.box-show-preview-controls .bp-controls { opacity: 1; } diff --git a/src/lib/__tests__/Controls-test.js b/src/lib/__tests__/Controls-test.js index c2ef01ae0..3fbcb5a46 100644 --- a/src/lib/__tests__/Controls-test.js +++ b/src/lib/__tests__/Controls-test.js @@ -74,11 +74,15 @@ describe('lib/Controls', () => { describe('isPreviewControlButton()', () => { it('should determine whether the element is a preview control button', () => { + let parent = null; let element = null; expect(controls.isPreviewControlButton(element)).to.be.false; + parent = document.createElement('div'); element = document.createElement('div'); element.className = 'bp-controls-btn'; + parent.appendChild(element); + expect(controls.isPreviewControlButton(element)).to.be.true; element.className = ''; @@ -104,8 +108,8 @@ describe('lib/Controls', () => { expect(clearTimeoutStub).to.be.calledTwice; }); - it('should call resetTimeout again if block hiding is true', () => { - controls.blockHiding = true; + it('should call resetTimeout again if should hide is false', () => { + controls.shouldHide = false; controls.resetTimeout(); const resetTimeoutStub = sandbox.stub(controls, 'resetTimeout'); @@ -114,8 +118,8 @@ describe('lib/Controls', () => { expect(resetTimeoutStub).to.be.called; }); - it('should not remove the preview controls class if block hiding is true', () => { - controls.blockHiding = true; + it('should not remove the preview controls class if should hide is false', () => { + controls.shouldHide = false; controls.containerEl.className = SHOW_PREVIEW_CONTROLS_CLASS; controls.resetTimeout(); @@ -124,8 +128,8 @@ describe('lib/Controls', () => { expect(controls.containerEl.classList.contains(SHOW_PREVIEW_CONTROLS_CLASS)).to.be.true; }); - it('should remove the preview controls class if block hiding is false', () => { - controls.blockHiding = false; + it('should remove the preview controls class if should hide is true', () => { + controls.shouldHide = true; controls.containerEl.className = SHOW_PREVIEW_CONTROLS_CLASS; controls.resetTimeout(); @@ -135,7 +139,7 @@ describe('lib/Controls', () => { }); it('should blur the controls if they are active', () => { - controls.blockHiding = false; + controls.shouldHide = true; const containsStub = sandbox.stub(controls.controlsEl, 'contains').returns(true); const blurStub = sandbox.stub(document.activeElement, 'blur'); @@ -151,7 +155,7 @@ describe('lib/Controls', () => { it('should make block hiding true', () => { controls.mouseenterHandler(); - expect(controls.blockHiding).to.be.true; + expect(controls.shouldHide).to.be.false; }); }); @@ -159,17 +163,19 @@ describe('lib/Controls', () => { it('should make block hiding false', () => { controls.mouseleaveHandler(); - expect(controls.blockHiding).to.be.false; + expect(controls.shouldHide).to.be.true; }); }); describe('focusinHandler()', () => { - it('should add the controls class if the element is a preview control button', () => { + it('should add the controls class, block hiding, and set the controls to be focused if the element is a preview control button', () => { const isControlButtonStub = sandbox.stub(controls, 'isPreviewControlButton').returns(true); controls.focusinHandler('event'); expect(isControlButtonStub).to.be.called; expect(controls.containerEl.classList.contains(SHOW_PREVIEW_CONTROLS_CLASS)).to.be.true; + expect(controls.shouldHide).to.be.false; + expect(controls.isFocused).to.be.true; }); it('should not add the controls class if the element is not a preview control button', () => { @@ -190,7 +196,8 @@ describe('lib/Controls', () => { controls.focusoutHandler('event'); expect(isControlButtonStub).to.be.called; - expect(controls.containerEl.classList.contains(SHOW_PREVIEW_CONTROLS_CLASS)).to.be.false; + expect(controls.shouldHide).to.be.true; + expect(controls.isFocused).to.be.false; }); it('should not remove the controls class if the element is not a preview control button and the related target is not', () => { @@ -227,6 +234,23 @@ describe('lib/Controls', () => { }); }); + describe('clickHandler()', () => { + const event = { + preventDefault: sandbox.stub() + }; + + it('should prevent default', () => { + controls.clickHandler(event); + expect(event.preventDefault).to.be.called; + }); + + it('should stop block hiding if the controls are not focused', () => { + controls.isFocused = false; + controls.clickHandler(event); + expect(controls.shouldHide).to.be.true; + }); + }); + describe('add()', () => { it('should create a button with the right attributes', () => { const btn = controls.add('test button', sandbox.stub(), 'test1', 'test content');