Source: components/sl-menu.js

import Ember from 'ember';
import StreamEnabled from 'ember-stream/mixins/stream-enabled';
import layout from '../templates/components/sl-menu';
import { warn } from '../utils/all';

/**
 * @module
 * @augments ember/Component
 * @augments ember-stream/mixins/stream-enabled
 */
export default Ember.Component.extend( StreamEnabled, {

    // -------------------------------------------------------------------------
    // Dependencies

    // -------------------------------------------------------------------------
    // Attributes

    /** @type {String[]} */
    classNameBindings: [
        'showingAll:show-all'
    ],

    /** @type {String[]} */
    classNames: [
        'sl-menu'
    ],

    /** @type {Object} */
    layout,

    /** @type {String} */
    tagName: 'div',

    // -------------------------------------------------------------------------
    // Actions

    /** @type {Object} */
    actions: {

        /**
         * Handle an action from a sub-menu item
         *
         * @function actions:handleAction
         * @param {String} actionName - The name of an action to pass up to the
         *        parent controller
         * @param {*} data - Any data to also pass up to the parent controller
         * @returns {undefined}
         */
        handleAction( actionName, data ) {
            this.sendAction( 'action', actionName, data );
        },

        /**
         * Trigger hiding all of the menu's sub-menus
         *
         * @function actions:hideAll
         * @returns {undefined}
         */
        hideAll() {
            this.hideAll();
        },

        /**
         * Trigger showing all the menu's sub-menus
         *
         * @function actions:showAll
         * @returns {undefined}
         */
        showAll() {
            this.showAll();
        }
    },

    // -------------------------------------------------------------------------
    // Events

    /**
     * mouseLeave event handler
     *
     * @function
     * @returns {undefined}
     */
    mouseLeave() {
        this.send( 'hideAll' );
    },

    /**
     * mouseMove event handler
     *
     * @function
     * returns {undefined}
     */
    mouseMove() {
        this.clearSelections();
    },

    // -------------------------------------------------------------------------
    // Properties

    /**
     * Whether to show a menu item to display all sub-menus
     *
     * @type {Boolean}
     */
    allowShowAll: false,

    /**
     * The array of menu items
     *
     * @type {?Object[]}
     */
    items: null,

    /**
     * An array of objects containing data about the selected states
     *
     * @private
     * @type {?ember/Array}
     */
    selections: null,

    /**
     * Whether to show all the menu's sub-items
     *
     * @private
     * @type {Boolean}
     */
    showingAll: false,

    // -------------------------------------------------------------------------
    // Observers

    /**
     * Initialize any computed properties that need setup
     *
     * @function
     * @returns {undefined}
     */
    initialize: Ember.on(
        'init',
        function() {
            this.set( 'selections', new Ember.A() );
        }
    ),

    /**
     * Setup the stream actions bindings
     *
     * @function
     * @returns {undefined}
     */
    setupStreamActions: Ember.on(
        'init',
        function() {
            const stream = this.get( 'stream' );

            if ( !stream ) {
                return;
            }

            stream.on( 'doAction', () => {
                this.doAction();
            });

            stream.on( 'hideAll', () => {
                this.hideAll();
            });

            stream.on( 'select', ( index ) => {
                this.select( index );
            });

            stream.on( 'selectDown', () => {
                this.selectDown();
            });

            stream.on( 'selectLeft', () => {
                this.selectLeft();
            });

            stream.on( 'selectNext', () => {
                this.selectNext();
            });

            stream.on( 'selectParent', () => {
                this.selectParent();
            });

            stream.on( 'selectPrevious', () => {
                this.selectPrevious();
            });

            stream.on( 'selectRight', () => {
                this.selectRight();
            });

            stream.on( 'selectSubMenu', () => {
                this.selectSubMenu();
            });

            stream.on( 'selectUp', () => {
                this.selectUp();
            });

            stream.on( 'showAll', () => {
                this.showAll();
            });
        }
    ),

    /**
     * Retrieve the currently selected item
     *
     * @function
     * @returns {?Object}
     */
    selectedItem: Ember.computed(
        'selections.@each.item',
        function() {
            const lastItem = this.get( 'selections.lastObject.item' );

            return lastItem ? lastItem : null;
        }
    ),

    // -------------------------------------------------------------------------
    // Methods

    /**
     * Clear the `selections` data
     *
     * @function
     * @returns {undefined}
     */
    clearSelections() {
        const selections = this.get( 'selections' );

        selections.forEach( ( selection ) => {
            Ember.set( selection.item, 'selected', false );
        });

        this.set( 'selections', new Ember.A() );
    },

    /**
     * Perform the currently selected item's `action`
     *
     * @function
     * @returns {undefined}
     */
    doAction() {
        const selectedItem = this.get( 'selectedItem' );

        if ( selectedItem ) {
            const action = Ember.get( selectedItem, 'action' );

            if ( action ) {
                this.sendAction( 'action', action, Ember.get( selectedItem, 'data' ) );
            }
        }
    },

    /**
     * Hide all the menu's sub-menus
     *
     * @function
     * @returns {undefined}
     */
    hideAll() {
        this.set( 'showingAll', false );
        this.clearSelections();
    },

    /**
     * Select an item by its index in the current selection context
     *
     * @function
     * @param {Number} index - The index of the item to select
     * @throws {ember/Error}
     * @returns {undefined}
     */
    select( index ) {
        if ( this.get( 'showingAll' ) ) {
            this.hideAll();
        }

        const selections = this.get( 'selections' );
        const selectionsLength = selections.length;
        let item;

        if ( selectionsLength > 0 ) {
            const selection = selections.objectAt( selectionsLength - 1 );

            if ( !selection ) {
                throw new Ember.Error( 'Current selection is undefined' );
            }

            const contextItems = selectionsLength > 1 ?
                Ember.get( selection, 'items' ) :
                this.get( 'items' );

            const currentItem = Ember.get( selection, 'item' );

            if ( !currentItem ) {
                throw new Ember.Error( 'Current item is undefined' );
            }

            item = contextItems.objectAt( index );

            if ( !item ) {
                return;
            }

            Ember.set( currentItem, 'selected', false );
            Ember.set( item, 'selected', true );
            Ember.setProperties( selection, { index, item });
        } else {
            const items = this.get( 'items' );

            if ( !items ) {
                throw new Ember.Error( 'Component `items` is undefined' );
            }

            if ( items.length > 0 && index < items.length ) {
                item = items[ index ];

                Ember.set( item, 'selected', true );
                selections.pushObject({ index, item, items });
            }
        }
    },

    /**
     * Select a menu item in the "down" direction
     *
     * At the top-level of the menu, "down" corresponds to opening and selecting
     * the first child in its sub-menu.
     * Inside a sub-menu, "down" corresponds to selecting the next sibling
     * menu item.
     *
     * @function
     * @returns {undefined}
     */
    selectDown() {
        const selectionsLength = this.get( 'selections' ).length;

        if ( 1 === selectionsLength ) {
            this.selectSubMenu();
        } else if ( selectionsLength > 1 ) {
            this.selectNext();
        } else {
            this.select( 0 );
        }
    },

    /**
     * Select a menu item in the "left" direction
     *
     * At the top-level of the menu, "left" corresponds to selecting the
     * previous sibling menu item.
     * Inside a sub-menu, "left" corresponds to parsing back to the parent item
     *
     * @function
     * @returns {undefined}
     */
    selectLeft() {
        const selectionsLength = this.get( 'selections' ).length;

        if ( 1 === selectionsLength || this.get( 'showingAll' ) ) {
            this.selectPrevious();
        } else if ( selectionsLength > 1 ) {
            this.selectParent();
        }
    },

    /**
     * Select the next sibling in the current context
     *
     * @function
     * @throws {ember/Error}
     * @returns {undefined}
     */
    selectNext() {
        const selections = this.get( 'selections' );

        // Select the first item from `items` if nothing is currently selected
        if ( selections.length < 1 ) {
            if ( this.get( 'showingAll' ) ) {
                this.hideAll();
            }

            this.select( 0 );
            return;
        }

        const selection = selections.objectAt( selections.length - 1 );
        const currentItems = Ember.get( selection, 'items' );

        if ( !currentItems ) {
            throw new Ember.Error( 'Current selection items are undefined' );
        }

        const currentIndex = Ember.get( selection, 'index' );

        if ( 'number' !== Ember.typeOf( currentIndex ) ) {
            throw new Ember.Error( 'Current index is not valid' );
        }

        // Select the "show all" option if we're on the last context item at the
        // top level, and the `allowShowAll` is enabled
        if (
            1 === selections.length &&
            currentItems.length - 1 === currentIndex &&
            this.get( 'allowShowAll' )
        ) {
            this.clearSelections();
            this.showAll();
            return;
        }

        const currentItem = Ember.get( selection, 'item' );

        if ( !currentItem ) {
            throw new Ember.Error( 'Current item is undefined' );
        }

        let newIndex = currentIndex + 1;

        if ( newIndex >= currentItems.length ) {
            newIndex -= currentItems.length;
        }

        const item = currentItems[ newIndex ];

        if ( !item ) {
            throw new Ember.Error( `Item with index ${newIndex} is undefined` );
        }

        Ember.set( currentItem, 'selected', false );
        Ember.set( item, 'selected', true );

        Ember.setProperties( selection, {
            index: newIndex,
            item
        });
    },

    /**
     * Select the parent menu from the current context
     *
     * @function
     * @throws {ember/Error}
     * @returns {undefined}
     */
    selectParent() {
        const selections = this.get( 'selections' );

        if ( selections.length <= 1 ) {
            warn( '`selectParent` triggered with no parent context' );
        }

        const currentItem = Ember.get( selections.popObject(), 'item' );

        if ( !currentItem ) {
            throw new Ember.Error( 'Invalid last menu item' );
        }

        Ember.set( currentItem, 'selected', false );
    },

    /**
     * Select the previous sibling in the current context
     *
     * @function
     * @throws {ember/Error}
     * @returns {undefined}
     */
    selectPrevious() {
        const selections = this.get( 'selections' );

        // Check if we're at the top-level context
        if ( selections.length < 1 ) {
            // Trigger "show all" if allowed to
            if ( this.get( 'allowShowAll' ) && !this.get( 'showingAll' ) ) {
                this.showAll();
            } else {
                // Otherwise, select the last item in the context
                this.hideAll();
                this.select( this.get( 'items' ).length - 1 );
            }

            return;
        }

        const selection = selections.objectAt( selections.length - 1 );
        const currentItems = Ember.get( selection, 'items' );

        if ( !currentItems ) {
            throw new Ember.Error( 'Current items are undefined' );
        }

        // Select the "show all" option when at the beginning of the top-level
        // and `allowShowAll` is enabled
        if (
            1 === selections.length &&
            0 === selection.index &&
            this.get( 'allowShowAll' )
        ) {
            this.clearSelections();
            this.showAll();
            return;
        }

        if ( currentItems.length < 2 ) {
            warn( '`selectPrevious` triggered with no siblings in context' );
            return;
        }

        const currentIndex = Ember.get( selection, 'index' );

        if ( 'number' !== Ember.typeOf( currentIndex ) ) {
            throw new Ember.Error( 'Current index is not valid' );
        }

        const currentItem = Ember.get( selection, 'item' );

        if ( !currentItem ) {
            throw new Ember.Error( 'Current item is undefined' );
        }

        let newIndex = currentIndex - 1;

        if ( newIndex < 0 ) {
            newIndex += currentItems.length;
        }

        const item = currentItems[ newIndex ];

        if ( !item ) {
            throw new Ember.Error( `Item with index ${newIndex} is undefined` );
        }

        Ember.set( currentItem, 'selected', false );
        Ember.set( item, 'selected', true );

        Ember.setProperties( selection, {
            index: newIndex,
            item
        });
    },

    /**
     * Select a menu item in the "right" direction
     *
     * When at the top-level of the menu, "right" corresponds to the next
     * sibling item.
     * When inside a sub-menu, "right" corresponds to entering its sub-menu, if
     * it has one.
     *
     * @function
     * @throws {ember/Error}
     * @returns {undefined}
     */
    selectRight() {
        const selections = this.get( 'selections' );

        if ( 1 === selections.length || this.get( 'showingAll' ) ) {
            this.selectNext();
        } else if ( selections.length > 1 ) {
            this.selectSubMenu();
        }
    },

    /**
     * Select the sub-menu in the current context
     *
     * @function
     * @throws {ember/Error}
     * @returns {undefined}
     */
    selectSubMenu() {
        const selections = this.get( 'selections' );

        if ( selections.length < 1 ) {
            return;
        }

        const selection = selections.get( selections.length - 1 );

        if ( !selection ) {
            throw new Ember.Error( 'Last item of `selection` is invalid' );
        }

        const currentItem = Ember.get( selection, 'item' );

        if ( !currentItem ) {
            throw new Ember.Error( 'Last selection menu item is invalid' );
        }

        const items = Ember.get( currentItem, 'items' );

        if ( !items ) {
            return;
        }

        const index = 0;
        const item = items[ index ];

        if ( !item ) {
            throw new Ember.Error( 'First item in selected sub-menu is undefined' );
        }

        Ember.set( item, 'selected', true );

        selections.pushObject({
            index,
            item,
            items
        });
    },

    /**
     * Select a menu item in the "up" direction
     *
     * When at the top level, "up" corresponds to no action.
     * When in the first sub-menu and on the first item, "up" corresponds to
     * selecting the top level.
     * When in any other sub-menu, "up" corresponds to selecting the previous
     * sibling menu item.
     *
     * @function
     * @throws {ember/Error}
     * @returns {undefined}
     */
    selectUp() {
        const selections = this.get( 'selections' );
        const selectionsLength = selections.length;

        // Do nothing if there is no parent context
        if ( selectionsLength < 2 ) {
            return;
        }

        // Check if the selection is in a first-level sub-menu
        if ( 2 === selectionsLength ) {
            const selection = selections.get( 1 );

            if ( 0 === Ember.get( selection, 'index' ) ) {
                this.selectParent();
                return;
            }
        }

        // In any other sub-menu level, cycle through siblings
        this.selectPrevious();
    },

    /**
     * Trigger the showAll menu-item
     *
     * @function
     * @returns {undefined}
     */
    showAll() {
        this.set( 'showingAll', true );
    }

});