Главная » Frontend » Тестовое задание: выпадающее меню на чистом javascript

Тестовое задание: выпадающее меню на чистом javascript

Два-три года назад откликнулся на вакансию фронтенд-разработчика контентных проектов в Mail.ru Group. В ответ стандартно получил просьбу выполнить тестовое задание. К сожалению, моего уровня оказалось недостаточно и мне пришёл отказ. Хочу поделиться тестовым заданием и своим кодом, который был написан в ходе его выполнения. Возможно кому-то пригодится такое простенькое выпадающее меню на нативном javascript.

Итак, задание формулировалось следующим образом:

Реализовать идеальное, на твой взгляд, «выпадающее меню» (dropdown).
Пример/черновик верстки http://jsfiddle.net/jawLmp4x/
Минимальные требования:
0. Никаких библиотек, только нативный js
1. Возможность отследить раскрытие/скрытие «выпадашки» и выбор элемента
2. Настройка раскрытия/скрытия по нажатию или наведению
Всё остальное на твое усмотрение, можно просто описать словами, что бы ты ещё улучшил, какую функциональность добавил и так далее.
P.S. Поддержка браузерами не имеет значения

Оценив задание, мне стало понятно, что для «идеальности» нужно выполнить весьма немало требований. За вечер успел накидать только базовый вариант без дополнительных возможностей. А утром выслал его, сопроводив описание остальных улучшений следующими словами.

Основное требование к любому компоненту — возможность переиспользования, как в рамках одного проекта, так и за его пределами. Поэтому одним из главных критериев должна быть универсальность. Универсальность выпадающего меню, на мой взгляд, можно рассматривать в следующих аспектах:

  1. Возможность использования нескольких экземпляров меню в рамках одной страницы.
  2. Возможность настройки через data-атрибуты (в частности: горизонтальное/вертикальное, события отображения вложенных уровней и т.д.).
  3. Независимость от количества пунктов меню.
  4. Независимость от количества уровней вложенности.
  5. Независимость javascript-кода от классов, отвечающих за стилизацию меню.
  6. Автоматическое перепозиционирование выпадающих плашек в случае нехватки места для отображения в стандартном месте вывода в пределах видимой области страницы (например, если меню находится в самом низу страницы, то открывать выпадающие плашки не вниз, а вверх).
  7. Кроссбраузерность.
  8. Адаптированность под мобильные устройства (как с точки зрения обработки соответствующих событий, так и с точки зрения отображения).
  9. Возможность подписки на события (открытие/закрытие/выбор).

Также возможно не до конца верно понял требования. Написал меню исходя из того, что в обоих вариантах (как при открытии по клику, так и при открытии плашек при наведении) пользователь должен выбрать пункт меню второго уровня, после чего выбранный пункт перекрашивается.

Пункт 1 из тестового задания рассматривал как сохранение текущего состояния меню, чтобы другие компоненты могли забрать эту информацию и использовать. В приведенном коде в экземпляре меню просто сохраняются индексы выбранных элементов. Реализацию подписки на события в тот вечер сделать не успел. Часть пунктов при этом могут быть обычными ссылками и ведут на другие страницы.

Пункт 2 был реализован засчёт data-атрибута data-event, который может принимать значения click или hover.

Итак, вставка горизонтального меню из нескольких пунктов, открывающееся по клику, выполняется с помощью следующего кода.

<div class="dd expanded" data-type="hor" data-event="click" id="horMenu">
    <div class="dd__item">
        <a href="#" class="dd__link">Menu</a>
        <ul class="dd__sub">
            <li class="dd__sub-item">item 1</li>
            <li class="dd__sub-item" disabled="true">item 2</li>
            <li class="dd__sub-item dd__sub-item__bold">item 3</li>
            <li class="dd__sub-item dd__sub-item__italic"><a href="//mail.ru/" target="_blank">item 4</a></li>
        </ul>
    </div><div class="dd__item">
        <span class="dd__link">Menu 2</span>
        <ul class="dd__sub">
            <li class="dd__sub-item">item 1</li>
            <li class="dd__sub-item" disabled="true">item 2</li>
            <li class="dd__sub-item dd__sub-item__bold">item 3</li>
            <li class="dd__sub-item dd__sub-item__italic"><a href="//mail.ru/" target="_blank">item 4</a></li>
        </ul>
    </div><div class="dd__item">
        <span class="dd__link">Menu 3</span>
        <ul class="dd__sub">
            <li class="dd__sub-item">item 1</li>
            <li class="dd__sub-item" disabled="true">item 2</li>
            <li class="dd__sub-item dd__sub-item__bold">item 3</li>
            <li class="dd__sub-item dd__sub-item__italic"><a href="//mail.ru/" target="_blank">item 4</a></li>
        </ul>
    </div>
</div>

А меню из одной кнопки с выпадающим списком при наведении таким:

<div class="dd expanded" data-type="ver" data-event="hover" id="verMenu">
    <div class="dd__item">
        <a href="#" class="dd__link">Menu</a>
        <ul class="dd__sub">
            <li class="dd__sub-item">item 1</li>
            <li class="dd__sub-item" disabled="true">item 2</li>
            <li class="dd__sub-item dd__sub-item__bold">item 3</li>
            <li class="dd__sub-item dd__sub-item__italic"><a href="//mail.ru/" target="_blank">item 4</a></li>
        </ul>
    </div>
</div>

Вот такая пара сотен строк кода получилась:

var app = {};

(function() {

    'use strict';

    app.nav = (function() {
        var _private = {},
            _public = {},
            _menu = document.getElementsByClassName('dd');

        _private.addEvent = function(el, type, handler) {
            if (el.addEventListener){
                el.addEventListener(type, handler, false)
            } else {
                el.attachEvent('on' + type, handler)
            }
        };

        // определение уровня элемента
        _private.getLevelItem = function(target) {
            var deep, aClasses, parent;

            aClasses = target.className.split(' ');
            for (var i = 0, len = aClasses.length; i < len; i++) {
                switch (aClasses[i]) {
                    // элемент первого уровня
                    case 'dd__link':
                        deep = 'parent';
                        parent = target.parentNode;
                        break;
                    // элемент второго уровня
                    case 'dd__sub-item':
                        deep = 'sub';
                        break;
                    default:
                }
            };
            return {
                deep: deep,
                parent: parent
            };
        };

        // проверка наличия класса, удаление при необходимости
        _private.checkHasClass = function(el, cl, dl) {
            var flag = false,
                aClasses;

            aClasses = el.className.split(' ');
            for (var i = 0, len = aClasses.length; i < len; i++) {
                if (aClasses[i] === cl) {
                    flag = true;
                    if (dl) {
                        aClasses.splice(i, 1);
                    }
                }
            };

            return {
                flag: flag,
                nameClass: aClasses.join(' ')
            }
        };

        // запись индекса активного пункта
        _private.writeIndex = function(flag, propertyName, els, current, id) {
            if (flag) {
                app.nav.navElements[id][propertyName] = -1;
            } else {
                for (var i = 0, len = els.length - 1 ; i <= len; i++) {
                    if (els[i] === current) {
                        app.nav.navElements[id][propertyName] = i;
                    }
                };
            }
        };

        // конструктор меню
        _private.Menu = function(el, type, event) {
            this.el = el;           // ссылка на элемент в DOM
            this.type = type;       // тип меню : горизонтальное или вертикальное
            this.event = event;     // событие срабатывания
            this.iItem = -1;        // индекс родительского элемента открытой плашки
                                    // если меньше 0, то все плашки скрыты
            this.iSub = -1;         // индекс выбранного элемента второго уровня
                                    // если меньше 0, то нет выбранного элемента
        };

        // скрытие выпадающих плашек
        _private.Menu.prototype.hideAll = function() {
            var menuItems = this.el.getElementsByClassName('dd__item');

            for (var i = menuItems.length - 1; i >= 0 ; i--) {
                menuItems[i].className = 'dd__item';
            };
        };

        // сброс выбранного пункта меню
        _private.Menu.prototype.resetChoose = function() {
            var menuSubItems = this.el.getElementsByClassName('dd__sub-item');
            for (var i = menuSubItems.length - 1; i >= 0 ; i--) {
                menuSubItems[i].className = _private.checkHasClass(menuSubItems[i], 'dd__sub-item__active', true).nameClass;
            };
        };

        // навешивание обработчиков
        _private.Menu.prototype.init = function() {
            var menuItems = this.el.getElementsByClassName('dd__item'),
                menuSub = this.el.getElementsByClassName('dd__sub'),
                events = [];

            if (this.event === 'click') {

                // обработчик на клик для пунктов меню первого уровня
                _private.addEvent(this.el, 'mousedown', function(e) {
                    var target = e.target,
                        childrenItems = target.parentNode.parentNode.getElementsByClassName('dd__item'),
                        childrenSubs,
                        level = _private.getLevelItem(target),
                        classActive,
                        menuId;

                    // если клик на элемент первого уровня
                    if (level.deep === 'parent') {

                        // получение
                        menuId = target.parentNode.parentNode.getAttribute('id');

                        // определение активности элемента
                        classActive = _private.checkHasClass(level.parent, 'dd__item__active', true);

                        // запись индекса открытой плашки
                        _private.writeIndex(classActive.flag, 'iItem', childrenItems, level.parent, menuId);

                        // скрытие всех плашек
                        app.nav.navElements[menuId].hideAll();

                        // обновление класса элемента
                        level.parent.className = classActive.nameClass;
                        if (!classActive.flag) {
                            level.parent.className = level.parent.className + ' dd__item__active';
                        }
                    }

                    e.preventDefault();
                });

                // скрытие выпадающих плашек при клике вне области меню
                _private.addEvent(document, 'mousedown', function(e) {

                    for (var menu in app.nav.navElements) {
                        if (app.nav.navElements[menu].event === 'click') {
                            app.nav.navElements[menu].hideAll();
                            app.nav.navElements[menu].iItem = -1;
                        }
                    }

                    e.stopPropagation();
                });
            }

            if (this.event === 'hover') {

                // обработчик на mouseover
                _private.addEvent(this.el, 'mouseover', function(e) {
                    var target = e.target,
                        childrenItems = target.parentNode.parentNode.getElementsByClassName('dd__item'),
                        level = _private.getLevelItem(target),
                        itemIsLink = target.nodeName === 'A',
                        classActive,
                        menuId,
                        parent;

                    // если элемент первого уровня
                    if (level.deep === 'parent') {

                        menuId = target.parentNode.parentNode.getAttribute('id');

                        // определение активности элемента
                        classActive = _private.checkHasClass(level.parent, 'dd__item__active', true);

                        _private.writeIndex(classActive.flag, 'iItem', childrenItems, level.parent, menuId);

                        // скрытие всех плашек
                        app.nav.navElements[menuId].hideAll();

                        // обновление класса элемента
                        level.parent.className = classActive.nameClass;
                        if (!classActive.flag) {
                            level.parent.className = level.parent.className + ' dd__item__active';
                        }
                    }

                    e.stopPropagation();
                });

                // обработчик на клик
                _private.addEvent(this.el, 'mousedown', function(e) {
                    var target = e.target;
                });

                // скрытие выпадающих плашек при клике вне области меню
                _private.addEvent(document, 'mouseover', function(e) {
                    for (var menu in app.nav.navElements) {
                        if (app.nav.navElements[menu].event === 'hover') {
                            app.nav.navElements[menu].hideAll();
                            app.nav.navElements[menu].iItem = -1;
                        }
                    }
                });
            }

            // обработчик выбора пунта меню второго уровня
            _private.addEvent(this.el, 'mousedown', function(e) {
                var target = e.target,
                    childrenSubs,
                    level = _private.getLevelItem(target),
                    itemIsLink = target.nodeName === 'A',
                    classActive,
                    parent,
                    menuId;

                // если клик на элемент второго уровня
                if ((level.deep === 'sub') || (_private.getLevelItem(target.parentNode).deep === 'sub')) {
                    if (!target.getAttribute('disabled')) {

                        if (itemIsLink) {
                            parent = target.parentNode.parentNode.parentNode.parentNode;
                        } else {
                            parent = target.parentNode.parentNode.parentNode;
                        }
                        menuId = parent.getAttribute('id');

                        // сброс
                        app.nav.navElements[menuId].resetChoose();

                        // обновление класса и запись индекса выбранного пункта
                        if (itemIsLink) {
                            target.parentNode.className = target.parentNode.className + ' dd__sub-item__active';
                            childrenSubs = target.parentNode.parentNode.getElementsByClassName('dd__sub-item');
                            _private.writeIndex(false, 'iSub', childrenSubs, level.parent, menuId);
                        } else {
                            target.className = target.className + ' dd__sub-item__active';
                            childrenSubs = target.parentNode.getElementsByClassName('dd__sub-item');
                            _private.writeIndex(false, 'iSub', childrenSubs, target, menuId);
                        }

                    } else {
                        e.preventDefault();
                    }
                }

                e.stopPropagation();
            });
        };

        // все меню на странице
        _public.navElements = {};

        // инициализация всех меню на странице
        _public.init = function() {

            for (var i = 0, len = _menu.length-1, id; i <= len; i++) {
                id = _menu[i].getAttribute('id');
                _public.navElements[id] = new _private.Menu(_menu[i], _menu[i].getAttribute('data-type'), _menu[i].getAttribute('data-event'));
                _public.navElements[id].init();
            }
        };

        return _public;
    })();

    app.nav.init();
})();
Понравилась статья? — Ставь лайк!

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

×
Новости и обзор новинок рынка строительной техники.
Подпишитесь на обновления нашей группы!