DaveBitter
3/1/2019 - 2:23 PM

Animated grid modal

Animated grid modal

// Helpers
const setAsyncTimeout = (cb, timeout = 0) =>
    new Promise(resolve => setTimeout(() => {
        cb();
        resolve();
    }, timeout));

// Constants
const constants = {
    attributes: {
        DATA_TRANSITIONING: 'data-transitioning',
        DATA_IS_MODAL: 'data-is-modal',
        DATA_GRID: 'data-grid',
        DATA_GRID_ITEM: 'data-grid-item',
        DATA_GRID_ITEM_CLOSE: 'data-grid-item-close'
    },
    directions: {
        GROW: 'grow',
        SHRINK: 'shrink'
    }
};

class GridModal {
    constructor(element, options) {
        this._element = element;
        this._options = options;

        this._init();
    }

    _state = {
        activeItem: null
    }

    /**
     * Set bounds for node
     * @param {Node} node - Node to set bounds for
     */
    _setBounds(node, bounds) {
        node.style = `
            position: fixed;
            top: 0;
            left: 0;
            transform: translate3d(${bounds.x}px, ${bounds.y}px, 0);
            max-height: ${bounds.height}px;
            max-width: ${bounds.width}px;
        `;
    }

    /**
     * Animate node based on bounds and direction
     * @param {Node} node - Node to animate
     * @param {Node} from - Starting bounds
     * @param {Node} to - Ending bounds
     * @param {Node} direction - Direction of animation (grow/shrink)
     */
    async _animate(node, from, to, direction) {
        // Set start bounds
        this._setBounds(node, from);

        // Add transition
        await setAsyncTimeout(() => { node.setAttribute(constants.attributes.DATA_TRANSITIONING, ''); }, 0);

        // Set end bounds
        await setAsyncTimeout(() => { this._setBounds(node, to); }, 0);

        // Clean up animation
        await setAsyncTimeout(() => {
            node.removeAttribute(constants.attributes.DATA_TRANSITIONING);
            node[direction === constants.directions.GROW ? 'setAttribute' : 'removeAttribute'](constants.attributes.DATA_IS_MODAL, '');
            node.style = null;
        }, 250);
    }

    /**
     * Get bounds for shrink animation and call animate
     */
    _shrink() {
        const from = this._state.activeItem.getBoundingClientRect();
        const to = this._state.activeItem.parentNode.getBoundingClientRect();

        this._animate(this._state.activeItem, from, to, constants.directions.SHRINK);
    }

    /**
     * Get bounds for grow animation and call animate
     */
    _grow() {
        const from = this._state.activeItem.getBoundingClientRect();
        const to = {
            x: 0,
            y: 0,
            width: window.innerWidth,
            height: window.innerHeight
        }

        this._animate(this._state.activeItem, from, to, constants.directions.GROW);
    }

    /**
     * Handle click for grid item
     * @param {Node} target - Target of clicked node
     */
    _handleClick({ target }) {
        if (target.hasAttribute(constants.attributes.DATA_GRID_ITEM)) {
            this._state.activeItem = target;
            this._grow();
        }

        if (target.hasAttribute(constants.attributes.DATA_GRID_ITEM_CLOSE)) { this._shrink(); }
    }

    /**
     * Inititalize all event listeners
     */
    _addEventListeners() {
        this._grid.addEventListener('click', this._handleClick.bind(this));
    }

    /**
     * Cache all selectors for further use
     */
    _cacheSelectors() {
        this._grid = this._element.querySelector(`[${constants.attributes.DATA_GRID}]`);
    }

    /**
     * Inititalize component
     */
    _init() {
        this._cacheSelectors();
        this._addEventListeners();
    }
}

export default GridModal;
{
    "items": [
        {},
        {},
        {},
        {},
        {},
        {},
        {},
        {},
        {},
        {},
        {},
        {}
    ]
}
<div class="grid-modal" data-module="examples/grid-modal/GridModal">
    <div class="grid-modal__grid" data-grid>
        {% for item in items %}
            <div class="grid-modal__grid-item">
                <div class="grid-modal__grid-item-content" data-grid-item>
                    <img class="grid-modal__grid-item-image" src="/static/img/placeholders/ny.jpg"></img>
                    <button class="grid-modal__grid-item-close" data-grid-item-close>
                        <svg class="svg svg--close" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
                            <path d="M8 5.9130435L2.086957 0 0 2.0869565 5.913043 8 0 13.9130435 2.086957 16 8 10.0869565 13.913043 16 16 13.9130435 10.086957 8 16 2.0869565 13.913043 0z" />
                        </svg>
                    </button>
                </div>
            </div>
        {% endfor %}
    </div>
</div>
.grid-modal {
    &__grid {
        position: relative;
        display: flex;
        flex-direction: column;

        @include mq($mq-mob) {
            flex-direction: row;
            flex-wrap: wrap;
        }

        &-item {
            height: 200px;
            margin: $spacing-sml;

            @include mq($mq-mob) {
                width: calc(50% - (2 * #{$spacing-sml}));
            }

            @include mq($mq-tab--sml) {
                width: calc((100%/3) - (2 * #{$spacing-sml}));
            }

            &-content {
                position: relative;
                width: 100%;
                height: 100%;

                &[data-is-modal],
                &[data-transitioning] {
                    z-index: 100;
                }

                &[data-transitioning] {
                    transition:
                        max-width $transition-timing-med $transition-easing-cubic,
                        max-height $transition-timing-med $transition-easing-cubic,
                        transform $transition-timing-med $transition-easing-cubic;
                }

                &[data-is-modal] {
                    position: fixed;
                    top: 0;
                    right: 0;
                    bottom: 0;
                    left: 0;
                }
            }

            &-image {
                width: 100%;
                height: 100%;
                object-fit: cover;
                pointer-events: none;
            }

            &-close {
                z-index: 1;
                display: none;
                position: absolute;
                top: $spacing-med;
                right: $spacing-med;
                width: 50px;
                height: 50px;
                background-color: transparent;
                border: none;
                cursor: pointer;

                [data-is-modal] & {
                    display: block;
                }

                svg {
                    width: 16px;
                    height: 16px;
                    fill: white;
                    pointer-events: none;
                }
            }
        }
    }
}