Skip to content
Snippets Groups Projects
actionmenu.js 7.17 KiB
Newer Older
/*jslint esversion: 6*/

/**
 * Determine whether the menu should be opened in dialog or regular layout.
 * @type {[type]}
 */
function determineBreakpoint(element) {
    return $(element).closest('.ui-dialog-content').length > 0 ? '.ui-dialog-content' : '#layout_content';
}

/**
 * Obtain all parents of the given element that have scrollable content.
 */
function getScrollableParents(element, menu_width, menu_height) {
    const offset     = $(element).offset();
    const breakpoint = determineBreakpoint(element);

    var elements = [];
    $(element).parents().each(function () {
        // Stop at breakpoint
        if ($(this).is(breakpoint)) {
            return false;
        }

        // Exit early if overflow is visible
        const overflow = $(this).css('overflow');
        if (overflow === 'visible' || overflow === 'inherit') {
            return;
        }

        // Check whether element is overflown
        const overflown = this.scrollHeight > this.clientHeight || this.scrollWidth > this.clientWidth;
        if (overflow === 'hidden' && overflown) {
            elements.push(this);
            return;
        }

        // Check if menu fits inside element
        const offs = $(this).offset();
        const w    = $(this).width();
        const h    = $(this).height();

        if (offset.left + menu_width > offs.left + w) {
            elements.push(this);
        } else if (offset.top + menu_height > offs.top + h) {
            elements.push(this);
        }
    });

    return elements;
}

/**
 * Scroll handler for all scroll related events.
 * This will reposition the menu(s) according to the scrolled distance.
 */
function scrollHandler(event) {
    const data = $(event.target).data('action-menu-scroll-data');

    const diff_x = event.target.scrollLeft - data.left;
    const diff_y = event.target.scrollTop - data.top;

    data.menus.forEach((menu) => {
        const offset = menu.offset();
        menu.offset({
            left: offset.left - diff_x,
            top: offset.top - diff_y
        });
    });

    data.left = event.target.scrollLeft;
    data.top  = event.target.scrollTop;

    $(event.target).data('action-menu-scroll-data', data);
}

const stash  = new Map();
const secret = typeof Symbol === 'undefined' ?  Math.random().toString(36).substring(2, 15) : Symbol();

class ActionMenu {
    /**
     * Create menu using a singleton pattern for each element.
     */
    static create(element, position = true) {
        const id = $(element).uniqueId().attr('id');
        const breakpoint = determineBreakpoint(element);
        if (!stash.has(id)) {
            const menu_offset = $(element).offset().top + $('.action-menu-content', element).height();
            const max_offset = $(breakpoint).offset().top + $(breakpoint).height();
            const reversed = menu_offset > max_offset;

            stash.set(id, new ActionMenu(secret, element, reversed, position));
        }

        return stash.get(id);
    }

    /**
     * Closes all menus.
     * @return {[type]} [description]
     */
    static closeAll() {
        stash.forEach((menu) => menu.close());
    }

    /**
     * Private constructor by implementing the secret/passed_secret mechanism.
     */
    constructor(passed_secret, element, reversed, position) {
        // Enforce use of create (would use a private constructor if I could)
        if (secret !== passed_secret) {
            throw new Error('Cannot create ActionMenu. Use ActionMenu.create()!');
        }

        const offset     = $(element).offset();
        const height     = $('.action-menu-content').height();
        const width      = $('.action-menu-content').width();
        const breakpoint = determineBreakpoint(element);

        this.element = $(element);
        this.menu = this.element;
        this.content = $('.action-menu-content', element);
        this.is_reversed = reversed;
        this.is_open = false;

        const menu_width  = this.content.width();
        const menu_height = this.content.height();

        // Reposition the menu?
        if (position) {
            var parents = getScrollableParents(this.element, menu_width, menu_height);
            if (parents.length > 0) {
                this.menu = $('<div class="action-menu-wrapper">').append(this.content.remove());
                $('.action-menu-icon', element).clone().data('action-menu-element', element).prependTo(this.menu);

                this.menu
                    .offset(this.element.offset())
                    .appendTo(breakpoint);

                // Always add breakpoint
                parents.push(breakpoint);
                parents.forEach((parent, index) => {
                    var data = $(parent).data('action-menu-scroll-data') || {
                        menus: [],
                        left: parent.scrollLeft,
                        top: parent.scrollTop
                    };
                    data.menus.push(this.menu);

                    $(parent).data('action-menu-scroll-data', data);

                    if (data.menus.length < 2) {
                        $(parent).scroll(scrollHandler);
                    }
                });
            }
        }

        this.update();
    }

    /**
     * Adds a class to the menu's element.
     */
    addClass(name) {
        this.menu.addClass(name);
    }

    /**
     * Open the menu.
     */
    open() {
        this.toggle(true);
    }

    /**
     * Close the menu.
     */
    close() {
        this.toggle(false);
    }

    /**
     * Toggle the menus display state. Pass a state to enforce it.
     */
    toggle(state = null) {
        this.is_open = state === null ? !this.is_open : state;

        this.update();
    }

    /**
     * Update the menu element's attributes.
     */
    update() {
        this.element.toggleClass('is-open', this.is_open);

        this.menu.toggleClass('is-open', this.is_open);
        this.menu.toggleClass('is-reversed', this.is_reversed);
Moritz Strohm's avatar
Moritz Strohm committed
        this.menu.find('.action-menu-icon').attr('aria-expanded', this.is_open ? 'true' : 'false');
    }

    /**
     * Confirms an action in the action menu that calls a JavaScript function
     * instead of linking to another URL.
     */
    confirmJSAction(element = null) {
        //Show visual hint using a deferred. This way we don't need to
        //duplicate the functionality in the done() handler.
        //(code copied from copyable_link.js and modified)
        (new Promise((resolve, reject) => {
            var confirmation = $('<div class="js-action-confirmation">');
            confirmation.text = jQuery(element).data('confirmation_text');
            confirmation.insertBefore(element);
            jQuery(element).parent().addClass('js-action-confirm-animation');
            var timeout = setTimeout(() => {
                jQuery(element).parent().off('animationend');
                resolve(confirmation);
            }, 1500);
            jQuery(element).parent().one('animationend', () => {
                clearTimeout(timeout);
                resolve(confirmation);
            });
        })).then((confirmation, parent) => {
            confirmation.remove();
            jQuery(element).parent().removeClass('js-action-confirm-animation');
        });
    }
}

export default ActionMenu;