Source: drawer.js

drawer.js

const Penner = require('penner')
const moment = require('moment')
const Events = require('eventemitter3')

const utils = require('./utils')
let defaults = require('./defaults')

class Drawer extends Events
{
    /**
     * @param {object} [options]
     * @param {HTMLElement} [options.parent=document.body] where to attach the drawer
     * @param {boolean} [options.auto=true] automatically open and close the drawer based on acceleration or click
     * @param {string} [options.side=left] (left, right, top, or bottom)
     * @param {number} [options.size] size (in pixels) of opened drawer
     * @param {number} [options.barSize=20] size (in pixels) of drag bar
     * @param {boolean} [options.full=true] whether the drawer should take up the full width or height
     * @param {boolean} [options.open] start with drawer open
     * @param {boolean} [options.noInteraction] open drawer only programmatically
     * @param {object} [options.styles] styles for div
     * @param {string} [options.className] class name for div
     * @param {string} [options.barBackground=rgba(0,0,0,0.25)] background setting for drag bar
     * @param {string} [options.ease=easeInOutSine] ease for animated opening
     * @param {number} [options.duration=500] animate time for opening drawer on click
     * @param {number} [options.threshold=10] number of pixels before move starts
     * @param {number} [options.timeRecent=100] maximum time in milliseconds to use when calculating acceleration
     * @param {number} [options.minVelocty=0.5] minimum velocity (pixels/millisecond) for opening and closing after a drag
     * @param {string} [options.content] HTML content for the drawer
     * @param {object} [options.contentStyles] styles for content of drawer
     * @param {boolean} [options.closeOnEscape=true] close the drawer when escape is pressed
     * @fires opening
     * @fires closing
     * @fires opened
     * @fires closed
     * @fires location
     */
    constructor(options)
    {
        super()
        this.options = utils.options(options, Drawer._defaults)
        this.options.parent = this.options.parent || document.body
        this._size = this.options.size
        this._barSize = this.options.barSize
        this.ease = this.options.ease ? (typeof this.options.ease === 'function' ? this.options.ease : Penner[this.options.ease]) : Penner['easeInOutSine']
        this.all = ['left', 'right', 'top', 'bottom']

        /**
         * minimum velocity (pixels/millisecond) for opening and closing after a drag
         * @type (number)
         */
        this.minVelocity = this.options.minVelocity

        /**
         * maximum time in milliseconds to use when calculating acceleration
         * @type {number}
         */
        this.timeRecent = this.options.timeRecent

        /**
         * automatically open and close the drawer based on acceleration or click
         * @type {boolean}
         */
        this.auto = this.options.auto

        /**
         * number of pixels before move starts after a mousedown or touchstart
         * @type {number}
         */
        this.threshold = this.options.threshold

        /**
         * animate time for opening drawer on click
         * @type {number}
         **/
        this.duration = this.options.duration

        /**
         * Main drawer element
         * @type {HTMLElement}
         */
        this.div = utils.html({
            parent: this.options.parent,
            defaultStyles: {
                'position': 'fixed',
                'background': 'white'
            },
            styles: this.options.styles
        })

        /**
         * use this to add content to the div
         * @type {HTMLElement}
         */
        this.content = utils.html({ parent: this.div, styles: this.options.contentStyles, html: this.options.content })

        if (this.options.className)
        {
            this.div.className = this.options.className
        }

        /**
         * Bar drawer element
         * @type {HTMLElement}
         */
        this.bar = utils.html({
            parent: this.options.parent,
            styles: {
                'position': 'fixed',
                'background': this.options.barBackground || 'rgba(0,0,0,0.15)',
                'cursor': ['drag', '-webkit-grab', 'pointer']
            }
        })
        if (!this.options.noInteraction)
        {
            this._addListeners()
        }
        else
        {
            this.bar.style.display = 'none'
        }
        this._side = this.options.side
        this.vertical = this.side === 'left' || this.side === 'right'
        this._setSide(true)
        this.location = 0
        if (this.options.open)
        {
            this.open(true)
        }
        else
        {
            this.close(true)
        }
    }

    /**
     * call this to update the contents or after the contents of the drawer are updated (handles sizing)
     * @param {string} [content]
     */
    updateContents(content)
    {
        if (content)
        {
            this.content.innerHTML = content
        }
        this._setSide(true)
    }

    get side()
    {
        return this._side
    }
    set side(value)
    {
        if (this._side !== value)
        {
            this._side = value
            this.vertical = value === 'left' || value === 'right'
            this._setSide()
            if (this.opened)
            {
                this.open(true)
            }
        }
    }

    get size()
    {
        if (this._size)
        {
            return this._size
        }
        else
        {
            if (this.side === 'left' || this.side === 'right')
            {
                return this.div.offsetWidth
            }
            else
            {
                return this.div.offsetHeight
            }
        }
    }
    set size(value)
    {
        this._size = value
        this._setSide(true)
        if (this.opened)
        {
            this.open(false)
        }
    }

    get barSize()
    {
        return this._barSize
    }
    set barSize(value)
    {
        this._barSize = value
        this._setSide(true)
        if (this.opened)
        {
            this.open(false)
        }
    }

    /**
     * change top value of drawer
     * @type {number}
     */
    get top()
    {
        return this.div.style.top
    }
    set top(value)
    {
        this.div.style.top = this.bar.style.top = value
    }

    /**
     * change left value of drawer
     * @type {number}
     */
    get left()
    {
        return this.div.style.left
    }
    set left(value)
    {
        this.div.style.left = this.bar.style.left = value
    }

    /**
     * change bottom value of drawer
     * @type {number}
     */
    get bottom()
    {
        return this.div.style.bottom
    }
    set bottom(value)
    {
        this.div.style.bottom = this.bar.style.bottom = value
    }

    /**
     * change right value of drawer
     * @type {number}
     */
    get right()
    {
        return this.div.style.right
    }
    set right(value)
    {
        this.div.style.right = this.bar.style.right = value
    }

    _setSide(noReset)
    {
        if (!noReset)
        {
            for (let side of this.all)
            {
                if (side !== this.side)
                {
                    this.div.style[side] = 'unset'
                    this.bar.style[side] = 'unset'
                }
            }
        }
        if (this.vertical)
        {
            this.div.style.width = this._size ? this._size + 'px' : 'auto'
            this.bar.style.width = this.barSize + 'px'
            if (this.opened)
            {
                this.div.style[this._side] = 0
                this.bar.style[this._side] = this.div.offsetWidth + 'px'
            }
            else
            {
                this.div.style[this._side] = -this.size + 'px'
                this.bar.style[this._side] = 0
            }
            if (this.options.full)
            {
                this.div.style.top = this.bar.style.top = 0
                this.div.style.height = this.bar.style.height = '100vh'
            }
            else
            {
                this.bar.style.height = this.div.offsetHeight + 'px'
                this.bar.style.top = this.div.offsetTop + 'px'
            }
        }
        else
        {
            this.div.style.height = this._size ? this._size + 'px' : 'auto'
            this.bar.style.height = this.barSize + 'px'
            if (this.opened)
            {
                this.div.style[this._side] = 0
                this.bar.style[this._side] = this.div.offsetHeight + 'px'
            }
            else
            {
                this.div.style[this._side] = -this.size + 'px'
                this.bar.style[this._side] = 0
            }
            if (this.options.full)
            {
                this.div.style.left = this.bar.style.left = 0
                this.div.style.width = this.bar.style.width = '100vw'
            }
            else
            {
                this.bar.style.width = this.div.offsetWidth + 'px'
                this.bar.style.left = this.div.offsetLeft + 'px'
            }
        }
        if (this.opened)
        {
            this.open(true)
        }
        else
        {
            this.close(true)
        }
    }

    _addListeners()
    {
        this.bar.addEventListener('mousedown', (e) => this._down(e))
        this.bar.addEventListener('touchstart', (e) => this._down(e))
        this.bar.addEventListener('mousemove', (e) => this._move(e), { passive: false })
        this.bar.addEventListener('touchmove', (e) => this._move(e), { passive: false })
        document.body.addEventListener('mousemove', (e) => this._move(e), { passive: false })
        document.body.addEventListener('touchmove', (e) => this._move(e), { passive: false })
        this.bar.addEventListener('mouseup', (e) => this._up(e))
        this.bar.addEventListener('touchend', (e) => this._up(e))
        document.body.addEventListener('mouseup', (e) => this._up(e))
        document.body.addEventListener('mouseleave', (e) => this._up(e))
        document.body.addEventListener('touchend', (e) => this._up(e))
        if (this.options.closeOnEscape)
        {
            document.addEventListener('keydown', (e) => this._escapeClose(e))
        }
    }

    _escapeClose(e)
    {
        if (e.code === 'Escape')
        {
            this.close()
        }
    }

    _down(e)
    {
        this.down = { x: e.pageX, y: e.pageY }
        this.changes = []
        this.moving = false
        if (this.bar.style.cursor === 'grab' || this.bar.style.cursor === '-webkit-grab')
        {
            utils.styles(this.bar, { 'cursor': ['grabbing', '-webkit-grabbing' ]})
        }
        e.preventDefault()
    }

    _checkThreshold(e)
    {
        if (!this.moving)
        {
            if (this.side === 'left' || this.side === 'right')
            {
                if (Math.abs(e.pageX - this.down.x) >= this.threshold)
                {
                    this.moving = true
                    return true
                }
                else
                {
                    return false
                }
            }
            else
            {
                if (Math.abs(e.pageY - this.down.y) >= this.threshold)
                {
                    this.moving = true
                    return true
                }
                else
                {
                    return false
                }
            }
        }
        else
        {
            return true
        }
    }

    _move(e)
    {
        if (this.down && this._checkThreshold(e))
        {
            let value
            if (this.vertical)
            {
                if (this.side === 'left')
                {
                    value = e.pageX - this.barSize / 2
                }
                else
                {
                    value = window.innerWidth - e.pageX + this.barSize / 2
                }
            }
            else
            {
                if (this.side === 'top')
                {
                    value = e.pageY - this.barSize / 2
                }
                else
                {
                    value = window.innerHeight - e.pageY + this.barSize / 2
                }
            }
            value -= this.size
            value = value > 0 ? 0 : value
            value = value < -this.size ? -this.size : value
            this.location = value
            this.changes.push({ value, time: moment() })
            e.preventDefault()
        }
    }

    /**
     * location of the drawer relative to the edge
     * @type {number}
     */
    get location()
    {
        return this._location
    }
    set location(value)
    {
        if (this._location !== value)
        {
            this.div.style[this.side] = value + 'px'
            this.bar.style[this.side] = value + this.size + 'px'
            this._location = value
            if (value === 0)
            {
                this.opened = true
                this.emit('opened', this)
            }
            else if (value === -this.size)
            {
                this.opened = false
                this.emit('closed', this)
            }
            this.emit('location', value + this.size, this)
        }
    }

    _getVelocity()
    {
        const now = moment()
        let current = this.changes[this.changes.length - 1]
        for (let i = this.changes.length - 2; i >= 0; i--)
        {
            if (now.diff(this.changes[i].time) > this.timeRecent)
            {
                i = i < this.changes.length - 1 ? i + 1 : i
                break
            }
            else
            {
                current = this.changes[i]
            }
        }
        const last = this.changes[this.changes.length - 1]
        const deltaDistance = current.value - last.value
        const deltaTime = current.time.diff(last.time)
        return deltaDistance / deltaTime
    }

    _up()
    {
        if (this.down)
        {
            if (!this.moving)
            {
                this.toggle()
            }
            else if (this.auto)
            {
                if (this.changes.length > 2)
                {
                    let velocity = this._getVelocity()
                    if (velocity === 0)
                    {
                        this._forceToggle()
                    }
                    else
                    {
                        if (Math.abs(velocity) < this.minVelocity)
                        {
                            velocity = this.minVelocity * (velocity < 0 ? -1 : 1)
                        }
                        if (velocity > 0)
                        {
                            this._openAnimate(velocity)
                        }
                        else
                        {
                            this._closeAnimate(velocity)
                        }
                    }
                }
                else
                {
                    this._forceToggle()
                }
            }
            if (this.bar.style.cursor === 'grabbing' || this.bar.style.cursor === '-webkit-grabbing')
            {
                utils.styles(this.bar, { 'cursor': ['grab', '-webkit-grab'] })
            }
            this.down = false
        }
    }

    _forceToggle()
    {
        if (this.div.offsetWidth > this.size / 2)
        {
            this.open()
        }
        else
        {
            this.close()
        }
    }

    /**
     * toggle the drawer (close if open, and open if closed)
     * @param {boolean} [noAnimate] do not animate
     */
    toggle(noAnimate)
    {
        if (this.opened)
        {
            this.close(noAnimate)
        }
        else
        {
            this.open(noAnimate)
        }
    }

    /**
     * open the drawer
     * @param {boolean} [noAnimate]
     */
    open(noAnimate)
    {
        if (this.down || !this.opened)
        {
            this.easing = null
            if (noAnimate)
            {
                this.div.style[this.side] = 0
                this.bar.style[this.side] = this.size + 'px'
                this.emit('opened', this)
            }
            else
            {
                this._openAnimate()
                this.emit('opening', this)
            }
            this.opened = true
            if (this.options.closeOnBlur)
            {
                this.div.focus()
            }
        }
    }

    _openAnimate(velocity)
    {
        const duration = velocity ? Math.abs((0 - this.size) / velocity) : this.duration
        this.easing = { start: this.location, end: 0, time: moment(), ease: velocity ? Penner.linear : this.ease, duration, type: 'open' }
        requestAnimationFrame(() => this.update())
    }

    /**
     * close the drawer
     * @param {boolean} [noAnimate]
     */
    close(noAnimate)
    {
        if (this.down || this.opened)
        {
            this.easing = null
            if (noAnimate)
            {
                this.location = -this.size
                this.emit('closed', this)
            }
            else
            {
                this._closeAnimate()
                this.emit('closing', this)
            }
            this.opened = false
        }
    }

    _closeAnimate(velocity)
    {
        const start = this.location
        const duration = velocity ? Math.abs((start - this.size) / velocity) : this.duration
        this.easing = { start, end: -this.size, time: moment(), ease: velocity ? Penner.linear : this.ease, duration, type: 'close' }
        requestAnimationFrame(() => this.update())
    }

    update()
    {
        if (this.easing)
        {
            let duration = moment().diff(this.easing.time)
            duration = duration > this.easing.duration ? this.easing.duration : duration
            this.location = this.ease(duration, this.easing.start, this.easing.end - this.easing.start, this.easing.duration)
            if (duration === this.easing.duration)
            {
                if (this.easing.type === 'open')
                {
                    this.emit('opened', this)
                }
                else
                {
                    this.emit('closed', this)
                }
                this.easing = null
            }
            else
            {
                requestAnimationFrame(() => this.update())
            }
        }
    }

    /**
     * defaults for Drawer
     * @type {object}
     */
    static get defaults()
    {
        return Drawer._defaults
    }
    static set defaults(value)
    {
        Drawer._defaults = value
    }
}

Drawer.defaults = defaults

module.exports = Drawer

/**
  * trigger when the drawer is opening from UI click or calling drawer.open()
  * @event Drawer~opening
  * @type {object}
  * @property {Drawer} drawer
  */

/**
  * trigger when drawing is closing from UI click or calling drawer.close()
  * @event Drawer~closing
  * @type {object}
  * @property {Drawer} drawer
  */

/**
  * trigger when drawer is fully opened because of UI interaction or drawer.open() finishing
  * @event Drawer~opened
  * @type {object}
  * @property {Drawer} drawer
  */

/**
  * trigger when drawer is fully closed because of UI interaction or drawer.close() finishing
  * @event Drawer~closed
  * @type {object}
  * @property {Drawer} drawer
  */

/**
  * trigger when drawer's location changes because of UI interaction or drawer.close/open
  * @event Drawer~location
  * @type {object}
  * @property {number} location of drawer
  * @property {Drawer} drawer
  */