diff --git a/scripts/test/setup.js b/scripts/test/setup.js index e69de29b..ff3626b7 100644 --- a/scripts/test/setup.js +++ b/scripts/test/setup.js @@ -0,0 +1,9 @@ +const requestAnimationFrameTimeout = 15; + +window.requestAnimationFrame = (callback) => { + return setTimeout(callback, requestAnimationFrameTimeout); +}; + +window.cancelAnimationFrame = (id) => { + return clearTimeout(id); +}; diff --git a/src/Draggable/Draggable.js b/src/Draggable/Draggable.js index 7addf841..edd8e287 100644 --- a/src/Draggable/Draggable.js +++ b/src/Draggable/Draggable.js @@ -1,6 +1,6 @@ import {closest} from 'shared/utils'; -import {Accessibility, Mirror} from './Plugins'; +import {Accessibility, Mirror, AutoScroll} from './Plugins'; import { MouseSensor, @@ -68,9 +68,10 @@ export default class Draggable { * @property {Object} Plugins * @property {Mirror} Plugins.Mirror * @property {Accessibility} Plugins.Accessibility + * @property {AutoScroll} Plugins.AutoScroll * @type {Object} */ - static Plugins = {Mirror, Accessibility}; + static Plugins = {Mirror, Accessibility, AutoScroll}; /** * Draggable constructor. @@ -127,7 +128,7 @@ export default class Draggable { document.addEventListener('drag:stop', this[onDragStop], true); document.addEventListener('drag:pressure', this[onDragPressure], true); - this.addPlugin(...[Mirror, Accessibility, ...this.options.plugins]); + this.addPlugin(...[Mirror, Accessibility, AutoScroll, ...this.options.plugins]); this.addSensor(...[MouseSensor, TouchSensor, ...this.options.sensors]); const draggableInitializedEvent = new DraggableInitializedEvent({ diff --git a/src/Draggable/Plugins/AutoScroll/AutoScroll.js b/src/Draggable/Plugins/AutoScroll/AutoScroll.js new file mode 100644 index 00000000..9f4c8410 --- /dev/null +++ b/src/Draggable/Plugins/AutoScroll/AutoScroll.js @@ -0,0 +1,217 @@ +import {closest} from 'shared/utils'; + +export const onDragStart = Symbol('onDragStart'); +export const onDragMove = Symbol('onDragMove'); +export const onDragStop = Symbol('onDragStop'); +export const scroll = Symbol('scroll'); + +/** + * AutoScroll default options + * @property {Object} defaultOptions + * @property {Number} defaultOptions.speed + * @property {Number} defaultOptions.sensitivity + * @type {Object} + */ +export const defaultOptions = { + speed: 10, + sensitivity: 30, +}; + +/** + * AutoScroll plugin which scrolls the closest scrollable parent + * @class AutoScroll + * @module AutoScroll + */ +export default class AutoScroll { + + /** + * AutoScroll constructor. + * @constructs AutoScroll + * @param {Draggable} draggable - Draggable instance + */ + constructor(draggable) { + + /** + * Draggable instance + * @property draggable + * @type {Draggable} + */ + this.draggable = draggable; + + /** + * AutoScroll options + * @property {Object} options + * @property {Number} options.speed + * @property {Number} options.sensitivity + * @type {Object} + */ + this.options = { + ...defaultOptions, + ...this.getOptions(), + }; + + /** + * Keeps current mouse position + * @property {Object} currentMousePosition + * @property {Number} currentMousePosition.clientX + * @property {Number} currentMousePosition.clientY + * @type {Object|null} + */ + this.currentMousePosition = null; + + /** + * Scroll animation frame + * @property scrollAnimationFrame + * @type {Number|null} + */ + this.scrollAnimationFrame = null; + + /** + * Closest scrollable element + * @property scrollableElement + * @type {HTMLElement|null} + */ + this.scrollableElement = null; + + /** + * Animation frame looking for the closest scrollable element + * @property findScrollableElementFrame + * @type {Number|null} + */ + this.findScrollableElementFrame = null; + + this[onDragStart] = this[onDragStart].bind(this); + this[onDragMove] = this[onDragMove].bind(this); + this[onDragStop] = this[onDragStop].bind(this); + this[scroll] = this[scroll].bind(this); + } + + /** + * Attaches plugins event listeners + */ + attach() { + this.draggable + .on('drag:start', this[onDragStart]) + .on('drag:move', this[onDragMove]) + .on('drag:stop', this[onDragStop]); + } + + /** + * Detaches plugins event listeners + */ + detach() { + this.draggable + .off('drag:start', this[onDragStart]) + .off('drag:move', this[onDragMove]) + .off('drag:stop', this[onDragStop]); + } + + /** + * Returns options passed through draggable + * @return {Object} + */ + getOptions() { + return this.draggable.options.autoScroll || {}; + } + + /** + * Drag start handler. Finds closest scrollable parent in separate frame + * @private + */ + [onDragStart](dragEvent) { + this.findScrollableElementFrame = requestAnimationFrame(() => { + this.scrollableElement = closestScrollableElement(dragEvent.source); + }); + } + + /** + * Drag move handler. Remembers mouse position and initiates scrolling + * @private + */ + [onDragMove](dragEvent) { + if (!this.scrollableElement) { + return; + } + + const sensorEvent = dragEvent.sensorEvent; + + this.currentMousePosition = { + clientX: sensorEvent.clientX, + clientY: sensorEvent.clientY, + }; + + this.scrollAnimationFrame = requestAnimationFrame(this[scroll]); + } + + /** + * Drag stop handler. Cancels scroll animations and resets state + * @private + */ + [onDragStop]() { + cancelAnimationFrame(this.scrollAnimationFrame); + cancelAnimationFrame(this.findScrollableElementFrame); + + this.scrollableElement = null; + this.scrollAnimationFrame = null; + this.findScrollableElementFrame = null; + this.currentMousePosition = null; + } + + /** + * Scroll function that does the heavylifting + * @private + */ + [scroll]() { + if (!this.scrollableElement) { + return; + } + + cancelAnimationFrame(this.scrollAnimationFrame); + + const windowHeight = window.innerHeight; + const windowWidth = window.innerWidth; + const rect = this.scrollableElement.getBoundingClientRect(); + + let offsetY = (Math.abs(rect.bottom - this.currentMousePosition.clientY) <= this.options.sensitivity) - (Math.abs(rect.top - this.currentMousePosition.clientY) <= this.options.sensitivity); + let offsetX = (Math.abs(rect.right - this.currentMousePosition.clientX) <= this.options.sensitivity) - (Math.abs(rect.left - this.currentMousePosition.clientX) <= this.options.sensitivity); + + if (!offsetX && !offsetY) { + offsetX = (windowWidth - this.currentMousePosition.clientX <= this.options.sensitivity) - (this.currentMousePosition.clientX <= this.options.sensitivity); + offsetY = (windowHeight - this.currentMousePosition.clientY <= this.options.sensitivity) - (this.currentMousePosition.clientY <= this.options.sensitivity); + } + + this.scrollableElement.scrollTop += offsetY * this.options.speed; + this.scrollableElement.scrollLeft += offsetX * this.options.speed; + + this.scrollAnimationFrame = requestAnimationFrame(this[scroll]); + } +} + +/** + * Checks if element has overflow + * @param {HTMLElement} element + * @return {Boolean} + * @private + */ +function hasOverflow(element) { + const overflowRegex = /(auto|scroll)/; + const computedStyles = getComputedStyle(element, null); + + const overflow = computedStyles.getPropertyValue('overflow') + + computedStyles.getPropertyValue('overflow-y') + + computedStyles.getPropertyValue('overflow-x'); + + return overflowRegex.test(overflow); +} + +/** + * Finds closest scrollable element + * @param {HTMLElement} element + * @return {HTMLElement} + * @private + */ +function closestScrollableElement(element) { + const scrollableElement = closest(element, (currentElement) => hasOverflow(currentElement)); + + return scrollableElement || document.scrollingElement || document.documentElement || null; +} diff --git a/src/Draggable/Plugins/AutoScroll/README.md b/src/Draggable/Plugins/AutoScroll/README.md new file mode 100644 index 00000000..6fcbf497 --- /dev/null +++ b/src/Draggable/Plugins/AutoScroll/README.md @@ -0,0 +1,48 @@ +## AutoScroll + +The auto scroll plugin listens to Draggables `drag:start`, `drag:move` and `drag:stop` events to determine when to scroll +the document it's on. +This plugin is used by draggable by default, but could potentially be replaced with a custom plugin. + +### API + +**`new AutoScroll(draggable: Draggable): AutoScroll`** +To create an auto scroll plugin instance. + +### Options + +**`speed {Number}`** +Determines the scroll speed. Default: `10` + +**`sensitivity {Number}`** +Determines the sensitivity of scrolling. Default: `30` + +### Examples + +```js +import {Draggable} from '@shopify/draggable'; + +const draggable = new Draggable(document.querySelectorAll('ul'), { + draggable: 'li', + autoScroll: { + speed: 6, + sensitivity: 12, + }, +}); +``` + +#### Removing the plugin + +```js +import {Draggable} from '@shopify/draggable'; + +const draggable = new Draggable(document.querySelectorAll('ul'), { + draggable: 'li', +}); + +// Removes AutoScroll plugin +draggable.removePlugin(Draggable.Plugin.AutoScroll); + +// Adds custom scroll plugin +draggable.addPlugin(CustomScrollPlugin); +``` diff --git a/src/Draggable/Plugins/AutoScroll/index.js b/src/Draggable/Plugins/AutoScroll/index.js new file mode 100644 index 00000000..9d61f573 --- /dev/null +++ b/src/Draggable/Plugins/AutoScroll/index.js @@ -0,0 +1,6 @@ +import AutoScroll, {defaultOptions} from './AutoScroll'; + +export default AutoScroll; +export { + defaultOptions as defaultAutoScrollOptions, +}; diff --git a/src/Draggable/Plugins/Mirror/index.js b/src/Draggable/Plugins/Mirror/index.js index e43bd7fd..d3370c3e 100644 --- a/src/Draggable/Plugins/Mirror/index.js +++ b/src/Draggable/Plugins/Mirror/index.js @@ -2,5 +2,5 @@ import Mirror, {defaultOptions} from './Mirror'; export default Mirror; export { - defaultOptions as defaultMirrorOption, + defaultOptions as defaultMirrorOptions, }; diff --git a/src/Draggable/Plugins/index.js b/src/Draggable/Plugins/index.js index f886d4b6..e25778e3 100644 --- a/src/Draggable/Plugins/index.js +++ b/src/Draggable/Plugins/index.js @@ -1,8 +1,11 @@ -import Mirror, {defaultMirrorOption} from './Mirror'; +import Mirror, {defaultMirrorOptions} from './Mirror'; +import AutoScroll, {defaultAutoScrollOptions} from './AutoScroll'; import Accessibility from './Accessibility'; export { Mirror, - defaultMirrorOption, + defaultMirrorOptions, + AutoScroll, + defaultAutoScrollOptions, Accessibility, }; diff --git a/src/Draggable/tests/Draggable.test.js b/src/Draggable/tests/Draggable.test.js index 3267f82f..13898a0d 100644 --- a/src/Draggable/tests/Draggable.test.js +++ b/src/Draggable/tests/Draggable.test.js @@ -19,7 +19,11 @@ import { DraggableDestroyEvent, } from './../DraggableEvent'; -import {Accessibility, Mirror} from './../Plugins'; +import { + Accessibility, + Mirror, + AutoScroll, +} from './../Plugins'; import { MouseSensor, @@ -54,6 +58,7 @@ describe('Draggable', () => { expect(Draggable.Plugins).toBeDefined(); expect(Draggable.Plugins.Mirror).toEqual(Mirror); expect(Draggable.Plugins.Accessibility).toEqual(Accessibility); + expect(Draggable.Plugins.AutoScroll).toEqual(AutoScroll); }); }); @@ -106,13 +111,16 @@ describe('Draggable', () => { const newInstance = new Draggable(); expect(newInstance.plugins.length) - .toBe(2); + .toBe(3); expect(newInstance.plugins[0]) .toBeInstanceOf(Mirror); expect(newInstance.plugins[1]) .toBeInstanceOf(Accessibility); + + expect(newInstance.plugins[2]) + .toBeInstanceOf(AutoScroll); }); test('should attach custom plugins', () => { @@ -123,9 +131,9 @@ describe('Draggable', () => { }); expect(newInstance.plugins.length) - .toBe(3); + .toBe(4); - const customPlugin = newInstance.plugins[2]; + const customPlugin = newInstance.plugins[3]; expect(customPlugin.draggable).toBe(newInstance); @@ -199,12 +207,6 @@ describe('Draggable', () => { newInstance.destroy(); - expect(expectedPlugins[2].detachWasCalled) - .toBe(true); - - expect(expectedPlugins[2].numTimesDetachCalled) - .toBe(1); - expect(expectedPlugins[3].detachWasCalled) .toBe(true); @@ -216,6 +218,12 @@ describe('Draggable', () => { expect(expectedPlugins[4].numTimesDetachCalled) .toBe(1); + + expect(expectedPlugins[5].detachWasCalled) + .toBe(true); + + expect(expectedPlugins[5].numTimesDetachCalled) + .toBe(1); }); test('should remove all sensor event listeners', () => { diff --git a/src/shared/utils/index.js b/src/shared/utils/index.js index b92ac169..98ebc319 100644 --- a/src/shared/utils/index.js +++ b/src/shared/utils/index.js @@ -1,7 +1,5 @@ import closest from './closest'; -import scroll from './scroll'; export { closest, - scroll, }; diff --git a/src/shared/utils/scroll/README.md b/src/shared/utils/scroll/README.md deleted file mode 100644 index d9435846..00000000 --- a/src/shared/utils/scroll/README.md +++ /dev/null @@ -1 +0,0 @@ -## scroll diff --git a/src/shared/utils/scroll/index.js b/src/shared/utils/scroll/index.js deleted file mode 100644 index 69ca63b9..00000000 --- a/src/shared/utils/scroll/index.js +++ /dev/null @@ -1,3 +0,0 @@ -import scroll from './scroll'; - -export default scroll; diff --git a/src/shared/utils/scroll/scroll.js b/src/shared/utils/scroll/scroll.js deleted file mode 100644 index acca23bc..00000000 --- a/src/shared/utils/scroll/scroll.js +++ /dev/null @@ -1,18 +0,0 @@ -let scrollAnimationFrame; - -export default function scroll(element, {clientX, clientY, speed, sensitivity}) { - if (scrollAnimationFrame) { - cancelAnimationFrame(scrollAnimationFrame); - } - - function scrollFn() { - const rect = element.getBoundingClientRect(); - const offsetY = (Math.abs(rect.bottom - clientY) <= sensitivity) - (Math.abs(rect.top - clientY) <= sensitivity); - const offsetX = (Math.abs(rect.right - clientX) <= sensitivity) - (Math.abs(rect.left - clientX) <= sensitivity); - element.scrollTop += offsetY * speed; - element.scrollLeft += offsetX * speed; - scrollAnimationFrame = requestAnimationFrame(scrollFn); - } - - scrollAnimationFrame = requestAnimationFrame(scrollFn); -}